diff --git a/api/models/manifest.py b/api/models/manifest.py index 6f60c8acc..ae0fd5a78 100644 --- a/api/models/manifest.py +++ b/api/models/manifest.py @@ -194,17 +194,6 @@ class CommitLeader(BaseModel): date: str -# Both fields required-nullable: the scanner always emits them (null for a repo -# with no commits), so they're present-but-nullable on the wire, not optional. -class CommitDateRange(BaseModel): - oldest: Optional[str] = Field( - description="Oldest commit date (YYYY-MM-DD), or null when the repo has no commits" - ) - newest: Optional[str] = Field( - description="Newest commit date (YYYY-MM-DD), or null when the repo has no commits" - ) - - class DayLeader(BaseModel): date: str count: int @@ -252,7 +241,6 @@ class RepoStats(BaseModel): commitCount: int = Field( description="Commits in the full history, however many `commits` carries" ) - commitDates: CommitDateRange maxCommitsPerDay: Optional[DayLeader] maxCommitStreakDays: int authors: list[AuthorStat] @@ -306,13 +294,6 @@ class SignatureResponse(BaseModel): content_signature: str -class DateRangeMs(BaseModel): - minCreated: int - maxCreated: int - minModified: int - maxModified: int - - class TimelineChange(BaseModel): path: str sha: Optional[str] = Field(description="New blob sha, or null when deleted") @@ -340,7 +321,6 @@ class TimelineBundle(BaseModel): blobLines: dict[str, Optional[int]] blobSizes: dict[str, Optional[int]] commitLineRanges: list[RangeStat] - commitDateRanges: list[DateRangeMs] notes: list[str] diff --git a/api/scan/stats.py b/api/scan/stats.py index 7ff5aeb63..bcf8f30a3 100644 --- a/api/scan/stats.py +++ b/api/scan/stats.py @@ -11,7 +11,6 @@ from api.models.manifest import ( AuthorStat, BusynessThresholds, - CommitDateRange, CommitEntry, CommitLeader, DayLeader, @@ -242,7 +241,6 @@ def compute_repo_stats(tree: DirNode, commits: list[CommitEntry]) -> RepoStats: newest_dir = d grandest = sparsest = None # commits, by files changed - oldest_commit = newest_commit = None # commit-date range # The walk yields oldest first, so the ends ARE the leaders. Comparing dates # instead would tie on a busy day and keep the first, which is not newest. first = commits[0] if commits else None @@ -259,10 +257,6 @@ def compute_repo_stats(tree: DirNode, commits: list[CommitEntry]) -> RepoStats: # multi-counting. 0 until manifest-wrap bakes it; read that as 1. d = day_of(c.date) day_totals[d] = max(day_totals.get(d, 0), c.same_day_total or 1) - if oldest_commit is None or d < oldest_commit: - oldest_commit = d - if newest_commit is None or d > newest_commit: - newest_commit = d busiest_day: DayLeader | None = None if day_totals: @@ -320,7 +314,6 @@ def _commit_leader(c: Optional[CommitEntry]) -> CommitLeader | None: newestCommit=_commit_leader(last), commitCount=len(commits), # YYYY-MM-DD sorts lexically in chronological order, so min/max are exact. - commitDates=CommitDateRange(oldest=oldest_commit, newest=newest_commit), maxCommitsPerDay=busiest_day, maxCommitStreakDays=_longest_streak(list(day_totals.keys())), authors=authors, diff --git a/api/scan/timeline.py b/api/scan/timeline.py index da9c33400..f752bdace 100644 --- a/api/scan/timeline.py +++ b/api/scan/timeline.py @@ -20,7 +20,6 @@ from api.core.constants import TimelineStage from api.models.manifest import ( CommitEntry, - DateRangeMs, FileNode, Manifest, RangeStat, @@ -31,7 +30,6 @@ from api.scan.blobmeta import MISSING_BLOB, blob_file_node, resolve_blob_stats from api.scan.manifest import wrap_manifest from api.core.progress import Throttle, log -from api.utils.dates import iso_to_ms from api.core.exceptions import NotAGitRepoError from api.scan.signatures import derive_tree_signals, new_signature from api.scan.skiprules import SkipRules @@ -45,9 +43,9 @@ _HISTORY_HEARTBEAT_EVERY = 2000 # commits between progress ticks -# Four steps, a quarter of the percent each. The last is the router's: +# Three steps, a third of the percent each. The last is the router's: # serialising the bundle is the step nothing else can report. -ASSEMBLE_STEPS = 4 +ASSEMBLE_STEPS = 3 # Commits between ticks inside a step that reports its own progress. _ASSEMBLE_HEARTBEAT_EVERY = 200 @@ -290,81 +288,6 @@ def compute_commit_line_ranges( return ranges -def compute_commit_date_ranges( - deltas: list[CommitDelta], - commits: list[CommitEntry], - git_created: dict[str, str], - git_modified: dict[str, str], - *, - on_commit: Callable[[int, int], None] | None = None, -) -> list[DateRangeMs]: - """Per-commit created/modified ms ranges over the files present at each - commit; range[HEAD] equals the live manifest's dateRanges (weathering - normalizes against these). replay.ts walks the same deltas for a different - output (per-frame scrub index) — neither is a copy of the other.""" - commit_ms = [iso_to_ms(c.date) or 0 for c in commits] - # Parsed once per path, not per (commit, path): the loop below runs ~98M - # times on a big repo, and re-parsing there is what made this the slow step. - created_ms = {p: iso_to_ms(v) for p, v in git_created.items()} - modified_ms = {p: iso_to_ms(v) for p, v in git_modified.items()} - - final_idx: dict[str, int] = {} - genesis_idx: dict[str, int] = {} - for i, delta in enumerate(deltas): - for path, sha in delta.changes: - final_idx[path] = i - if sha is not None and path not in genesis_idx: - genesis_idx[path] = i - - present: set[str] = set() - last_change: dict[str, int] = {} - ranges: list[DateRangeMs] = [] - for i, delta in enumerate(deltas): - for path, sha in delta.changes: - last_change[path] = i - if sha is None: - present.discard(path) - else: - present.add(path) - - min_created = min_modified = None - max_created = max_modified = None - for path in present: - lm = last_change.get(path, 0) - modified = None - if lm >= final_idx.get(path, 0): - modified = modified_ms.get(path) - if modified is None: - modified = commit_ms[lm] if lm < len(commit_ms) else 0 - created = created_ms.get(path) - if created is None: - gi = genesis_idx.get(path, 0) - created = commit_ms[gi] if gi < len(commit_ms) else 0 - - if min_created is None or created < min_created: - min_created = created - if max_created is None or created > max_created: - max_created = created - if min_modified is None or modified < min_modified: - min_modified = modified - if max_modified is None or modified > max_modified: - max_modified = modified - - # An empty present set collapses to a zero span, which the client already - # treats as "no spread" (freshest / newest). - ranges.append( - DateRangeMs( - minCreated=min_created or 0, - maxCreated=max_created or 0, - minModified=min_modified or 0, - maxModified=max_modified or 0, - ) - ) - if on_commit is not None and i % _ASSEMBLE_HEARTBEAT_EVERY == 0: - on_commit(i, len(deltas)) - return ranges - - def build_timeline_bundle( root: str, source: SourceRef, @@ -450,16 +373,6 @@ def build_timeline_bundle( ) assemble_tick(on_progress, 2) # per-commit line ranges commit_line_ranges = compute_commit_line_ranges(deltas, blob_lines) - # The long one: it walks every file present at every commit, so it reports - # from inside rather than sitting on its own step for minutes. - assemble_tick(on_progress, 3) - commit_date_ranges = compute_commit_date_ranges( - deltas, - commits, - history.created, - history.modified, - on_commit=lambda i, total: assemble_tick(on_progress, 3, i / max(total, 1)), - ) log("timeline bundle complete") return TimelineBundle( @@ -475,6 +388,5 @@ def build_timeline_bundle( blobLines=blob_lines, blobSizes=blob_sizes, commitLineRanges=commit_line_ranges, - commitDateRanges=commit_date_ranges, notes=notes, ) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index be52d7dac..f49999607 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -545,7 +545,6 @@ def make_manifest(src: str = "/repo", **overrides: Any) -> Manifest: "dirtyFileCount": 0, "codeBytes": 0, "commitCount": 0, - "commitDates": {"oldest": None, "newest": None}, "maxCommitStreakDays": 0, "authors": [], }, @@ -565,7 +564,6 @@ def make_timeline_bundle(**overrides: Any) -> TimelineBundle: "blobLines": {}, "blobSizes": {}, "commitLineRanges": [], - "commitDateRanges": [], "notes": [], } return TimelineBundle.model_validate({**fields, **overrides}) diff --git a/api/tests/scan/test_stats.py b/api/tests/scan/test_stats.py index a643c893d..576330ff1 100644 --- a/api/tests/scan/test_stats.py +++ b/api/tests/scan/test_stats.py @@ -3,7 +3,6 @@ from api.models.manifest import ( AuthorStat, BusynessThresholds, - CommitDateRange, CommitLeader, RangeStat, ) @@ -178,7 +177,6 @@ def test_commit_leaders_authors_and_streak(): assert s.newestCommit.sha == "ddd" assert s.newestCommit.date == "2022-02-10" assert s.minFilesPerCommit == CommitLeader(sha="ccc", files=1, date="2022-01-03") - assert s.commitDates == CommitDateRange(oldest="2022-01-01", newest="2022-02-10") assert s.maxCommitsPerDay.count == 2 assert s.maxCommitStreakDays == 3 assert s.authors[0] == AuthorStat(name="Ada", commits=3, hue=_author_hue("Ada")) @@ -203,8 +201,6 @@ def test_newest_commit_is_the_last_one_that_day(): assert s.newestCommit.sha == "ccc" assert s.oldestCommit.sha == "aaa" - # The day range is still a day, whatever the timestamps carry. - assert s.commitDates == CommitDateRange(oldest="2026-08-15", newest="2026-08-15") def test_empty_tree_and_no_commits(): @@ -213,7 +209,6 @@ def test_empty_tree_and_no_commits(): assert s.maxFilesPerCommit is None assert s.maxCommitStreakDays == 0 assert s.authors == [] - assert s.commitDates == CommitDateRange(oldest=None, newest=None) assert s.lineCountRange == RangeStat(min=0, max=0) assert s.mediaCount == 0 diff --git a/api/tests/scan/test_timeline.py b/api/tests/scan/test_timeline.py index 85bda6a4c..edd3c85f9 100644 --- a/api/tests/scan/test_timeline.py +++ b/api/tests/scan/test_timeline.py @@ -4,7 +4,7 @@ import os import subprocess from pathlib import Path -from api.models.manifest import DateRangeMs, RangeStat +from api.models.manifest import RangeStat from api.scan.timeline import walk_deltas from api.git import SourceRef @@ -350,69 +350,6 @@ def test_head_line_range_matches_live_scan(tmp_path: Path) -> None: assert ranges[-1] == live_range # Timeline HEAD == Live -def test_head_date_range_matches_live_scan(tmp_path: Path) -> None: - """The date analog of the line-range contract: commitDateRanges[-1] (HEAD) - equals the live scan's dateRanges for the same repo, so weathering at HEAD - normalizes identically. Compared as epoch ms, which is what the client - consumes.""" - from api.scan.timeline import compute_commit_date_ranges, walk_deltas - from api.utils.dates import iso_to_ms - from api.git.meta import collect_git_history - from api.scan.scanner import scan_tree - - _init(tmp_path) - (tmp_path / "a.txt").write_text("one\n") - _commit(tmp_path, "c1") - (tmp_path / "b.txt").write_text("two\n") - _commit(tmp_path, "c2") - (tmp_path / "a.txt").write_text("one\nmore\n") - _commit(tmp_path, "c3") - - live = None - for ev in scan_tree(str(tmp_path), SourceRef(str(tmp_path)), use_cache=False): - if ev.phase == "manifest-complete": - live = ev.manifest - assert live is not None - live_dates = live.dateRanges - - deltas = walk_deltas(tmp_path) - git_created, git_modified, commits = collect_git_history(tmp_path, use_cache=False) - ranges = compute_commit_date_ranges(deltas, commits, git_created, git_modified) - - assert ranges[-1] == DateRangeMs( - minCreated=iso_to_ms(live_dates.minCreated), - maxCreated=iso_to_ms(live_dates.maxCreated), - minModified=iso_to_ms(live_dates.minModified), - maxModified=iso_to_ms(live_dates.maxModified), - ) - # One range per commit, and the created span never runs backwards. - assert len(ranges) == len(deltas) - assert all(r.minCreated <= r.maxCreated for r in ranges) - - -def test_commit_date_ranges_track_the_present_set(tmp_path: Path) -> None: - """A deleted file drops out of the range at the commit that removed it.""" - from api.scan.timeline import compute_commit_date_ranges, walk_deltas - from api.git.meta import collect_git_history - - _init(tmp_path) - (tmp_path / "old.txt").write_text("x\n") - _commit(tmp_path, "c1") - (tmp_path / "new.txt").write_text("y\n") - _commit(tmp_path, "c2") - (tmp_path / "old.txt").unlink() - _commit(tmp_path, "c3") - - deltas = walk_deltas(tmp_path) - git_created, git_modified, commits = collect_git_history(tmp_path, use_cache=False) - ranges = compute_commit_date_ranges(deltas, commits, git_created, git_modified) - - # At c1 only old.txt exists, so the span is a single instant. - assert ranges[0].minCreated == ranges[0].maxCreated - # At HEAD only new.txt survives, so its creation is both ends again. - assert ranges[-1].minCreated == ranges[-1].maxCreated - - def test_bundle_replay_matches_reconstruct(tmp_path: Path) -> None: """Replaying deltas[0..i] reproduces reconstruct_manifest's file set + lines at that ref — ties the bundle to the proven phase-1 reconstruction. The diff --git a/api/tests/test_models.py b/api/tests/test_models.py index 0e7606785..be3893a27 100644 --- a/api/tests/test_models.py +++ b/api/tests/test_models.py @@ -148,7 +148,6 @@ def test_manifest_serializes(self) -> None: "oldestCommit": None, "newestCommit": None, "commitCount": 0, - "commitDates": {"oldest": None, "newest": None}, "maxCommitsPerDay": None, "maxCommitStreakDays": 0, "authors": [], diff --git a/app/src/App.tsx b/app/src/App.tsx index b6c854c0e..319435045 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,50 +1,84 @@ -// App.tsx — the composition root: the routes, and the few reactions -// that outlive any one of them. Each view owns its own layout, its own hooks, -// and the canvas it mounts. +// App.tsx — the composition root: the focused project, the routes, and the few +// reactions that outlive any one of them. The session is created here and +// provided to everything below, so the chrome reads one project rather than a +// global. A side-by-side view would create several and provide one per column. import './App.css'; -import { useEffect } from 'preact/hooks'; +import { useEffect, useMemo } from 'preact/hooks'; import { useSignalEffect } from '@preact/signals'; import { Router, Route, Switch, Redirect } from 'wouter-preact'; import { HomeView } from '@/views/HomeView/HomeView'; import { CityView } from '@/views/CityView/CityView'; -import { SOURCE_ERROR } from '@/state/stores/source'; +import { CitySession } from '@/city/session/session'; +import { CityProvider } from '@/city/CityProvider'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; -import { useManifestSource } from '@/hooks/useManifestSource'; -import { attachOverlayDriver } from '@/state/stores/progress'; +import { useLiveUpdates } from '@/city/session/loader'; +import { useServerData } from '@/state/stores/serverData'; +import { attachUrlBinding } from '@/router/urlBinding'; +import { attachRecents } from '@/state/stores/recents'; +import { SessionChrome } from '@/state/cityChrome'; +import { isDebugMode } from '@/utils/debugMode'; import { navigate, attachRouteHistory, useRouteLocation, useRouteSearch } from '@/router/location'; import { ROUTES } from '@/router/paths'; export function App() { - // The title spans both routes; everything a single view needs is mounted by - // that view. - useDocumentTitle(); - useManifestSource(); + // The city you opened, for as long as the app is up: the one with chrome + // around it. Everything below reads it through the provider. + const session = useMemo(() => new CitySession({ chrome: new SessionChrome() }), []); + useEffect(() => () => session.dispose(), [session]); // Before anything that reads the URL, so back/forward is never missed. useEffect(() => attachRouteHistory(), []); - useEffect(() => attachOverlayDriver(), []); + // The URL describes THIS session: one adapter, pointed at one project. + useEffect(() => attachUrlBinding(session), [session]); + // What it opens is what "recent" means; the backdrop's session does not. + useEffect(() => attachRecents(session), [session]); + + return ( + + + + + + ); +} + +// Inside the provider, so everything it mounts can read the session it belongs +// to rather than being handed pieces of it. +function AppRoutes({ session }: { session: CitySession }) { + useDocumentTitle(); + useServerData(); + useLiveUpdates(session); + + useEffect(() => session.progress.attachOverlayDriver(), [session]); + + // Debug-only ?shot= capture, imported dynamically so the harness + // never ships in a normal session. + useEffect(() => { + if (!new URLSearchParams(window.location.search).has('shot') || !isDebugMode()) return; + void import('@/city/scene/debug/capture/captureHarness').then((m) => + m.initCaptureHarness(session) + ); + }, [session]); - // A failure sends you back to the landing, which reads SOURCE_ERROR itself to + // A failure sends you back to the landing, which reads the error itself to // explain what happened. useSignalEffect(() => { - if (SOURCE_ERROR.value) navigate(ROUTES.HOME); + if (session.source.error.value) navigate(ROUTES.HOME); }); return ( - - - - - - - - - - - - - + + + + + + + + + + + ); } diff --git a/app/src/api/branches.ts b/app/src/api/branches.ts index 7ab54e3d4..5274e7637 100644 --- a/app/src/api/branches.ts +++ b/app/src/api/branches.ts @@ -4,7 +4,7 @@ import { URL_PARAMS } from '@/constants/urlParams'; import { apiUrl } from '@/api/apiUrl'; -import { ScanError, type ScanErrorCode } from '@/api/manifest'; +import { ScanError, type ScanErrorCode } from '@/city/session/api/manifest'; export interface BranchList { branches: string[]; diff --git a/app/src/city/City.css b/app/src/city/City.css index 8a7300e63..ffdfa77e5 100644 --- a/app/src/city/City.css +++ b/app/src/city/City.css @@ -4,9 +4,6 @@ display: block; width: 100%; height: 100%; -} - -/* Opaque so a sub-frame gap during resize blends in instead of flashing. */ -.city-canvas--scene { + /* Painted, so a sub-frame gap during a resize blends in instead of flashing. */ background: var(--cc-bg-app); } diff --git a/app/src/city/City.tsx b/app/src/city/City.tsx index 9ecd6f401..9d25b7fc1 100644 --- a/app/src/city/City.tsx +++ b/app/src/city/City.tsx @@ -1,115 +1,85 @@ -// city/City.tsx — the and its Three.js lifecycle: this folder's mount -// point, beside the createCity API it drives. Mount applies MANIFEST on every -// change, unmount tears the scene down so a remount cannot stack a second -// renderer on it, and the variant is all a view says about what it is FOR. +// city/City.tsx — a city on a , and its Three.js lifecycle. It renders +// a session: what to show, who hears about the build and what scrubs it all +// come from there, so two of these are two sessions and nothing else. Unmount +// tears the scene down, or a remount stacks a second renderer on one canvas. import './City.css'; import { useRef, useEffect } from 'preact/hooks'; -import { effect } from '@preact/signals'; -import { createCity } from '@/city'; -import { CameraMode } from '@/city/render/cameraRig'; -import { attachSettingsReactions } from '@/state/settings/reactions'; -import { BACKDROP_HANDLE, SCENE_HANDLE } from '@/city/sceneHandle'; -import { MANIFEST } from '@/state/stores/manifest'; -import { markRebuilding, markError } from '@/state/stores/progress'; -import { TIMELINE_MODE } from '@/state/stores/timeline'; -import { reapplyTimelineScene } from '@/hooks/useTimelineMode'; +import { computed, effect } from '@preact/signals'; +import { createCityScene } from '@/city/scene'; +import { rebuildOnSave } from '@/city/session/rebuildOnSave'; +import type { CitySession } from '@/city/session/session'; import type { Manifest } from '@/types'; -export enum CityVariant { - /** The app's main view: opaque, so a sub-frame gap during resize blends into - * the page instead of flashing through. */ - Scene = 'scene', - /** Wallpaper: transparent, so whatever the view puts behind it (the hero - * image) shows until the city has something to paint. */ - Backdrop = 'backdrop', -} - export interface CityProps { - variant?: CityVariant; + /** The city to render. Everything this canvas needs is in it. */ + session: CitySession; + /** What a screen reader is told this canvas is. */ + label?: string; } -export function City({ variant = CityVariant.Scene }: CityProps = {}) { +const DEFAULT_LABEL = + '3D city map of the repository. Files are buildings, directories are streets, commits are trees. Browse it with the file tree and search panels.'; + +export function City({ session, label = DEFAULT_LABEL }: CityProps) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; let disposed = false; - let city: Awaited> | null = null; let unsubApply: (() => void) | null = null; let disposeReactions: (() => void) | null = null; - // Start empty; the apply-effect below paints the first manifest. The variant - // is also what the camera is FOR, so the rig opens on the right pose itself. - createCity(canvas, { - cameraMode: variant === CityVariant.Backdrop ? CameraMode.Backdrop : CameraMode.Project, - }) - .then((handle) => { + const { manifest, progress, timeline } = session; + // The store's value spans the skeleton the stream emits before it is fully + // typed; the scene takes manifests. + const showing = computed(() => manifest.current.value as Manifest | null); + + // Starts empty; the apply below paints the first manifest. + createCityScene(canvas, session) + .then((built) => { // Unmounted before the async build resolved: dispose the orphan now, or // its renderer + frame loop leak forever (nothing else holds a ref). if (disposed) { - handle.dispose(); + built.dispose(); return; } - city = handle; - // Published to its own slot: the two variants are independent cities. - if (variant === CityVariant.Scene) SCENE_HANDLE.value = handle; - else BACKDROP_HANDLE.value = handle; - disposeReactions = attachSettingsReactions({ - // Rebuild the current mode (Timeline: union + scrub; Live: HEAD). - rebuildScene: () => - TIMELINE_MODE.peek() - ? reapplyTimelineScene() - : handle.applyManifest(MANIFEST.peek() as Manifest), - invalidateLayoutCache: handle.invalidateLayoutCache, + session.scene.value = built; + // It knows what it is showing and how to re-pack it, so a Save needs to + // be told neither. + disposeReactions = rebuildOnSave({ + scene: built, + report: progress, + config: session.config, }); - // A backdrop shows what its view decided to show, which is not the - // opened project: useHomeBackdrop drives that canvas itself. - if (variant !== CityVariant.Scene) return; - - // Only kicks off the apply and surfaces its error: reaching Idle belongs - // to the decoration pass, and reframing to the city composer. + // Only kicks the apply off and surfaces its error: reaching Idle is the + // decoration pass's, and framing the composer's. unsubApply = effect(() => { - const m = MANIFEST.value as Manifest; - if (!m) return; // nothing to build yet - // Live's bridge from manifest to scene; Timeline packs its own union - // city. Peeked, so leaving the mode doesn't repack what it committed. - if (TIMELINE_MODE.peek()) return; - markRebuilding(); - void handle.applyManifest(m).catch(markError); + const m = showing.value; + if (!m) return; // nothing to show yet + // Scrubbing owns the contents while it runs. Peeked, so leaving the + // mode doesn't repack what it committed (the teardown owns that). + if (timeline.mode.peek()) return; + void built.applyManifest(m).catch(progress.markError); }); }) - .catch((err) => { - // No WebGL, or a context the driver refused. The landing has its - // wallpaper to fall back on; the city route has nothing to show. - if (variant === CityVariant.Scene) markError(err); - }); + .catch(progress.markError); // no WebGL, or a context the driver refused return () => { disposed = true; unsubApply?.(); disposeReactions?.(); - // Tear the city down so a remount doesn't stack a second renderer + - // frame loop on the same canvas (old city keeps rendering as a ghost). - city?.dispose(); - city = null; - // Only its own slot: clearing the other would strand the city still up. - if (variant === CityVariant.Scene) SCENE_HANDLE.value = null; - else BACKDROP_HANDLE.value = null; + session.scene.peek()?.dispose(); + session.scene.value = null; + // The canvas is gone, so nothing it had on it is on screen: a remount + // rebuilds from scratch, and that build is a load with a world to wait for. + progress.markGone(); }; }, []); // Non-text content needs a text alternative (WCAG 1.1.1). Keyboard users // browse the same data through Explore and Search. - return ( - - ); + return ; } diff --git a/app/src/city/CityProvider.tsx b/app/src/city/CityProvider.tsx new file mode 100644 index 000000000..2448e74ad --- /dev/null +++ b/app/src/city/CityProvider.tsx @@ -0,0 +1,29 @@ +// city/CityProvider.tsx — which city the chrome around it is looking at. Wrap +// the app in one session, or wrap each column of a side-by-side view in its +// own, and the same components work either way. itself is handed a +// session directly and needs none of this. + +import { createContext } from 'preact'; +import { useContext } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; +import type { CitySession } from './session/session'; + +const CityContext = createContext(null); + +export function CityProvider({ + session, + children, +}: { + session: CitySession; + children: ComponentChildren; +}) { + return {children}; +} + +/** The city this part of the tree belongs to. Throws rather than falling back + * to a global: reading the wrong city is the bug this shape rules out. */ +export function useCity(): CitySession { + const session = useContext(CityContext); + if (!session) throw new Error('useCity: no above this component'); + return session; +} diff --git a/app/src/city/README.md b/app/src/city/README.md new file mode 100644 index 000000000..39e2b0445 --- /dev/null +++ b/app/src/city/README.md @@ -0,0 +1,60 @@ +# `city/` — one city, whole + +Everything a city IS lives here: what it is showing, how far its load has got, +the history it is scrubbed through, what it looks like, and the Three.js scene +drawing it. Two cities on screen are two `CitySession`s and nothing else. + +Three things, and the split is the point: what a city IS, what DRAWS it, and +the component that puts one on a page. + +``` +City.tsx a rendering a session +CityProvider.tsx how the chrome around it finds which session it is looking at + +session/ what a city is + session.ts CitySession — it, and everything it is made of + config.ts CityConfig — what it looks like, per session + stores/ its manifest, source, progress and timeline + loader.ts fetching it: the scan, its cancel, the live poll + timelineMode.ts scrubbing it: entering, moving, leaving + commands.ts the verbs the chrome sends its scene + settings/ the fields that config is made of: what a city HAS + rebuildOnSave.ts a Save to that config → this city re-packs + +scene/ what draws it + index.ts createCityScene(canvas, session) — the composer + build.ts what the last apply MADE of it, and how to apply another + types/ SceneContext, CityScene, CityChrome + components/ what is IN the city: buildings, streets, trees, gem… + layout/ where they go: the packer, on a worker + render/ camera, post-processing, frame loop + interaction/ picker, pointer and keyboard, tooltip + scrub/ replaying history across it + constants/ utils/ debug/ +``` + +## The boundary + +A city never reaches for the app. It asks, through `CityChrome` +(`types/index.ts`): whether something else holds the keyboard, and to show a +node's details or get out of the way. `state/cityChrome.ts` is codecity's +answer; a session given none asks for nothing, which is what the landing's +backdrop gets. + +Nothing here reads the settings panel's signals either. Every visual value comes +from `session.config`, seeded from the panel and overridable per city — the two +workers take a snapshot of it, since they cannot read a signal at all. + +What it does use from the app, deliberately: + +| | | +| ------------------------- | ----------------------------------------------------------- | +| `api/*` | the backend | +| `state/stores/excludes` | folders you hid in this repo, which outlive any one session | +| `state/stores/recents` | a label for the overlay before the server sends one | +| `state/stores/serverData` | the server's config and its discover list | +| `state/settings/fields/*` | **types only**, plus `config.ts` seeding from them | + +The app, in turn, reacts to a city rather than being called by it: +`attachUrlBinding` (router) reflects and follows the URL, `attachRecents` +records what you opened, and `attachConfigReactions` re-packs on a Save. diff --git a/app/src/city/components/buildings/buildingIndex.ts b/app/src/city/components/buildings/buildingIndex.ts deleted file mode 100644 index a6abd855c..000000000 --- a/app/src/city/components/buildings/buildingIndex.ts +++ /dev/null @@ -1,27 +0,0 @@ -// city/components/buildings/buildingIndex.ts — Lookup adapter that decouples consumers -// (picker, fader, outline, animator, manifest-diff) from the -// CellTile rendering layout. Two indexes: -// - byPath: path → Building (manifest diff, hover-by-path) -// - byCellSlot: "cellId:slotId" → Building (raycaster hits) -// -// Building objects carry their own (cellId, slotId) back-pointers -// so reverse lookups are O(1). A fresh index is built per rebuild -// (cellAssembly), so there is no removal/clearing API. - -import type { Building } from '@/types/index'; - -export class BuildingIndex { - readonly byPath = new Map(); - private readonly _byCellSlot = new Map(); - - insert(b: Building): void { - const path = b.file?.path; - if (!path) return; - this.byPath.set(path, b); - this._byCellSlot.set(`${b.cellId}:${b.slotId}`, b); - } - - byCellSlot(key: string): Building | undefined { - return this._byCellSlot.get(key); - } -} diff --git a/app/src/city/components/buildings/facadePanelTextureArray.ts b/app/src/city/components/buildings/facadePanelTextureArray.ts deleted file mode 100644 index a34d2c96f..000000000 --- a/app/src/city/components/buildings/facadePanelTextureArray.ts +++ /dev/null @@ -1,274 +0,0 @@ -// city/components/buildings/facadePanelTextureArray.ts — Paged DataArrayTexture manager -// for instanced ad panels. Each media file (image or video) gets one -// "flat layer" index in the caller's view; internally that flat index is -// split into (page, localLayer) so we can use multiple smaller -// DataArrayTextures when a repo's media count exceeds the hardware's -// MAX_ARRAY_TEXTURE_LAYERS limit (typical desktop: 2048; spec minimum: -// 256). The fragment shader is given all page textures as a sampler -// array uniform plus a `uPageSize` float, and reconstructs (page, -// localLayer) from the per-instance iLayerIndex value. -// -// Why paged instead of a single bigger texture: a DataArrayTexture's -// depth is bounded by MAX_ARRAY_TEXTURE_LAYERS regardless of how small -// each layer is. Infisical (2.6k media files → adCapacity ≈ 3.9k) is -// past the typical 2048 cap; one DataArrayTexture refused to allocate -// via texStorage3D, leaving every per-layer upload writing into nothing. -// Paging into N <= MAX_PAGES separate texture arrays gives unlimited -// effective depth at the cost of one sampler-array lookup branch per -// fragment. -// -// WebGL2 requirement: sampler2DArray + texture() are GLSL ES 3.00 -// features. The ad-panel material must set glslVersion: THREE.GLSL3. -// -// Storage model: each page DataArrayTexture is constructed with -// `data: null` and `source.dataReady = false` so three.js's first-frame -// upload runs only texStorage3D to allocate GPU storage and skips the -// otherwise-broken texSubImage3D(..., null) (which WebGL2 reads as -// `offset=0 into PIXEL_UNPACK_BUFFER` → `INVALID_OPERATION`). Per-layer -// uploads then go straight to the right page's GPU texture via -// renderer.copyTextureToTexture, fed from a tiny per-call scratch canvas -// that's garbage-collected after the copy. There is no CPU-side mirror -// of the texture data. - -import * as THREE from 'three'; - -// Why 128, not 512: ad panels render on small building faces and almost -// never occupy more than a few hundred screen pixels. 128² × 4 = 64 KiB -// per layer keeps GPU memory bounded — at 2048 layers per page that's -// 128 MiB per page, comfortably under any modern integrated GPU's budget. -export const PANEL_TEX_SIZE = 128; - -// Maximum number of texture-array pages the fragment shader can dispatch -// into. Single source of truth for the shader side too: it's injected as -// the FACADE_PANEL_MAX_PAGES #define (facadePanels.ts), which sizes the -// `uPanelArrays` sampler array AND gates the sampleLayer dispatch branches -// (each `#if FACADE_PANEL_MAX_PAGES > N`), so the page count can't drift. At -// MAX_PAGES = 8 with a typical pageSize of 2048 layers, this supports -// 16,384 media files per repo — well beyond any realistic codebase. -export const MAX_PAGES = 8; - -// Renderer reference for per-layer GPU uploads. Set once by createCity -// after the WebGLRenderer is constructed (registerRenderer below). Uploads -// that fire BEFORE this point (e.g., a cached .onload that races -// renderer construction) park on `_rendererWaiters` and resume the moment -// registerRenderer lands. In test environments where the renderer never -// arrives, uploads time out and the upload promise resolves to `false` -// so the caller can leave the placeholder visible instead of advancing -// the fade attribute onto an empty texture layer. -let _renderer: THREE.WebGLRenderer | null = null; -const _rendererWaiters: Array<(r: THREE.WebGLRenderer | null) => void> = []; -const _RENDERER_WAIT_TIMEOUT_MS = 5000; - -export function registerRenderer(renderer: THREE.WebGLRenderer): void { - _renderer = renderer; - if (_rendererWaiters.length > 0) { - const waiters = _rendererWaiters.splice(0); - for (const w of waiters) w(renderer); - } -} - -function _whenRendererReady(): Promise { - if (_renderer) return Promise.resolve(_renderer); - return new Promise((resolve) => { - let settled = false; - const finish = (r: THREE.WebGLRenderer | null): void => { - if (settled) return; - settled = true; - resolve(r); - }; - _rendererWaiters.push(finish); - setTimeout(() => finish(null), _RENDERER_WAIT_TIMEOUT_MS); - }); -} - -// WebGL2 caps texture-array depth at MAX_ARRAY_TEXTURE_LAYERS. We probe -// the actual hardware limit once via a throwaway WebGL2 context and use -// it as the per-page depth. In a non-browser test environment the probe -// falls back to the WebGL2 spec minimum (256). -let _maxArrayLayersCache: number | null = null; -const _MAX_ARRAY_LAYERS_FALLBACK = 256; - -function _detectMaxArrayLayers(): number { - if (_maxArrayLayersCache !== null) return _maxArrayLayersCache; - if (typeof document === 'undefined') { - _maxArrayLayersCache = _MAX_ARRAY_LAYERS_FALLBACK; - return _maxArrayLayersCache; - } - try { - const probe = document.createElement('canvas'); - const gl = probe.getContext('webgl2') as WebGL2RenderingContext | null; - if (gl) { - const limit = gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS); - _maxArrayLayersCache = - typeof limit === 'number' && limit > 0 ? limit : _MAX_ARRAY_LAYERS_FALLBACK; - } else { - _maxArrayLayersCache = _MAX_ARRAY_LAYERS_FALLBACK; - } - } catch { - _maxArrayLayersCache = _MAX_ARRAY_LAYERS_FALLBACK; - } - return _maxArrayLayersCache; -} - -export class FacadePanelTextureArray { - /** One DataArrayTexture per used page; length is in [1, MAX_PAGES]. - * `shaderTextures` pads this to exactly MAX_PAGES for the shader's - * fixed-size sampler array. */ - readonly textures: THREE.DataArrayTexture[]; - /** Layers per page. Reported by the GPU's MAX_ARRAY_TEXTURE_LAYERS and - * exposed as a `uPageSize` shader uniform so the shader can decompose - * the flat iLayerIndex into (page, localLayer) regardless of which - * hardware it runs on. */ - readonly pageSize: number; - private readonly _capacity: number; - private _next = 0; - // Set by dispose() so any in-flight uploads parked in - // _whenRendererReady — or already past it but still doing - // copyTextureToTexture — bail out instead of writing to deleted - // textures. The skeleton→final manifest sequence (live updates too) - // disposes the old FacadePanelTextureArray before the new one's loads - // have all drained; without this guard the old loads can still hit - // the GPU and silently mutate state we no longer own. - private _disposed = false; - - constructor(capacity = 256) { - const pageSize = _detectMaxArrayLayers(); - this.pageSize = pageSize; - - const hardCap = MAX_PAGES * pageSize; - if (capacity > hardCap) { - // Genuinely past what the shader can address. Bumping MAX_PAGES - // (and the matching `else if` branches in the shader) is the fix. - console.warn( - `[facadePanelTextureArray] requested capacity ${capacity} exceeds ` + - `MAX_PAGES (${MAX_PAGES}) × pageSize (${pageSize}) = ${hardCap}. ` + - `Bump MAX_PAGES in facadePanelTextureArray.ts and add matching shader ` + - `branches in facadePanel.frag.glsl to support more layers.` - ); - capacity = hardCap; - } - this._capacity = capacity; - - this.textures = []; - let remaining = capacity; - while (remaining > 0) { - const depth = Math.min(remaining, pageSize); - const tex = new THREE.DataArrayTexture(null, PANEL_TEX_SIZE, PANEL_TEX_SIZE, depth); - tex.format = THREE.RGBAFormat; - tex.type = THREE.UnsignedByteType; - tex.minFilter = THREE.LinearFilter; - tex.magFilter = THREE.LinearFilter; - // WebGL2 forbids UNPACK_FLIP_Y / PREMULTIPLY_ALPHA for 3D/array texture - // uploads — left at the Texture default (flipY = true) three.js logs - // "texImage3D: FLIP_Y ... isn't allowed" on every page alloc. Our UVs - // don't assume a flip (same as the icon atlas), so disable it. - tex.flipY = false; - // dataReady = false → three.js skips the broken - // texSubImage3D(..., null) on init and only runs texStorage3D - // to allocate GPU storage. See the file header for the WebGL - // error it otherwise produces. - tex.source.dataReady = false; - tex.needsUpdate = true; - this.textures.push(tex); - remaining -= depth; - } - } - - /** Padded MAX_PAGES-long array suitable for binding to the shader's - * fixed-size `sampler2DArray uPanelArrays[MAX_PAGES]` uniform. Empty - * slots reuse the first page texture — the shader's per-fragment - * branch never selects them (iLayerIndex is bounded by capacity), so - * the binding is just a "must be valid sampler" formality. */ - get shaderTextures(): THREE.DataArrayTexture[] { - const padded = this.textures.slice(); - while (padded.length < MAX_PAGES) { - padded.push(this.textures[0]); - } - return padded; - } - - /** Reserve the next available flat layer index in [0, capacity). - * Returns -1 when capacity is exhausted (caller should skip ad-panel - * creation for the overflow building). The returned index is what - * goes into the per-instance iLayerIndex attribute; the shader maps - * it back to (page, localLayer) using uPageSize. */ - allocate(): number { - if (this._next >= this._capacity) return -1; - return this._next++; - } - - /** Blit `img` into the layer at `flatLayer`, scaled to PANEL_TEX_SIZE². - * Resolves to `true` when the GPU upload succeeded, `false` if the - * renderer never became available (timeout) or this texture array - * was disposed before the upload could run. Callers should NOT - * advance their iTextureFade attribute on a `false` return — the - * destination layer is still unwritten and sampling it produces - * black/transparent pixels. */ - async uploadImage(flatLayer: number, img: HTMLImageElement): Promise { - return this._uploadFromCanvas(flatLayer, _scaleToScratch(img)); - } - - /** Blit a canvas (e.g. a video first-frame poster) into the layer at - * `flatLayer`. Same semantics + return contract as uploadImage. */ - async uploadCanvas(flatLayer: number, src: HTMLCanvasElement): Promise { - return this._uploadFromCanvas(flatLayer, _scaleToScratch(src)); - } - - private async _uploadFromCanvas(flatLayer: number, canvas: HTMLCanvasElement): Promise { - if (this._disposed) return false; - const renderer = await _whenRendererReady(); - if (!renderer || this._disposed) return false; - const page = Math.floor(flatLayer / this.pageSize); - const localLayer = flatLayer - page * this.pageSize; - const dstTex = this.textures[page]; - if (!dstTex) return false; - - const tempTex = new THREE.CanvasTexture(canvas); - tempTex.format = THREE.RGBAFormat; - tempTex.type = THREE.UnsignedByteType; - tempTex.minFilter = THREE.LinearFilter; - tempTex.magFilter = THREE.LinearFilter; - // copyTextureToTexture applies the SOURCE texture's unpack flags, and the - // 3D-destination copy path (texSubImage3D) forbids UNPACK_FLIP_Y / - // PREMULTIPLY_ALPHA. CanvasTexture defaults flipY = true, so leaving it on - // logs "texImage3D: FLIP_Y ... isn't allowed" on every upload (the driver - // drops the flip; the array is flipY = false and its UVs assume no flip, so - // the result is already correct — this just silences the error). - tempTex.flipY = false; - tempTex.premultiplyAlpha = false; - // three@0.184 unified API. srcRegion bounds are EXCLUSIVE - // (width = max.x - min.x), so the full PANEL_TEX_SIZE slice runs from - // 0 to PANEL_TEX_SIZE on each axis. dstPosition.z selects the - // destination layer WITHIN this page. - renderer.copyTextureToTexture( - tempTex, - dstTex, - new THREE.Box3( - new THREE.Vector3(0, 0, 0), - new THREE.Vector3(PANEL_TEX_SIZE, PANEL_TEX_SIZE, 1) - ), - new THREE.Vector3(0, 0, localLayer) - ); - tempTex.dispose(); - return true; - } - - /** Number of layers allocated so far (across all pages). */ - get count(): number { - return this._next; - } - - dispose(): void { - this._disposed = true; - for (const tex of this.textures) tex.dispose(); - } -} - -function _scaleToScratch(src: CanvasImageSource): HTMLCanvasElement { - const canvas = document.createElement('canvas'); - canvas.width = PANEL_TEX_SIZE; - canvas.height = PANEL_TEX_SIZE; - const ctx = canvas.getContext('2d')!; - ctx.drawImage(src, 0, 0, PANEL_TEX_SIZE, PANEL_TEX_SIZE); - return canvas; -} diff --git a/app/src/city/components/buildings/material.ts b/app/src/city/components/buildings/material.ts deleted file mode 100644 index 9a78507a8..000000000 --- a/app/src/city/components/buildings/material.ts +++ /dev/null @@ -1,201 +0,0 @@ -// city/components/buildings/material.ts — the one building ShaderMaterial, its -// uniforms, and the icon atlas. Every cell's detail mesh shares it, so mutating -// a uniform here updates the whole city at once. - -import * as THREE from 'three'; -import { BUILDINGS } from '@/state/settings/fields/buildings'; -import { BLOOM } from '@/state/settings/fields/effects'; -import { SCENE } from '@/state/settings/fields/scene'; -import { RUINS } from '@/state/settings/fields/ruins'; -import type { IconAtlas } from './atlas'; -import { setColorFromHex } from '@/city/utils/color/setColorFromHex'; -import { writeSunDir } from '@/city/utils/shaders/sunDir'; -import { - LIGHTING_SUN_AZIMUTH_DEG, - LIGHTING_SUN_ELEVATION_DEG, - LIGHTING_AMBIENT, - LIGHTING_SUN_CONTRAST, -} from '@/city/constants/lighting'; - -// Shader-side facade keys ride uniforms; the JS-side ones bake into per-instance -// attributes, which is why changing them routes to a full rebuild. - -import buildingVertSrc from './building.vert.glsl?raw'; -import buildingFragSrc from './building.frag.glsl?raw'; - -// One material for every cell: applyManifest runs repeatedly, and a per-rebuild -// material would accumulate. -let _sharedMaterial: THREE.ShaderMaterial | null = null; - -// Pushed in before the cell pass, so buildings have it as they're built. Null -// while it loads or if it failed: the shader paints the bare roof colour. -let _atlas: IconAtlas | null = null; - -export function setIconAtlas(atlas: IconAtlas | null): void { - _atlas = atlas; - if (_sharedMaterial) { - _sharedMaterial.uniforms.uIconAtlas.value = atlas ? atlas.texture : null; - _sharedMaterial.uniforms.uIconSlotSize.value = atlas ? atlas.slotSize : 0; - } -} - -/** The icon atlas the cells resolve per-instance roof UVs from, or null when - * it hasn't built yet. Single source, shared with the uIconAtlas uniform. */ -export function getIconAtlas(): IconAtlas | null { - return _atlas; -} - -/** Mirrors _sharedMaterial.transparent so the setter can skip a no-op - * needsUpdate — the owners call it per sweep and per scrub frame. */ -let _translucent = false; - -/** Opaque buildings sort front-to-back, so early-z skips the ~620-line facade - * shader on hidden fragments; iFade.x is the only alpha, so a fade needs this. */ -export function setBuildingsTranslucent(on: boolean): void { - if (on === _translucent) return; - _translucent = on; - if (_sharedMaterial) { - _sharedMaterial.transparent = on; - _sharedMaterial.needsUpdate = true; - } -} - -// Grime is age-scaled: a [newest, oldest] range the shader lerps per-building by -// createdAge, written into a Vector2 uniform (both 0 when disabled → mix → 0). -function _grimeIntensityVec(out: THREE.Vector2): THREE.Vector2 { - const f = BUILDINGS.value; - const [lo, hi] = f.GRIME_ENABLED ? f.GRIME_INTENSITY : [0, 0]; - return out.set(lo, hi); -} - -/** Shared by every cell's detail mesh: a material per cell meant a program - * compile per cell, which hung the tab on large repos. */ -export function getBuildingMaterial(): THREE.ShaderMaterial { - if (_sharedMaterial) return _sharedMaterial; - // Chunks are registered via THREE.ShaderChunk in registerShaderChunks.ts; - // Three.js's native preprocessor resolves #include at compile time. - const fragSrc = buildingFragSrc; - _sharedMaterial = new THREE.ShaderMaterial({ - vertexShader: buildingVertSrc, - fragmentShader: fragSrc, - // Opaque unless something is mid-fade — see setBuildingsTranslucent. - transparent: _translucent, - uniforms: { - // Hidden-tier wireframe thickness in screen-pixels. Updated by - // refreshBuildingMaterial() on Save. - uOutlineWidth: { value: BUILDINGS.value.OUTLINE_WIDTH }, - // Null until the atlas builds; the shader gates sampling on iIconUV.x. - uIconAtlas: { value: _atlas ? _atlas.texture : null }, - uIconSlotSize: { value: _atlas ? _atlas.slotSize : 0 }, - // Height-based haze, mixed into the post-tonemap sRGB framebuffer, so the - // hex bytes pass through unconverted. - uFogEnabled: { value: SCENE.value.FOG_ENABLED }, - uFogColor: { value: setColorFromHex(new THREE.Color(), SCENE.value.FOG_COLOR) }, - uFogIntensity: { value: SCENE.value.FOG_INTENSITY }, - // Raw fraction — the shader scales it by each building's own height. - uFogHeightFrac: { value: SCENE.value.FOG_HEIGHT_FRAC }, - // Extra emission on the freshest building's windows, over a baseline 1. - uWindowEmissionBoost: { value: BUILDINGS.value.WINDOW_EMISSION }, - // Age-driven decay uniforms (createdAge-gated, independent of - // modifiedAge). See BUILDINGS (aging) config. - uGrimeIntensity: { value: _grimeIntensityVec(new THREE.Vector2()) }, - uGrimeCoverage: { value: new THREE.Vector2(...BUILDINGS.value.GRIME_COVERAGE) }, - // The placeholder is an overhead sun rather than a zero vector, which - // would shadow every face if _writeSunDir somehow didn't run. - uSunDirWorld: { value: new THREE.Vector3(0, 1, 0) }, - uAmbient: { value: LIGHTING_AMBIENT }, - uSunContrast: { value: LIGHTING_SUN_CONTRAST }, - // Seeded from the store so the first frame is already configured; only - // the shader-side keys, since the rest bake into attributes. - uSlabHeightFrac: { value: BUILDINGS.value.SLAB_HEIGHT_FRAC }, - uWindowWidthFrac: { value: BUILDINGS.value.WINDOW_WIDTH_FRAC }, - uWindowHeightFrac: { value: BUILDINGS.value.WINDOW_HEIGHT_FRAC }, - uWindowMarginFrac: { value: BUILDINGS.value.WINDOW_MARGIN_FRAC }, - uDoorHeightFrac: { value: BUILDINGS.value.DOOR_HEIGHT_FRAC }, - uRoofBorderFrac: { value: BUILDINGS.value.ROOF_BORDER_FRAC }, - // BUILDINGS store — HSL lightness deltas applied to slab and door - // via shadeColor/shadeAndShiftHue in the shader. - uSlabLightnessDelta: { value: BUILDINGS.value.SLAB_LIGHTNESS_DELTA }, - uDoorLightnessDelta: { value: BUILDINGS.value.DOOR_LIGHTNESS_DELTA }, - // Deleted-file cross (RUINS store) — consumed in sRGB like the roof it - // composites over, so the hex bytes pass through (see setColorFromHex). - uRuinXEnabled: { value: RUINS.value.X_ENABLED }, - uRuinXColor: { value: setColorFromHex(new THREE.Color(), RUINS.value.X_COLOR) }, - uRuinXWidth: { value: RUINS.value.X_WIDTH }, - // WINDOW_LIGHTING store — per-cell lit/unlit lightness deltas, gap - // thresholds, and the warm-amber tint for old/dim lit panes. - uWindowUnlitLightnessDelta: { value: BUILDINGS.value.UNLIT_LIGHTNESS_DELTA }, - uWindowGapBaseThreshold: { value: BUILDINGS.value.GAP_BASE_THRESHOLD }, - uWindowGapAgeBonus: { value: BUILDINGS.value.GAP_AGE_BONUS }, - // Consumed in sRGB, so the hex bytes pass through unconverted. - uDimGlowColor: { value: setColorFromHex(new THREE.Color(), BUILDINGS.value.DIM_GLOW_COLOR) }, - uLitFreshnessExponent: { value: BUILDINGS.value.LIT_FRESHNESS_EXPONENT }, - }, - }); - writeSunDir( - _sharedMaterial.uniforms.uSunDirWorld.value as THREE.Vector3, - LIGHTING_SUN_AZIMUTH_DEG, - LIGHTING_SUN_ELEVATION_DEG - ); - return _sharedMaterial; -} - -/** Re-apply the committed config to the shared uniforms, so a Save reaches the - * whole city without rebuilding any of it. */ -export function refreshBuildingMaterial(): void { - if (!_sharedMaterial) return; - const sceneCfg = SCENE.value; - const bloomCfg = BLOOM.value; - _sharedMaterial.uniforms.uOutlineWidth.value = BUILDINGS.value.OUTLINE_WIDTH; - // Intensity is zeroed as well as the flag, so the mix is inert even where a - // driver takes the branch differently. - _sharedMaterial.uniforms.uFogEnabled.value = sceneCfg.FOG_ENABLED; - setColorFromHex(_sharedMaterial.uniforms.uFogColor.value as THREE.Color, sceneCfg.FOG_COLOR); - _sharedMaterial.uniforms.uFogIntensity.value = sceneCfg.FOG_ENABLED ? sceneCfg.FOG_INTENSITY : 0; - _sharedMaterial.uniforms.uFogHeightFrac.value = sceneCfg.FOG_HEIGHT_FRAC; - // Without bloom the windows stay LDR, with nothing for the pass to catch. - _sharedMaterial.uniforms.uWindowEmissionBoost.value = bloomCfg.ENABLED - ? BUILDINGS.value.WINDOW_EMISSION - : 0; - // Scene directional lighting (fixed constants — re-seed idempotently). - writeSunDir( - _sharedMaterial.uniforms.uSunDirWorld.value as THREE.Vector3, - LIGHTING_SUN_AZIMUTH_DEG, - LIGHTING_SUN_ELEVATION_DEG - ); - _sharedMaterial.uniforms.uAmbient.value = LIGHTING_AMBIENT; - _sharedMaterial.uniforms.uSunContrast.value = LIGHTING_SUN_CONTRAST; - // Shader-side keys only: the rest bake into attributes, so the whole store - // routes through a rebuild and these come along with it. - const facade = BUILDINGS.value; - // Age weathering (grime) — [newest, oldest] ranges the shader lerps - // per-building by createdAge. - _grimeIntensityVec(_sharedMaterial.uniforms.uGrimeIntensity.value as THREE.Vector2); - (_sharedMaterial.uniforms.uGrimeCoverage.value as THREE.Vector2).set(...facade.GRIME_COVERAGE); - _sharedMaterial.uniforms.uSlabHeightFrac.value = facade.SLAB_HEIGHT_FRAC; - _sharedMaterial.uniforms.uWindowWidthFrac.value = facade.WINDOW_WIDTH_FRAC; - _sharedMaterial.uniforms.uWindowHeightFrac.value = facade.WINDOW_HEIGHT_FRAC; - _sharedMaterial.uniforms.uWindowMarginFrac.value = facade.WINDOW_MARGIN_FRAC; - _sharedMaterial.uniforms.uDoorHeightFrac.value = facade.DOOR_HEIGHT_FRAC; - _sharedMaterial.uniforms.uRoofBorderFrac.value = facade.ROOF_BORDER_FRAC; - // BUILDINGS store — pure uniform refresh, no rebuild required. - const facadeDetail = BUILDINGS.value; - _sharedMaterial.uniforms.uSlabLightnessDelta.value = facadeDetail.SLAB_LIGHTNESS_DELTA; - _sharedMaterial.uniforms.uDoorLightnessDelta.value = facadeDetail.DOOR_LIGHTNESS_DELTA; - const ruins = RUINS.value; - _sharedMaterial.uniforms.uRuinXEnabled.value = ruins.X_ENABLED; - setColorFromHex(_sharedMaterial.uniforms.uRuinXColor.value as THREE.Color, ruins.X_COLOR); - _sharedMaterial.uniforms.uRuinXWidth.value = ruins.X_WIDTH; - // WINDOW_LIGHTING store — pure uniform refresh into the pre-allocated - // THREE.Color uniform values. - const windowLighting = BUILDINGS.value; - _sharedMaterial.uniforms.uWindowUnlitLightnessDelta.value = windowLighting.UNLIT_LIGHTNESS_DELTA; - _sharedMaterial.uniforms.uWindowGapBaseThreshold.value = windowLighting.GAP_BASE_THRESHOLD; - _sharedMaterial.uniforms.uWindowGapAgeBonus.value = windowLighting.GAP_AGE_BONUS; - // DIM_GLOW_COLOR is consumed in sRGB space — see the uniform's initializer. - setColorFromHex( - _sharedMaterial.uniforms.uDimGlowColor.value as THREE.Color, - windowLighting.DIM_GLOW_COLOR - ); - _sharedMaterial.uniforms.uLitFreshnessExponent.value = windowLighting.LIT_FRESHNESS_EXPONENT; -} diff --git a/app/src/city/layout/rect.ts b/app/src/city/layout/rect.ts deleted file mode 100644 index 9a059c78d..000000000 --- a/app/src/city/layout/rect.ts +++ /dev/null @@ -1,81 +0,0 @@ -// city/layout/rect.ts — the city's canonical 2D footprint rectangle, plus the -// ONE place that knows how a Street/Building maps onto it. -// -// Rect lives on the layout plane: (x, y) is the rect's CENTER and w/d are -// the FULL width/depth, matching the Building convention. The layout plane's -// y axis is the world's z axis — 3D consumers (e.g. the footprint component) -// place rects at world (x, 0, y). -// -// A Street stores its extent as length-along-axis + width-across, so turning -// one into a Rect requires the orientation swap below. Keep that swap here so -// every consumer (layout occupancy, layout invariant checks, the footprint -// slab) can never disagree. - -import { StreetAxis } from '@/types'; -import type { Building, Street } from '@/types'; - -/** Axis-aligned rectangle on the layout plane. (x, y) is the CENTER; - * w/d are the full width/depth (matches Building/Street conventions). */ -export interface Rect { - x: number; - y: number; - w: number; - d: number; -} - -/** A Building already stores center + full extents — direct mapping. */ -export function rectOfBuilding(b: Building): Rect { - return { x: b.x, y: b.y, w: b.w, d: b.d }; -} - -/** A Street with orientation X has its long side (length) on x and its - * short side (width) on y; orientation Y is the inverse. */ -export function rectOfStreet(s: Street): Rect { - if (s.orientation === StreetAxis.X) { - return { x: s.x, y: s.y, w: s.length, d: s.width }; - } - return { x: s.x, y: s.y, w: s.width, d: s.length }; -} - -// _rectsOverlap(a, b) -> boolean -// -// True iff two axis-aligned rectangles intersect by more than FP noise. -// Touching edges (zero overlap) returns false; the packer relies on this -// so that two rects abutted at exactly their sibling gap apart count as -// non-overlapping. Because layout edges are derived from CENTER ± SIZE/2 -// after additive translation through non-integer offsets (e.g. a path's -// far edge `61.6 + 2 = 63.6` vs a building's near edge `66.6 - 3 = -// 63.5999…`), strict `<` comparison on FP-derived edges sporadically -// reports the touching case as a sub-femto-unit overlap. -// -// OVERLAP_EPS: tolerance for IEEE-754 noise that arises when two touching -// rects have edges computed via different additive paths (e.g. center+size/2 -// vs neighbor-center-size/2 through a non-integer subAnchor). Empirically -// ~7e-15 per single translation; a few orders of magnitude higher under -// deep recursion at large coordinate scales. 1e-9 sits well above this -// noise band and far below any visible-scale geometry (smallest gap ~1 -// unit), so it eliminates false-positive overlaps without masking real ones. -export const OVERLAP_EPS = 1e-9; - -/** The four edges of a rect (center ± half-extent). Shared by the overlap test - * and the intersection helper so neither re-derives edges inline. */ -export interface RectEdges { - x1: number; - x2: number; - y1: number; - y2: number; -} -export function rectEdges(r: Rect): RectEdges { - return { x1: r.x - r.w / 2, x2: r.x + r.w / 2, y1: r.y - r.d / 2, y2: r.y + r.d / 2 }; -} - -export function _rectsOverlap(a: Rect, b: Rect): boolean { - const A = rectEdges(a); - const B = rectEdges(b); - return ( - A.x1 < B.x2 - OVERLAP_EPS && - A.x2 > B.x1 + OVERLAP_EPS && - A.y1 < B.y2 - OVERLAP_EPS && - A.y2 > B.y1 + OVERLAP_EPS - ); -} diff --git a/app/src/city/state/index.ts b/app/src/city/scene/build.ts similarity index 84% rename from app/src/city/state/index.ts rename to app/src/city/scene/build.ts index 22a127257..2795bd354 100644 --- a/app/src/city/state/index.ts +++ b/app/src/city/scene/build.ts @@ -1,30 +1,25 @@ -// city/state/index.ts — the per-city manifest-bound store: signals plus the -// async pipeline that advances them. Per-instance, not a module singleton. -// manifest/layout are SOURCE signals reassigned every apply, the revisions are -// change counters, the rest are computed. Components rebuild off them. +// city/scene/build.ts — what the last apply MADE of this city, and the pipeline +// that makes it: manifest and layout are source signals reassigned every apply, +// the revisions are change counters, the rest are computed. Components rebuild +// off them. Per scene, so a second city builds without disturbing this one. import { signal, computed, batch, type Signal, type ReadonlySignal } from '@preact/signals'; import * as THREE from 'three'; -import { FOOTPRINT } from '@/state/settings/fields/footprint'; -import { TREES } from '@/state/settings/fields/trees'; -import { - beginBuild, - enterBuildStage, - markDecorating, - setBuildStagePercent, -} from '@/state/stores/progress'; +import type { CityConfig } from '@/city/session/config'; +import { type BuildReporter } from '@/city/session/stores/progress'; import { BuildStage } from '@/constants/progress'; -import { StreetAxis } from '@/types'; -import type { Building, CityBbox, CityLayout, Manifest, Street } from '@/types'; -import { getWorldBounds, type WorldBounds } from '../utils/floorBounds'; -import { nextPaint } from '../utils/nextPaint'; -import type { TreePlacement } from '../components/trees/treePlacement'; -import type { TreePlacementClient } from '../components/trees/treePlacementClient'; -import { gemAnchorXZ } from '@/city/components/gem/anchor'; -import { buildIconAtlas } from '../components/buildings/atlas'; -import { setIconAtlas } from '../components/buildings/material'; -import type { createLayoutClient } from '../layout'; +import { StreetAxis } from '@/city/scene/types'; +import type { Manifest } from '@/types'; +import type { Building, CityBbox, CityLayout, Street } from '@/city/scene/types'; +import { getWorldBounds, type WorldBounds } from './utils/floorBounds'; +import { nextPaint } from './utils/nextPaint'; +import type { TreePlacement } from './components/trees/treePlacement'; +import type { TreePlacementClient } from './components/trees/treePlacementClient'; +import { gemAnchorXZ } from '@/city/scene/components/gem/anchor'; +import { buildIconAtlas } from './components/buildings/atlas'; +import type { BuildingMaterial } from './components/buildings/material'; +import type { createLayoutClient } from './layout'; -export interface CityState { +export interface CityBuild { manifest: Signal; // Full layout (positions + per-building dims), reassigned EVERY apply — feeds // the dims-dependent rebuilds (buildings/footprint/trees) + the bbox computed. @@ -133,21 +128,29 @@ function sceneBboxOf(b: THREE.Box3): CityBbox { }; } -export function createCityState( +export function createCityBuild( layoutClient: ReturnType, - treePlacementClient: TreePlacementClient -): CityState { + treePlacementClient: TreePlacementClient, + // Whose build this is: the world's, or a wallpaper's that nobody is waiting + // for (see BuildReporter). + report: BuildReporter, + /** This city's material: the atlas the build makes is pushed into it. */ + buildingMaterial: BuildingMaterial, + /** What this city looks like: its ground buffer, halo and whether it grows + * trees at all decide what a build produces. */ + config: CityConfig +): CityBuild { const manifest = signal(null); const layout = signal(null); const treePlacements = signal(null); - // Change-notification counters (see CityState for what each means + who tracks it). + // Change-notification counters (see CityBuild for what each means + who tracks it). const structureRevision = signal(0); const cityRevision = signal(0); // Halo width, or 0 when off. Dedupes to a number so bbox re-fires only when // the halo changes, not on every FOOTPRINT change (which would refit). const footprintHalo = computed(() => { - const f = FOOTPRINT.value; + const f = config.FOOTPRINT.value; return f.ENABLED && f.HALO_WIDTH > 0 ? f.HALO_WIDTH : 0; }); @@ -173,7 +176,7 @@ export function createCityState( // structure change. null until the first apply. const latestWorldBounds = computed(() => { const sb = sceneBbox.value; - return sb ? getWorldBounds(sb, cityHeight.value) : null; + return sb ? getWorldBounds(sb, config.WORLD.value, cityHeight.value) : null; }); // The root-of-repo street, which gets the gem. Ref-stable on a reuse apply, @@ -231,7 +234,7 @@ export function createCityState( BuildStage.Assemble, // The trees component ends a build: enabled, it decorates; off, it settles // straight to idle and this stage never comes. - ...(TREES.peek().ENABLED ? [BuildStage.Decorate] : []), + ...(config.TREES.peek().ENABLED ? [BuildStage.Decorate] : []), ]; } @@ -240,6 +243,9 @@ export function createCityState( leadingStages: readonly BuildStage[] = [] ): Promise { const myGeneration = ++generation; + // Applying IS rebuilding: the status opens here so no caller has to say so + // before calling, and none can forget to. + report.markRebuilding(); // Structure only (no mtime/size), so it holds across skeleton and final. // Gates the atlas alone; layout reuse keys on layout_signature instead. @@ -258,8 +264,8 @@ export function createCityState( // The readout's denominator, decided before the first stage starts: a // fixed count would promise a stage this apply is not going to run. const own = buildStagesFor(newManifest); - beginBuild([...leadingStages, ...own]); - enterBuildStage(own[0]); + report.beginBuild([...leadingStages, ...own]); + report.enterBuildStage(own[0]); // The manifest fan-out and the atlas walk below run before anything can // repaint, so the row would name them only once they were over. await nextPaint(); @@ -272,13 +278,13 @@ export function createCityState( const atlas = await buildIconAtlas(newManifest); if (myGeneration !== generation) return; // superseded mid-build lastAtlasTreeSig = treeSig; - setIconAtlas(atlas); + buildingMaterial.setIconAtlas(atlas); } catch (err) { console.warn('[codecity] icon atlas build failed; roofs will render without icons', err); } } - enterBuildStage(BuildStage.Layout); + report.enterBuildStage(BuildStage.Layout); const reusedLayout = shouldReuse ? layout.peek() : null; let newLayout: CityLayout; // Full envelope, not `.tree`: the worker contract stays typed against @@ -287,7 +293,7 @@ export function createCityState( newLayout = await layoutClient.compute(newManifest, reusedLayout, (percent) => { // A superseded apply's worker keeps posting until it is told to stop; // its percent must not walk over the live build's readout. - if (myGeneration === generation) setBuildStagePercent(percent); + if (myGeneration === generation) report.setBuildStagePercent(percent); }); } catch (err) { if (err instanceof Error && err.message === 'superseded') return; @@ -297,15 +303,15 @@ export function createCityState( // The batch below holds the main thread for seconds on a big repo: hand the // browser a frame first, so the row naming that work paints before it. - enterBuildStage(BuildStage.Assemble); + report.enterBuildStage(BuildStage.Assemble); await nextPaint(); if (myGeneration !== generation) return; // Placed against the finished layout, so the scan belongs to the build, not // to the component that draws them: the batch below publishes a whole city. let newPlacements: TreePlacement[] | null = null; - if (TREES.peek().ENABLED) { - markDecorating(); + if (config.TREES.peek().ENABLED) { + report.markDecorating(); const newBbox = cityBbox(newLayout, footprintHalo.peek()); try { newPlacements = await treePlacementClient.compute( diff --git a/app/src/city/components/buildings/FACADE_PANELS.md b/app/src/city/scene/components/buildings/FACADE_PANELS.md similarity index 69% rename from app/src/city/components/buildings/FACADE_PANELS.md rename to app/src/city/scene/components/buildings/FACADE_PANELS.md index 355e141fb..19370a12b 100644 --- a/app/src/city/components/buildings/FACADE_PANELS.md +++ b/app/src/city/scene/components/buildings/FACADE_PANELS.md @@ -119,3 +119,55 @@ sampler array and gates the `sampleLayer` dispatch branches, so the page count lives in one constant. The sampler array is padded to that size so every declared slot is bound; unused slots reuse page 0 and are never sampled, since `iLayerIndex` stays within capacity. + +## The texture array is paged + +Each media file gets one **flat layer** index in the caller's view. Internally +that splits into `(page, localLayer)`, so several smaller `DataArrayTexture`s +cover a repo whose media count exceeds the hardware's +`MAX_ARRAY_TEXTURE_LAYERS` (typical desktop 2048; spec minimum 256). The +fragment shader takes every page as a sampler-array uniform plus a `uPageSize` +float and reconstructs the pair from the per-instance `iLayerIndex`. + +A single bigger texture is not an option: a `DataArrayTexture`'s depth is capped +by that same limit no matter how small each layer is. One repo with ~2.6k media +files (capacity ≈ 3.9k) went past 2048, and the single array refused to allocate +via `texStorage3D` — leaving every per-layer upload writing into nothing. Paging +into at most `MAX_PAGES` arrays buys unlimited effective depth for the cost of +one sampler-array branch per fragment. + +The real limit is probed once from a throwaway WebGL2 context, and falls back to +the spec minimum where there is no browser (tests). + +`sampler2DArray` and `texture()` are GLSL ES 3.00, so the panel material must set +`glslVersion: THREE.GLSL3`. + +## Storage model + +Each page is constructed with `data: null` and `source.dataReady = false`, so +three.js's first-frame upload runs `texStorage3D` to allocate and skips the +otherwise-broken `texSubImage3D(..., null)` — which WebGL2 reads as "offset 0 +into PIXEL_UNPACK_BUFFER" and rejects with `INVALID_OPERATION`. Per-layer +uploads then go straight to the page's GPU texture through +`renderer.copyTextureToTexture`, fed by a per-call scratch canvas that is +collected after the copy. There is no CPU-side mirror. + +`flipY` is off on both the array and the scratch texture: the 3D upload path +forbids `UNPACK_FLIP_Y` / `PREMULTIPLY_ALPHA`, and leaving three.js's default on +logs "texImage3D: FLIP_Y … isn't allowed" on every allocation and every upload. +The UVs assume no flip, so the result is the same either way — this only +silences the error. + +`PANEL_TEX_SIZE` is 128 because panels sit on small building faces and rarely +cover more than a few hundred screen pixels: 128² × 4 = 64 KiB per layer, so a +2048-layer page is 128 MiB. + +## Uploads race the renderer + +The renderer is registered once, after `createCityScene` constructs it. An upload +that fires before that — a cached `.onload` beating renderer construction — +parks until it lands. Where the renderer never arrives (tests), the wait times +out and the upload resolves `false`, and the caller must leave the placeholder +up rather than fade in an empty layer. `dispose()` makes in-flight uploads bail +the same way, since a skeleton→final sequence disposes the old array while the +old loads are still draining. diff --git a/app/src/city/components/buildings/atlas.ts b/app/src/city/scene/components/buildings/atlas.ts similarity index 100% rename from app/src/city/components/buildings/atlas.ts rename to app/src/city/scene/components/buildings/atlas.ts diff --git a/app/src/city/components/buildings/building.frag.glsl b/app/src/city/scene/components/buildings/building.frag.glsl similarity index 100% rename from app/src/city/components/buildings/building.frag.glsl rename to app/src/city/scene/components/buildings/building.frag.glsl diff --git a/app/src/city/components/buildings/building.vert.glsl b/app/src/city/scene/components/buildings/building.vert.glsl similarity index 100% rename from app/src/city/components/buildings/building.vert.glsl rename to app/src/city/scene/components/buildings/building.vert.glsl diff --git a/app/src/city/scene/components/buildings/buildingIndex.ts b/app/src/city/scene/components/buildings/buildingIndex.ts new file mode 100644 index 000000000..73b81062a --- /dev/null +++ b/app/src/city/scene/components/buildings/buildingIndex.ts @@ -0,0 +1,22 @@ +// city/scene/components/buildings/buildingIndex.ts — the two lookups everything +// downstream uses instead of knowing the cell layout: path → Building, and +// "cellId:slotId" → Building for a raycast hit. Each Building carries those +// back-pointers, and a rebuild makes a fresh index, so nothing removes. + +import type { Building } from '@/city/scene/types'; + +export class BuildingIndex { + readonly byPath = new Map(); + private readonly _byCellSlot = new Map(); + + insert(b: Building): void { + const path = b.file?.path; + if (!path) return; + this.byPath.set(path, b); + this._byCellSlot.set(`${b.cellId}:${b.slotId}`, b); + } + + byCellSlot(key: string): Building | undefined { + return this._byCellSlot.get(key); + } +} diff --git a/app/src/city/components/buildings/buildingKind.ts b/app/src/city/scene/components/buildings/buildingKind.ts similarity index 100% rename from app/src/city/components/buildings/buildingKind.ts rename to app/src/city/scene/components/buildings/buildingKind.ts diff --git a/app/src/city/components/buildings/cellAssembly.ts b/app/src/city/scene/components/buildings/cellAssembly.ts similarity index 83% rename from app/src/city/components/buildings/cellAssembly.ts rename to app/src/city/scene/components/buildings/cellAssembly.ts index 20f15d184..df41232d7 100644 --- a/app/src/city/components/buildings/cellAssembly.ts +++ b/app/src/city/scene/components/buildings/cellAssembly.ts @@ -6,11 +6,14 @@ import * as THREE from 'three'; import { SpatialGrid, type WorldBounds } from './spatialGrid'; import { createEmptyCellTile, type CellTile, allocateSlot } from './cellTile'; import { attachBuildingMeshToCell, writeBuildingToSlot } from './cellMesh'; +import type { BuildingMaterial } from './material'; +import type { CityConfig } from '@/city/session/config'; import { InstancedFacadePanels } from './facadePanels'; import { isMediaFile, isDataBuilding, isEmptyFile } from '@/utils/fileKind'; -import { BUILDINGS } from '@/state/settings/fields/buildings'; import { BuildingIndex } from './buildingIndex'; -import type { Building, SourceRef } from '@/types/index'; +import type { TimelineStore } from '@/city/session/stores/timeline'; +import type { SourceRef } from '@/types'; +import type { Building } from '@/city/scene/types'; export interface CellAssemblyOutput { grid: SpatialGrid; @@ -26,8 +29,14 @@ export interface CellAssemblyOutput { export function buildCellsFromLayout( bounds: WorldBounds, buildings: Building[], - source: SourceRef | null + source: SourceRef | null, + /** This city's history: which blob a facade reads is a scrub question. */ + timeline: TimelineStore | null, + /** This city's material and icon atlas, shared by every cell of it. */ + material: BuildingMaterial, + config: CityConfig ): CellAssemblyOutput { + const facade = config.BUILDINGS.value; const cellSize = SpatialGrid.computeOptimalCellSize(bounds); const grid = new SpatialGrid(bounds, cellSize); @@ -51,7 +60,7 @@ export function buildCellsFromLayout( const cells = new Map(); for (const [id, count] of cellCounts) { const cell = createEmptyCellTile(grid, id, cellCapacityFor(count), cellExtents.get(id)); - attachBuildingMeshToCell(cell); + attachBuildingMeshToCell(cell, material); cells.set(id, cell); } @@ -80,7 +89,7 @@ export function buildCellsFromLayout( b.cellId = cellId; b.slotId = slot; cell.buildings[slot] = b; - writeBuildingToSlot(cell, b); + writeBuildingToSlot(cell, b, facade, material.getIconAtlas()); index.insert(b); } @@ -100,18 +109,23 @@ export function buildCellsFromLayout( // One mesh serves both, so they share the LOD/streaming/fade machinery. // Textures load later: updateLOD streams in the ones actually on screen. - const mediaBuildings = BUILDINGS.value.MEDIA_ENABLED + const mediaBuildings = facade.MEDIA_ENABLED ? buildings.filter((b) => isMediaFile(b.file) && !isEmptyFile(b.file)) : []; // DATA_ENABLED gates only the facade texture; the windowless block still // renders from the building mesh (cellMesh) regardless. - const binaryBuildings = BUILDINGS.value.DATA_ENABLED + const binaryBuildings = facade.DATA_ENABLED ? buildings.filter((b) => isDataBuilding(b.file) && !isEmptyFile(b.file)) : []; let facadePanels: InstancedFacadePanels | null = null; const panelCount = mediaBuildings.length + binaryBuildings.length; if (panelCount > 0) { - facadePanels = new InstancedFacadePanels(Math.max(64, Math.ceil(panelCount * 1.5)), source); + facadePanels = new InstancedFacadePanels( + Math.max(64, Math.ceil(panelCount * 1.5)), + source, + timeline, + config + ); for (const b of mediaBuildings) facadePanels.registerMediaBuilding(b); for (const b of binaryBuildings) facadePanels.registerBinaryBuilding(b); sceneRoot.add(facadePanels.mesh); diff --git a/app/src/city/components/buildings/cellMesh.ts b/app/src/city/scene/components/buildings/cellMesh.ts similarity index 93% rename from app/src/city/components/buildings/cellMesh.ts rename to app/src/city/scene/components/buildings/cellMesh.ts index e1843ec03..51f85271d 100644 --- a/app/src/city/components/buildings/cellMesh.ts +++ b/app/src/city/scene/components/buildings/cellMesh.ts @@ -4,11 +4,12 @@ // vertex and index buffers stay shared. import * as THREE from 'three'; -import { BUILDINGS } from '@/state/settings/fields/buildings'; -import { BuildingOrient } from '@/types/index'; +import type { BuildingsConfig } from '@/city/session/settings/buildings'; +import type { IconAtlas } from './atlas'; +import { BuildingOrient } from '@/city/scene/types'; import type { CellTile } from './cellTile'; -import type { Building } from '@/types/index'; -import { getBuildingMaterial, getIconAtlas } from './material'; +import type { Building } from '@/city/scene/types'; +import type { BuildingMaterial } from './material'; import { getFileIconName } from '@/utils/fileIcons'; import { isDataBuilding, isEmptyFile, isUnmeasuredFile } from '@/utils/fileKind'; import { BuildingKind } from './buildingKind'; @@ -47,7 +48,7 @@ function orientToIndex(orient: BuildingOrient): number { /** Give a cell the real geometry, the shared material, and attribute buffers * for its capacity. The material belongs to material.ts: never dispose it. */ -export function attachBuildingMeshToCell(cell: CellTile): void { +export function attachBuildingMeshToCell(cell: CellTile, material: BuildingMaterial): void { const geom = SHARED_BUILDING_GEOMETRY.clone(); // Names and strides match the attributes building.vert.glsl declares. @@ -92,7 +93,7 @@ export function attachBuildingMeshToCell(cell: CellTile): void { new THREE.InstancedBufferAttribute(new Float32Array(cell.capacity * 3), 3) ); - const mat = getBuildingMaterial(); + const mat = material.get(); // Replace the placeholder mesh in-place. cell.detailMesh.geometry.dispose(); @@ -110,12 +111,15 @@ export function attachBuildingMeshToCell(cell: CellTile): void { /** One building's attributes into its slot. cellId and slotId must be set, and * the caller flags the buffers dirty once per batch rather than per write. */ -export function writeBuildingToSlot(cell: CellTile, b: Building): void { +export function writeBuildingToSlot( + cell: CellTile, + b: Building, + facade: BuildingsConfig, + atlas: IconAtlas | null +): void { const slot = b.slotId!; const mesh = cell.detailMesh; - // --- Config snapshot (read once per write, not per attribute) --- - const facade = BUILDINGS.value; const windowColsMax = facade.WINDOW_COLS_MAX; const widthPerWindowCol = facade.WIDTH_PER_WINDOW_COL; const doorWidthFrac = facade.DOOR_WIDTH_FRAC; @@ -136,7 +140,8 @@ export function writeBuildingToSlot(cell: CellTile, b: Building): void { _writeColor.set( getBuildingColorForRecency( b.file as unknown as Parameters[0], - 1 + 1, + facade ) ); const iRefColorAttr = mesh.geometry.getAttribute('iRefColor') as THREE.InstancedBufferAttribute; @@ -176,7 +181,6 @@ export function writeBuildingToSlot(cell: CellTile, b: Building): void { const iIconUVAttr = mesh.geometry.getAttribute('iIconUV') as THREE.InstancedBufferAttribute; let iconU = -1.0; let iconV = -1.0; - const atlas = getIconAtlas(); if (atlas && b.file) { const iconName = getFileIconName(b.file); const uv = atlas.uvFor(iconName); diff --git a/app/src/city/components/buildings/cellTile.ts b/app/src/city/scene/components/buildings/cellTile.ts similarity index 98% rename from app/src/city/components/buildings/cellTile.ts rename to app/src/city/scene/components/buildings/cellTile.ts index eb9035076..650881d52 100644 --- a/app/src/city/components/buildings/cellTile.ts +++ b/app/src/city/scene/components/buildings/cellTile.ts @@ -4,7 +4,7 @@ // and friends do the geometry and material assembly. import * as THREE from 'three'; -import type { Building } from '@/types/index'; +import type { Building } from '@/city/scene/types'; import type { SpatialGrid } from './spatialGrid'; export interface CellTile { diff --git a/app/src/city/components/buildings/color.ts b/app/src/city/scene/components/buildings/color.ts similarity index 69% rename from app/src/city/components/buildings/color.ts rename to app/src/city/scene/components/buildings/color.ts index 6c48f1316..aaf7287e3 100644 --- a/app/src/city/components/buildings/color.ts +++ b/app/src/city/scene/components/buildings/color.ts @@ -3,10 +3,10 @@ // utils/recency, so dim means nobody has touched this in a long time rather than // merely that it is the oldest here. getCreatedAge is a separate axis. -import { BUILDINGS } from '@/state/settings/fields/buildings'; -import { recencyT } from '@/city/utils/recency'; +import type { BuildingsConfig } from '@/city/session/settings/buildings'; +import { recencyT } from '@/city/scene/utils/recency'; import { NodeKind } from '@/types'; -import type { DateRanges } from '@/types'; +import type {} from '@/types'; import { parseDateMs } from '@/utils/dates'; // created/modified stay optional so a sparse mock still reaches the no-date @@ -56,40 +56,42 @@ function lerpRange(t: number, config: MinMaxRange): number { // ── Building color ──────────────────────────────────────────────────────────── -/** Weathering from the creation date, 1 for the repo's oldest file. Colour - * samples the modified date, so the two say different things. */ -export function getCreatedAge(file: FileLike, dateRanges: DateRanges): number { - const created = file.created || null; - if (!created) return 0.5; // unknown → midpoint (half-weathered) - const c = parseDateMs(created); - const min = parseDateMs(dateRanges.minCreated || ''); - const max = parseDateMs(dateRanges.maxCreated || ''); - if (isNaN(c) || isNaN(min) || isNaN(max) || max === min) return 0; - const t = (c - min) / (max - min); - return Math.max(0, Math.min(1, 1 - t)); +/** Weathering from how long ago the file was CREATED, 1 for the longest + * standing: laid down years ago and edited yesterday reads grimy and vivid. */ +export function getCreatedAge(file: FileLike, nowMs: number, cfg: BuildingsConfig): number { + return 1 - createdRecency(file, nowMs, cfg); +} + +/** How lately a file came into existence, on colour's curve but its own clock: + * standing a year and going untouched a year are not the same span. */ +export function createdRecency(file: FileLike, nowMs: number, cfg: BuildingsConfig): number { + return recencyT(parseDateMs(file.created || ''), nowMs, cfg.CREATED_HALF_LIFE_DAYS); } /** The colour axis inverted for the shader's iModifiedAge: 1 = longest * untouched. Shares modifiedRecency so Live and Timeline agree at HEAD. */ -export function getModifiedAge(file: FileLike, nowMs: number): number { - return 1 - modifiedRecency(file, nowMs); +export function getModifiedAge(file: FileLike, nowMs: number, cfg: BuildingsConfig): number { + return 1 - modifiedRecency(file, nowMs, cfg); } /** One building's colour, from the palette and the configured ranges. */ -export function getBuildingColor(file: FileLike, nowMs: number): string { - return getBuildingColorForRecency(file, modifiedRecency(file, nowMs)); +export function getBuildingColor(file: FileLike, nowMs: number, cfg: BuildingsConfig): string { + return getBuildingColorForRecency(file, modifiedRecency(file, nowMs, cfg), cfg); } /** How recently a file was touched, from its own age alone: a long-lived file * edited yesterday reads vivid and grimy at once. */ -export function modifiedRecency(file: FileLike, nowMs: number): number { - return recencyT(parseDateMs(file.modified || ''), nowMs, BUILDINGS.value.HALF_LIFE_DAYS); +export function modifiedRecency(file: FileLike, nowMs: number, cfg: BuildingsConfig): number { + return recencyT(parseDateMs(file.modified || ''), nowMs, cfg.MODIFIED_HALF_LIFE_DAYS); } /** getBuildingColor's curve driven by an explicit recency (1 freshest), so the * scrub can re-weather a building each frame through the very same maths. */ -export function getBuildingColorForRecency(file: FileLike, recency: number): string { - const palette = BUILDINGS.value; +export function getBuildingColorForRecency( + file: FileLike, + recency: number, + palette: BuildingsConfig +): string { const h = getHue(file.extension || '', palette.HUE_EXT_MAP); const s = lerpRange(recency, { min: palette.SATURATION_MIN, max: palette.SATURATION_MAX }); const l = lerpRange(recency, { min: palette.LIGHTNESS_MIN, max: palette.LIGHTNESS_MAX }); diff --git a/app/src/city/components/buildings/dataFacade.ts b/app/src/city/scene/components/buildings/dataFacade.ts similarity index 89% rename from app/src/city/components/buildings/dataFacade.ts rename to app/src/city/scene/components/buildings/dataFacade.ts index 9ebf638e1..78b5540f6 100644 --- a/app/src/city/components/buildings/dataFacade.ts +++ b/app/src/city/scene/components/buildings/dataFacade.ts @@ -3,9 +3,8 @@ // white-on-transparent like the server fingerprint, and browser-native, so the // backend needs no decoder for either. -import { fetchFileBytes } from '@/api/file'; +import { fetchFileBytes } from '@/city/session/api/file'; import type { SourceRef } from '@/types'; -import { scrubbedBlobShaFor } from '@/state/stores/timeline'; import { FONT_EXTS, AUDIO_EXTS } from '@/constants/fileExtensions'; import { PANEL_TEX_SIZE } from './facadePanelTextureArray'; @@ -36,14 +35,16 @@ let _fontSeq = 0; export async function renderFontGlyphFacade( source: SourceRef, path: string, - version: string + version: string, + /** The blob to read, when a scrub is pointing at one rather than at HEAD. */ + blobSha: string | null ): Promise { if (typeof FontFace === 'undefined' || typeof document.fonts === 'undefined') return null; const surface = _canvas(); if (!surface) return null; let face: FontFace | null = null; try { - const buf = await fetchFileBytes(source, path, version, scrubbedBlobShaFor(path)); + const buf = await fetchFileBytes(source, path, version, blobSha); const family = `cc-facade-font-${(_fontSeq += 1)}`; face = await new FontFace(family, buf).load(); document.fonts.add(face); @@ -79,13 +80,15 @@ function _getAudioCtx(): AudioContext | null { export async function renderWaveformFacade( source: SourceRef, path: string, - version: string + version: string, + /** The blob to read, when a scrub is pointing at one rather than at HEAD. */ + blobSha: string | null ): Promise { const audioCtx = _getAudioCtx(); const surface = _canvas(); if (!audioCtx || !surface) return null; try { - const buf = await fetchFileBytes(source, path, version, scrubbedBlobShaFor(path)); + const buf = await fetchFileBytes(source, path, version, blobSha); // decodeAudioData detaches its input, so hand it a copy. const audio = await audioCtx.decodeAudioData(buf.slice(0)); const data = audio.getChannelData(0); diff --git a/app/src/city/components/buildings/facadePanel.frag.glsl b/app/src/city/scene/components/buildings/facadePanel.frag.glsl similarity index 100% rename from app/src/city/components/buildings/facadePanel.frag.glsl rename to app/src/city/scene/components/buildings/facadePanel.frag.glsl diff --git a/app/src/city/components/buildings/facadePanel.vert.glsl b/app/src/city/scene/components/buildings/facadePanel.vert.glsl similarity index 100% rename from app/src/city/components/buildings/facadePanel.vert.glsl rename to app/src/city/scene/components/buildings/facadePanel.vert.glsl diff --git a/app/src/city/scene/components/buildings/facadePanelTextureArray.ts b/app/src/city/scene/components/buildings/facadePanelTextureArray.ts new file mode 100644 index 000000000..d9091cd98 --- /dev/null +++ b/app/src/city/scene/components/buildings/facadePanelTextureArray.ts @@ -0,0 +1,198 @@ +// city/components/buildings/facadePanelTextureArray.ts — paged +// DataArrayTexture manager for the instanced facade panels. Why it pages, how +// it stores, and what races the renderer: FACADE_PANELS.md. + +import * as THREE from 'three'; + +// Small faces, few hundred screen pixels: see FACADE_PANELS.md. +export const PANEL_TEX_SIZE = 128; + +// Pages the shader can dispatch into, and the one source of truth for its +// #define: sizing and drift are FACADE_PANELS.md's "Shader page count". +export const MAX_PAGES = 8; + +// Set once the renderer exists; uploads that beat it park here. +// FACADE_PANELS.md, "Uploads race the renderer". +let _renderer: THREE.WebGLRenderer | null = null; +const _rendererWaiters: Array<(r: THREE.WebGLRenderer | null) => void> = []; +const _RENDERER_WAIT_TIMEOUT_MS = 5000; + +export function registerRenderer(renderer: THREE.WebGLRenderer): void { + _renderer = renderer; + if (_rendererWaiters.length > 0) { + const waiters = _rendererWaiters.splice(0); + for (const w of waiters) w(renderer); + } +} + +function _whenRendererReady(): Promise { + if (_renderer) return Promise.resolve(_renderer); + return new Promise((resolve) => { + let settled = false; + const finish = (r: THREE.WebGLRenderer | null): void => { + if (settled) return; + settled = true; + resolve(r); + }; + _rendererWaiters.push(finish); + setTimeout(() => finish(null), _RENDERER_WAIT_TIMEOUT_MS); + }); +} + +// The hardware's MAX_ARRAY_TEXTURE_LAYERS, probed once; the spec minimum +// stands in where there is no browser. +let _maxArrayLayersCache: number | null = null; +const _MAX_ARRAY_LAYERS_FALLBACK = 256; + +function _detectMaxArrayLayers(): number { + if (_maxArrayLayersCache !== null) return _maxArrayLayersCache; + if (typeof document === 'undefined') { + _maxArrayLayersCache = _MAX_ARRAY_LAYERS_FALLBACK; + return _maxArrayLayersCache; + } + try { + const probe = document.createElement('canvas'); + const gl = probe.getContext('webgl2') as WebGL2RenderingContext | null; + if (gl) { + const limit = gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS); + _maxArrayLayersCache = + typeof limit === 'number' && limit > 0 ? limit : _MAX_ARRAY_LAYERS_FALLBACK; + } else { + _maxArrayLayersCache = _MAX_ARRAY_LAYERS_FALLBACK; + } + } catch { + _maxArrayLayersCache = _MAX_ARRAY_LAYERS_FALLBACK; + } + return _maxArrayLayersCache; +} + +export class FacadePanelTextureArray { + /** One per used page, in [1, MAX_PAGES]; `shaderTextures` pads it out. */ + readonly textures: THREE.DataArrayTexture[]; + /** Layers per page, and the `uPageSize` uniform the shader decomposes + * iLayerIndex with, so the split follows whatever hardware reports. */ + readonly pageSize: number; + private readonly _capacity: number; + private _next = 0; + // Makes in-flight uploads bail rather than write to deleted textures: a + // skeleton→final sequence disposes this while its loads are still draining. + private _disposed = false; + + constructor(capacity = 256) { + const pageSize = _detectMaxArrayLayers(); + this.pageSize = pageSize; + + const hardCap = MAX_PAGES * pageSize; + if (capacity > hardCap) { + // Genuinely past what the shader can address. Bumping MAX_PAGES + // (and the matching `else if` branches in the shader) is the fix. + console.warn( + `[facadePanelTextureArray] requested capacity ${capacity} exceeds ` + + `MAX_PAGES (${MAX_PAGES}) × pageSize (${pageSize}) = ${hardCap}. ` + + `Bump MAX_PAGES in facadePanelTextureArray.ts and add matching shader ` + + `branches in facadePanel.frag.glsl to support more layers.` + ); + capacity = hardCap; + } + this._capacity = capacity; + + this.textures = []; + let remaining = capacity; + while (remaining > 0) { + const depth = Math.min(remaining, pageSize); + const tex = new THREE.DataArrayTexture(null, PANEL_TEX_SIZE, PANEL_TEX_SIZE, depth); + tex.format = THREE.RGBAFormat; + tex.type = THREE.UnsignedByteType; + tex.minFilter = THREE.LinearFilter; + tex.magFilter = THREE.LinearFilter; + // The 3D upload path forbids UNPACK_FLIP_Y; the UVs assume none anyway. + tex.flipY = false; + // Allocate only: skips the texSubImage3D(..., null) WebGL2 rejects. + tex.source.dataReady = false; + tex.needsUpdate = true; + this.textures.push(tex); + remaining -= depth; + } + } + + /** The shader's fixed-size sampler array. Padding reuses page 0, which + * iLayerIndex can never select, so it is a valid-binding formality. */ + get shaderTextures(): THREE.DataArrayTexture[] { + const padded = this.textures.slice(); + while (padded.length < MAX_PAGES) { + padded.push(this.textures[0]); + } + return padded; + } + + /** The next flat layer, or -1 when capacity is spent and the caller + * should skip that building's panels. Becomes its iLayerIndex. */ + allocate(): number { + if (this._next >= this._capacity) return -1; + return this._next++; + } + + /** Blit `img` into `flatLayer`. False means the layer is still unwritten, + * so the caller must not advance iTextureFade onto it. */ + async uploadImage(flatLayer: number, img: HTMLImageElement): Promise { + return this._uploadFromCanvas(flatLayer, _scaleToScratch(img)); + } + + /** Blit a canvas (e.g. a video first-frame poster) into the layer at + * `flatLayer`. Same semantics + return contract as uploadImage. */ + async uploadCanvas(flatLayer: number, src: HTMLCanvasElement): Promise { + return this._uploadFromCanvas(flatLayer, _scaleToScratch(src)); + } + + private async _uploadFromCanvas(flatLayer: number, canvas: HTMLCanvasElement): Promise { + if (this._disposed) return false; + const renderer = await _whenRendererReady(); + if (!renderer || this._disposed) return false; + const page = Math.floor(flatLayer / this.pageSize); + const localLayer = flatLayer - page * this.pageSize; + const dstTex = this.textures[page]; + if (!dstTex) return false; + + const tempTex = new THREE.CanvasTexture(canvas); + tempTex.format = THREE.RGBAFormat; + tempTex.type = THREE.UnsignedByteType; + tempTex.minFilter = THREE.LinearFilter; + tempTex.magFilter = THREE.LinearFilter; + // The copy applies the SOURCE's unpack flags, and the 3D path forbids + // both of these: on by default they log on every upload. + tempTex.flipY = false; + tempTex.premultiplyAlpha = false; + // srcRegion bounds are EXCLUSIVE, and dstPosition.z picks the layer + // within this page. + renderer.copyTextureToTexture( + tempTex, + dstTex, + new THREE.Box3( + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(PANEL_TEX_SIZE, PANEL_TEX_SIZE, 1) + ), + new THREE.Vector3(0, 0, localLayer) + ); + tempTex.dispose(); + return true; + } + + /** Number of layers allocated so far (across all pages). */ + get count(): number { + return this._next; + } + + dispose(): void { + this._disposed = true; + for (const tex of this.textures) tex.dispose(); + } +} + +function _scaleToScratch(src: CanvasImageSource): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = PANEL_TEX_SIZE; + canvas.height = PANEL_TEX_SIZE; + const ctx = canvas.getContext('2d')!; + ctx.drawImage(src, 0, 0, PANEL_TEX_SIZE, PANEL_TEX_SIZE); + return canvas; +} diff --git a/app/src/city/components/buildings/facadePanels.ts b/app/src/city/scene/components/buildings/facadePanels.ts similarity index 94% rename from app/src/city/components/buildings/facadePanels.ts rename to app/src/city/scene/components/buildings/facadePanels.ts index 1820c60be..e06b20615 100644 --- a/app/src/city/components/buildings/facadePanels.ts +++ b/app/src/city/scene/components/buildings/facadePanels.ts @@ -5,26 +5,26 @@ import * as THREE from 'three'; import { untracked } from '@preact/signals'; -import { BuildingOrient, type SourceRef } from '@/types/index'; -import { BLOOM } from '@/state/settings/fields/effects'; -import { BUILDING_DIMENSIONS, BUILDINGS } from '@/state/settings/fields/buildings'; -import { MEDIA_ERROR_COLOR } from '@/constants/buildings'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; +import { type SourceRef } from '@/types'; +import { BuildingOrient } from '@/city/scene/types'; +import type { CityConfig } from '@/city/session/config'; +import { MEDIA_ERROR_COLOR } from '@/city/scene/constants/buildings'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; import { mediaKindOf, MediaKind, isDataBuilding } from '@/utils/fileKind'; import { FacadePanelTextureArray, MAX_PAGES as FACADE_PANEL_MAX_PAGES, } from './facadePanelTextureArray'; +import type { TimelineStore } from '@/city/session/stores/timeline'; import { - SETTLED_COMMIT, - TIMELINE_MODE, - hasNoContentAtScrub, - scrubbedBlobShaFor, -} from '@/state/stores/timeline'; -import { ContentPendingError, fetchFileBlob, fileUrl, isContentPending } from '@/api/file'; -import { fingerprintUrl } from '@/api/fingerprint'; + ContentPendingError, + fetchFileBlob, + fileUrl, + isContentPending, +} from '@/city/session/api/file'; +import { fingerprintUrl } from '@/city/session/api/fingerprint'; import { dataFacadeKind, renderFontGlyphFacade, renderWaveformFacade } from './dataFacade'; -import type { Building } from '@/types/index'; +import type { Building } from '@/city/scene/types'; import facadePanelVertSrc from './facadePanel.vert.glsl?raw'; import facadePanelFragSrc from './facadePanel.frag.glsl?raw'; @@ -90,8 +90,8 @@ const LIVE_STAMP = -1; /** What a load of this building would fetch right now. Untracked: a rebuild * computes it, and must not come back to life on every scrub. */ -function versionKeyFor(b: Building): string { - return untracked(() => scrubbedBlobShaFor(b.file?.path) ?? b.file?.modified ?? ''); +function versionKeyFor(b: Building, timeline: TimelineStore | null): string { + return untracked(() => timeline?.scrubbedBlobShaFor(b.file?.path) ?? b.file?.modified ?? ''); } export class InstancedFacadePanels { @@ -158,10 +158,15 @@ export class InstancedFacadePanels { // The city's own source: paths are repo-relative, so a facade read is // meaningless without the repo they are relative to. readonly source: SourceRef | null, + // This city's history, when something scrubs it: which blob a facade reads + // is a question about where the scrubber is. + readonly timeline: TimelineStore | null, + // What this city looks like: panel size, emission and the bloom gate. + readonly config: CityConfig, opts?: { onStartLoad?: (b: Building, layer: number, panelSlots: number[]) => void } ) { this._overrideStartLoad = opts?.onStartLoad ?? null; - this._versionStamp = TIMELINE_MODE.peek() ? SETTLED_COMMIT.peek() : LIVE_STAMP; + this._versionStamp = timeline?.mode.peek() ? timeline.settledCommit.peek() : LIVE_STAMP; this._capacity = mediaFileCapacity; // 4 faces per media building → total slot count. const slotCount = mediaFileCapacity * 4; @@ -173,13 +178,13 @@ export class InstancedFacadePanels { const geo = new THREE.PlaneGeometry(1, 1); // Material — GLSL3 required for sampler2DArray. - const adCfg = BUILDINGS.value; + const adCfg = this.config.BUILDINGS.value; const placeholderColor = new THREE.Color(adCfg.MEDIA_PLACEHOLDER_COLOR); // Cached for markBuildingErrored — recolors a slot's iColor on a permanent // media load failure. No emission bake; the shader applies it per kind. this._errorColor = new THREE.Color(MEDIA_ERROR_COLOR); - const bloomCfg = BLOOM.value; + const bloomCfg = this.config.BLOOM.value; const mat = new THREE.ShaderMaterial({ glslVersion: THREE.GLSL3, // Injected as a #define: it sizes the sampler array and gates the @@ -266,8 +271,8 @@ export class InstancedFacadePanels { const mh = b.file.media_height; const rawAspect = mw && mh && mw > 0 ? mh / mw : 1.0; const aspect = Math.min(2.5, Math.max(0.4, rawAspect)); - const cfg = BUILDINGS.value; - const dims = BUILDING_DIMENSIONS.value; + const cfg = this.config.BUILDINGS.value; + const dims = this.config.BUILDING_DIMENSIONS.value; const panelWidth = Math.max(0.1, b.w * (1 - 2 * cfg.MEDIA_SIDE_MARGIN_FRAC)); const panelHeight = panelWidth * aspect; const centerY = cfg.MEDIA_BOTTOM_OFFSET_FLOORS * dims.FLOOR_HEIGHT + panelHeight / 2; @@ -392,7 +397,7 @@ export class InstancedFacadePanels { radius: Math.max(b.w, b.d, panelHeight), shown: true, loadedKey: null, - wantKey: versionKeyFor(b), + wantKey: versionKeyFor(b, this.timeline), startLoad, }); @@ -537,8 +542,8 @@ export class InstancedFacadePanels { /** Hot-reload emission + data tint from config (no rebuild). */ refresh(): void { - const enabled = BLOOM.value.ENABLED; - const cfg = BUILDINGS.value; + const enabled = this.config.BLOOM.value.ENABLED; + const cfg = this.config.BUILDINGS.value; const u = this._material.uniforms; u.uMediaEmission.value = enabled ? cfg.MEDIA_EMISSION : 1.0; u.uDataEmission.value = enabled ? cfg.DATA_EMISSION : 1.0; @@ -548,10 +553,10 @@ export class InstancedFacadePanels { /** Re-ask every panel which version it wants, only when the scrub moved: * versionKeyFor walks a path's history, so it stays off the per-frame path. */ private _refreshWantedVersions(): void { - const stamp = TIMELINE_MODE.peek() ? SETTLED_COMMIT.peek() : LIVE_STAMP; + const stamp = this.timeline?.mode.peek() ? this.timeline.settledCommit.peek() : LIVE_STAMP; if (stamp === this._versionStamp) return; this._versionStamp = stamp; - for (const rec of this._panels) rec.wantKey = versionKeyFor(rec.b); + for (const rec of this._panels) rec.wantKey = versionKeyFor(rec.b, this.timeline); } dispose(): void { @@ -601,14 +606,14 @@ function asyncLoadMediaForBuilding( if (!kind) return; // Absent at this commit: there is no blob, and asking by path would hit HEAD // and 404. The building just shows no image. - if (hasNoContentAtScrub(b.file.path)) return; + if (ads.timeline?.hasNoContentAtScrub(b.file.path)) return; const source = ads.source; if (!source) return; const filePath = b.file.path || ''; // Scrubbed commits pin a version; Live keys on mtime. Either way the URL names // one immutable body, so a rebuild re-reads it from the browser cache. - const sha = scrubbedBlobShaFor(b.file.path); + const sha = ads.timeline?.scrubbedBlobShaFor(b.file.path) ?? null; const version = b.file.modified || ''; if (kind === MediaKind.Image) { @@ -626,16 +631,17 @@ function asyncLoadDataFacadeForBuilding( layer: number, panelSlots: number[] ): void { - if (hasNoContentAtScrub(b.file.path)) return; + if (ads.timeline?.hasNoContentAtScrub(b.file.path)) return; const source = ads.source; if (!source) return; const filePath = b.file.path || ''; const version = b.file.modified || ''; + const sha = ads.timeline?.scrubbedBlobShaFor(b.file.path) ?? null; switch (dataFacadeKind(b.file.extension || '')) { case 'font': void _loadCanvasFacade( ads, - () => renderFontGlyphFacade(source, filePath, version), + () => renderFontGlyphFacade(source, filePath, version, sha), layer, panelSlots ); @@ -643,7 +649,7 @@ function asyncLoadDataFacadeForBuilding( case 'audio': void _loadCanvasFacade( ads, - () => renderWaveformFacade(source, filePath, version), + () => renderWaveformFacade(source, filePath, version, sha), layer, panelSlots ); diff --git a/app/src/city/components/buildings/fadeTiers.ts b/app/src/city/scene/components/buildings/fadeTiers.ts similarity index 92% rename from app/src/city/components/buildings/fadeTiers.ts rename to app/src/city/scene/components/buildings/fadeTiers.ts index 52a019da3..02384700c 100644 --- a/app/src/city/components/buildings/fadeTiers.ts +++ b/app/src/city/scene/components/buildings/fadeTiers.ts @@ -2,9 +2,11 @@ // the live fader and the timeline scrub controller, so a hover dims the city the // same way whether or not you are time-travelling. import { NodeKind } from '@/types'; -import type { BuildingsConfig } from '@/state/settings/fields/buildings'; -import type { DirNode, FadeDetail, FileNode, PickTarget, Street } from '@/types'; -import { parentDirPath } from '@/city/utils/path'; +import type { BuildingsConfig } from '@/city/session/settings/buildings'; +import type { DirNode, FileNode } from '@/types'; +import type { FadeDetail, Street } from '@/city/scene/types'; +import type { PickTarget } from '@/city/scene/types/picker'; +import { parentDirPath } from '@/city/scene/utils/path'; import { ROOT_PATH } from '@/constants/manifest'; export interface TierResult { diff --git a/app/src/city/components/buildings/fader.ts b/app/src/city/scene/components/buildings/fader.ts similarity index 76% rename from app/src/city/components/buildings/fader.ts rename to app/src/city/scene/components/buildings/fader.ts index 9c61591b5..b5cbcab78 100644 --- a/app/src/city/components/buildings/fader.ts +++ b/app/src/city/scene/components/buildings/fader.ts @@ -5,18 +5,20 @@ import * as THREE from 'three'; import { effect, untracked } from '@preact/signals'; -import { BUILDINGS } from '@/state/settings/fields/buildings'; -import { TIMELINE_MODE } from '@/state/stores/timeline'; -import { FadeDetail, NodeKind } from '@/types'; +import type { BuildingsConfig } from '@/city/session/settings/buildings'; +import type { ReadonlySignal } from '@preact/signals'; +import type { TimelineStore } from '@/city/session/stores/timeline'; +import { NodeKind } from '@/types'; +import { FadeDetail } from '@/city/scene/types'; import { resolveDirTarget, tierFor } from './fadeTiers'; -import { setBuildingsTranslucent } from './material'; +import type { BuildingMaterial } from './material'; import type { CellTile } from './cellTile'; import type { InstancedFacadePanels } from './facadePanels'; -import type { createPicker } from '@/city/interaction/picker'; -import type { CityState } from '@/city/state'; +import type { createPicker } from '@/city/scene/interaction/picker'; +import type { CityBuild } from '@/city/scene/build'; // Narrow surface, so the fader doesn't take the buildings component's whole -// handle; the street-by-dir lookup comes from cityState directly. +// handle; the street-by-dir lookup comes from build directly. interface FaderWorld { getCells(): Map; getFacadePanels(): InstancedFacadePanels | null; @@ -24,25 +26,33 @@ interface FaderWorld { export function createBuildingFader({ world, - cityState, + build, picker, + timeline, + material, + buildings, }: { world: FaderWorld; - cityState: CityState; + build: CityBuild; picker: ReturnType; + /** This city's history, when something scrubs it. */ + timeline: TimelineStore | null; + /** This city's material: a fade is what makes its buildings translucent. */ + material: BuildingMaterial; + buildings: ReadonlySignal; }) { function _sweepAll(): void { // Timeline mode owns iFade per frame (scrub controller); a hover/select sweep // here would fight it. Live mode never takes this branch, so it stays identical. - if (TIMELINE_MODE.peek()) return; + if (timeline?.mode.peek()) return; const sel = picker.selection.value; const hov = picker.hover.value; const bldgTargetFile = sel && sel.kind === NodeKind.File ? sel.file : null; - const dirTarget = resolveDirTarget(sel, hov, cityState.streetsByDirMap.peek()); + const dirTarget = resolveDirTarget(sel, hov, build.streetsByDirMap.peek()); const hoverFile = hov && hov.kind === NodeKind.File ? hov.file : null; - const fadeCfg = BUILDINGS.value; + const fadeCfg = buildings.value; // Built here rather than inside applyBuildingFades' per-slot callback, // which would re-walk the tree per slot. @@ -79,7 +89,7 @@ export function createBuildingFader({ iFadeAttr.needsUpdate = true; } - setBuildingsTranslucent(anyTranslucent); + material.setTranslucent(anyTranslucent); // A media building's 4 panels take the same tier as its body, so the // billboard dims with the wall it sits on. @@ -103,14 +113,14 @@ export function createBuildingFader({ // A rebuild resets iFade to opaque while the selection still stands. // untracked: _sweepAll's own reads would double this up with the effects above. const _unsubChange = effect(() => { - void cityState.cityRevision.value; + void build.cityRevision.value; untracked(_sweepAll); }); - // Every value _sweepAll reads comes from BUILDINGS, so dragging a slider in - // the controls pane has to re-sweep to show up. + // Every value _sweepAll reads comes from the buildings config, so a change + // in the controls pane has to re-sweep to show up. const _unsubCfg = effect(() => { - void BUILDINGS.value; + void buildings.value; _sweepAll(); }); diff --git a/app/src/city/components/buildings/ghost.ts b/app/src/city/scene/components/buildings/ghost.ts similarity index 95% rename from app/src/city/components/buildings/ghost.ts rename to app/src/city/scene/components/buildings/ghost.ts index 8137c7381..e775b7f24 100644 --- a/app/src/city/components/buildings/ghost.ts +++ b/app/src/city/scene/components/buildings/ghost.ts @@ -6,9 +6,10 @@ import * as THREE from 'three'; import { effect } from '@preact/signals'; import { NodeKind } from '@/types'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; -import type { Building, FileTarget } from '@/types'; -import type { createPicker } from '@/city/interaction/picker'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; +import type { Building } from '@/city/scene/types'; +import type { FileTarget } from '@/city/scene/types/picker'; +import type { createPicker } from '@/city/scene/interaction/picker'; // Narrow world surface the ghost needs (mesh resolver only). Supplied by the // buildings component. diff --git a/app/src/city/components/buildings/index.ts b/app/src/city/scene/components/buildings/index.ts similarity index 90% rename from app/src/city/components/buildings/index.ts rename to app/src/city/scene/components/buildings/index.ts index 6dc79f7d6..dd7c48809 100644 --- a/app/src/city/components/buildings/index.ts +++ b/app/src/city/scene/components/buildings/index.ts @@ -6,12 +6,7 @@ import * as THREE from 'three'; import { effect, untracked } from '@preact/signals'; -import { BUILDINGS, BUILDING_DIMENSIONS } from '@/state/settings/fields/buildings'; -import { BLOOM } from '@/state/settings/fields/effects'; -import { SCENE } from '@/state/settings/fields/scene'; -import { RUINS } from '@/state/settings/fields/ruins'; -import { TIMELINE_MODE } from '@/state/stores/timeline'; -import type { Building, CityLayout, DateRanges, EnteringBuilding, StayingBuilding } from '@/types'; +import type { Building, CityLayout, EnteringBuilding, StayingBuilding } from '@/city/scene/types'; import type { FrameContext, SceneComponent, SceneContext } from '../../types'; import { armOnFirstTick } from '../../utils/armOnFirstTick'; @@ -20,8 +15,7 @@ import type { CellTile } from './cellTile'; import { BuildingIndex } from './buildingIndex'; import { buildCellsFromLayout } from './cellAssembly'; import type { InstancedFacadePanels } from './facadePanels'; -import { refreshBuildingMaterial } from './material'; -import { disposeObject3D } from '@/city/utils/disposeObject3D'; +import { disposeObject3D } from '@/city/scene/utils/disposeObject3D'; import { sourceOf } from '@/utils/manifest'; import { getBuildingColor, getCreatedAge, getModifiedAge } from './color'; import { createBuildingFader } from './fader'; @@ -46,7 +40,7 @@ export interface Buildings extends SceneComponent { tick(dt: number, ctx: FrameContext): void; /** Colour, assemble, swap in, relookup. Diffs against the prior cells to fire * the tweens; the boot rebuild snaps in rather than animating. */ - rebuild(layout: CityLayout, dateRanges: DateRanges, scannedAt?: string | null): Promise; + rebuild(layout: CityLayout, scannedAt?: string | null): Promise; /** Resolves when the meshes for the layout in effect exist. A later rebuild * supersedes rather than extends it: the caller re-asks on the next city. */ whenSettled(): Promise; @@ -67,14 +61,16 @@ export interface Buildings extends SceneComponent { * ad panels. The scrub pass decides the states; this owns the writes. */ applyScrub(states: ReadonlyMap): void; /** Install (or clear with null) the Timeline scrub controller, which drives - * scaleY + iFade per frame while TIMELINE_MODE is on. */ + * scaleY + iFade per frame while a scrub is on. */ setScrubController(controller: { update(): void } | null): void; /** Window-resize hook — forwards to the outline LineMaterial resolution. */ onResize(): void; } export function createBuildings(ctx: SceneContext): Buildings { - // Persistent outer group — added to the scene once by createCity. rebuild() + const material = ctx.buildingMaterial; + const timeline = ctx.timeline; + // Persistent outer group — added to the scene once by createCityScene. rebuild() // swaps the inner cell root in and out of this group. const group = new THREE.Group(); group.name = 'city-buildings'; @@ -117,12 +113,12 @@ export function createBuildings(ctx: SceneContext): Buildings { // Material theme. Reads each store's .value to subscribe to all of them; safe // at construction, since none of it is the picker, and it no-ops pre-rebuild. const stopMaterialEffect = effect(() => { - void BUILDINGS.value; - void SCENE.value; - void BLOOM.value; - void BUILDING_DIMENSIONS.value; - void RUINS.value; - refreshBuildingMaterial(); + void ctx.config.BUILDINGS.value; + void ctx.config.SCENE.value; + void ctx.config.BLOOM.value; + void ctx.config.BUILDING_DIMENSIONS.value; + void ctx.config.RUINS.value; + material.refresh(); _facadePanels?.refresh(); }); @@ -133,13 +129,13 @@ export function createBuildings(ctx: SceneContext): Buildings { let _rebuilding: Promise = Promise.resolve(); const stopRebuild = effect(() => { - const layout = ctx.cityState.layout.value; - const manifest = ctx.cityState.manifest.value; + const layout = ctx.build.layout.value; + const manifest = ctx.build.manifest.value; if (layout && manifest) untracked(() => { // Held, not just fired: nothing downstream can know the meshes exist // without it (see whenSettled). - _rebuilding = rebuild(layout, manifest.dateRanges, manifest.scanned_at); + _rebuilding = rebuild(layout, manifest.scanned_at); void _rebuilding; }); }); @@ -160,8 +156,11 @@ export function createBuildings(ctx: SceneContext): Buildings { getCells: () => _cells, getFacadePanels: () => _facadePanels, }, - cityState: ctx.cityState, + build: ctx.build, + material, + buildings: ctx.config.BUILDINGS, picker: ctx.picker!, + timeline, }); // Their overlays go straight on the scene: explicit renderOrders, so where // they sit in the graph doesn't decide draw order. @@ -170,6 +169,8 @@ export function createBuildings(ctx: SceneContext): Buildings { scene: ctx.scene, world: { getCells: () => _cells }, picker: ctx.picker!, + buildings: ctx.config.BUILDINGS, + rainbow: ctx.config.RAINBOW, }); _ghost = createGhostRenderer({ scene: ctx.scene, @@ -204,18 +205,19 @@ export function createBuildings(ctx: SceneContext): Buildings { // Enter/stay tween queue. No picker dep, so (unlike fader/outline/ghost) it is // created at construction, not armed. - const _tweens = createBuildingTweens({ getMeshForBuilding }); + const _tweens = createBuildingTweens({ getMeshForBuilding, buildings: ctx.config.BUILDINGS }); // Resolves meshes through the same accessors the tweens use, so a rebuild // swaps both onto the fresh cells at once. const applyScrub = createBuildingScrubApply({ + material, getBuildingIndex: () => _buildingIndex, getMeshForBuilding, getFacadePanels: () => _facadePanels, }); // Timeline scrub controller (installed by the timeline lifecycle). While - // TIMELINE_MODE, it drives scaleY + iFade per frame instead of the tweens. + // a scrub, it drives scaleY + iFade per frame instead of the tweens. let _scrubController: { update(): void } | null = null; // The prior meshes are disposed by now, but that frees GPU geometry only: the @@ -318,7 +320,7 @@ export function createBuildings(ctx: SceneContext): Buildings { function tick(_dt: number, frame: FrameContext): void { // First in the tick: outline and ghost read these matrices further down. In // Timeline the scrub controller owns them instead and this stays dormant. - if (TIMELINE_MODE.peek() && _scrubController) _scrubController.update(); + if (timeline?.mode.peek() && _scrubController) _scrubController.update(); else _tweens.update(0); _arm.arm(); _fader?.update(0); @@ -333,33 +335,33 @@ export function createBuildings(ctx: SceneContext): Buildings { _outline?.onResize(); } - async function rebuild( - layout: CityLayout, - dateRanges: DateRanges, - scannedAt?: string | null - ): Promise { + async function rebuild(layout: CityLayout, scannedAt?: string | null): Promise { const buildings = layout?.buildings ?? []; // Colour and weathering measure against the scan, not a live clock, so a // rebuild is deterministic and the goldens hold. const nowMs = parseDateMs(scannedAt ?? '') || Date.now(); // ---- Color the buildings. ---- + const facade = ctx.config.BUILDINGS.value; for (const b of buildings) { // Building.file is always a FileNode (directories become streets, // not buildings — see city/layout/layout.ts). b.color = getBuildingColor( b.file as unknown as Parameters[0], - nowMs + nowMs, + facade ); - // Independent of colour: creation age, so grime can mark an old file - // that was edited yesterday. + // The other clock: how long it has STOOD, so a building weathers even + // while it is being edited. b.createdAge = getCreatedAge( b.file as unknown as Parameters[0], - dateRanges + nowMs, + facade ); b.modifiedAge = getModifiedAge( b.file as unknown as Parameters[0], - nowMs + nowMs, + facade ); } @@ -386,7 +388,10 @@ export function createBuildings(ctx: SceneContext): Buildings { const cellOut = buildCellsFromLayout( bounds, buildings, - sourceOf(ctx.cityState.manifest.peek()) + sourceOf(ctx.build.manifest.peek()), + timeline, + material, + ctx.config ); // Grabbed before the swap reassigns them: the arrays stay readable through @@ -431,7 +436,7 @@ export function createBuildings(ctx: SceneContext): Buildings { _scrubController = null; if (!_firstBuildDone) { _firstBuildDone = true; - } else if (!TIMELINE_MODE.peek()) { + } else if (!timeline?.mode.peek()) { // Timeline mode packs the union once; the scrub controller owns the // matrix from here, so don't animate a per-commit diff against it. _tweens.onDiff(diff); diff --git a/app/src/city/scene/components/buildings/material.ts b/app/src/city/scene/components/buildings/material.ts new file mode 100644 index 000000000..5f12d7ac9 --- /dev/null +++ b/app/src/city/scene/components/buildings/material.ts @@ -0,0 +1,206 @@ +// city/components/buildings/material.ts — ONE city's building ShaderMaterial, +// its uniforms, and its icon atlas. Every cell of that city shares it, so a +// uniform written here updates the whole city at once and no other. + +import * as THREE from 'three'; +import type { CityConfig } from '@/city/session/config'; +import type { IconAtlas } from './atlas'; +import { setColorFromHex } from '@/city/scene/utils/color/setColorFromHex'; +import { writeSunDir } from '@/city/scene/utils/shaders/sunDir'; +import { + LIGHTING_SUN_AZIMUTH_DEG, + LIGHTING_SUN_ELEVATION_DEG, + LIGHTING_AMBIENT, + LIGHTING_SUN_CONTRAST, +} from '@/city/scene/constants/lighting'; + +// Shader-side facade keys ride uniforms; the JS-side ones bake into per-instance +// attributes, which is why changing them routes to a full rebuild. + +import buildingVertSrc from './building.vert.glsl?raw'; +import buildingFragSrc from './building.frag.glsl?raw'; + +/** One city's buildings, as the GPU sees them: the material every cell of it + * shares, and the icon atlas its roofs sample. */ +export class BuildingMaterial { + constructor(private readonly config: CityConfig) {} + + // One material for every cell of this city: applyManifest runs repeatedly, + // and a per-rebuild material would accumulate. + private material: THREE.ShaderMaterial | null = null; + + // Pushed in before the cell pass, so buildings have it as they're built. Null + // while it loads or if it failed: the shader paints the bare roof colour. + private atlas: IconAtlas | null = null; + + /** Mirrors the material's `transparent` so the setter can skip a no-op + * needsUpdate — the owners call it per sweep and per scrub frame. */ + private translucent = false; + + setIconAtlas(atlas: IconAtlas | null): void { + this.atlas = atlas; + if (this.material) { + this.material.uniforms.uIconAtlas.value = atlas ? atlas.texture : null; + this.material.uniforms.uIconSlotSize.value = atlas ? atlas.slotSize : 0; + } + } + + /** The icon atlas the cells resolve per-instance roof UVs from, or null when + * it hasn't built yet. Single source, shared with the uIconAtlas uniform. */ + getIconAtlas(): IconAtlas | null { + return this.atlas; + } + + /** Opaque buildings sort front-to-back, so early-z skips the ~620-line facade + * shader on hidden fragments; iFade.x is the only alpha, so a fade needs this. */ + setTranslucent(on: boolean): void { + if (on === this.translucent) return; + this.translucent = on; + if (this.material) { + this.material.transparent = on; + this.material.needsUpdate = true; + } + } + + // Grime is age-scaled: a [newest, oldest] range the shader lerps per-building + // by createdAge, into a Vector2 (both 0 when disabled → mix → 0). + private grimeIntensityVec(out: THREE.Vector2): THREE.Vector2 { + const f = this.config.BUILDINGS.value; + const [lo, hi] = f.GRIME_ENABLED ? f.GRIME_INTENSITY : [0, 0]; + return out.set(lo, hi); + } + + /** Shared by every cell's detail mesh: a material per cell meant a program + * compile per cell, which hung the tab on large repos. */ + get(): THREE.ShaderMaterial { + if (this.material) return this.material; + // Chunks are registered via THREE.ShaderChunk in registerShaderChunks.ts; + // Three.js's native preprocessor resolves #include at compile time. + const fragSrc = buildingFragSrc; + this.material = new THREE.ShaderMaterial({ + vertexShader: buildingVertSrc, + fragmentShader: fragSrc, + // Opaque unless something is mid-fade — see setBuildingsTranslucent. + transparent: this.translucent, + uniforms: { + // Hidden-tier wireframe thickness in screen-pixels. Updated by + // refreshBuildingMaterial() on Save. + uOutlineWidth: { value: this.config.BUILDINGS.value.OUTLINE_WIDTH }, + // Null until the atlas builds; the shader gates sampling on iIconUV.x. + uIconAtlas: { value: this.atlas ? this.atlas.texture : null }, + uIconSlotSize: { value: this.atlas ? this.atlas.slotSize : 0 }, + // Height-based haze, mixed into the post-tonemap sRGB framebuffer, so the + // hex bytes pass through unconverted. + uFogEnabled: { value: this.config.SCENE.value.FOG_ENABLED }, + uFogColor: { value: setColorFromHex(new THREE.Color(), this.config.SCENE.value.FOG_COLOR) }, + uFogIntensity: { value: this.config.SCENE.value.FOG_INTENSITY }, + // Raw fraction — the shader scales it by each building's own height. + uFogHeightFrac: { value: this.config.SCENE.value.FOG_HEIGHT_FRAC }, + // Extra emission on the freshest building's windows, over a baseline 1. + uWindowEmissionBoost: { value: this.config.BUILDINGS.value.WINDOW_EMISSION }, + // Age-driven decay uniforms (createdAge-gated, independent of + // modifiedAge). See BUILDINGS (aging) config. + uGrimeIntensity: { value: this.grimeIntensityVec(new THREE.Vector2()) }, + uGrimeCoverage: { value: new THREE.Vector2(...this.config.BUILDINGS.value.GRIME_COVERAGE) }, + // The placeholder is an overhead sun rather than a zero vector, which + // would shadow every face if _writeSunDir somehow didn't run. + uSunDirWorld: { value: new THREE.Vector3(0, 1, 0) }, + uAmbient: { value: LIGHTING_AMBIENT }, + uSunContrast: { value: LIGHTING_SUN_CONTRAST }, + // Seeded from the store so the first frame is already configured; only + // the shader-side keys, since the rest bake into attributes. + uSlabHeightFrac: { value: this.config.BUILDINGS.value.SLAB_HEIGHT_FRAC }, + uWindowWidthFrac: { value: this.config.BUILDINGS.value.WINDOW_WIDTH_FRAC }, + uWindowHeightFrac: { value: this.config.BUILDINGS.value.WINDOW_HEIGHT_FRAC }, + uWindowMarginFrac: { value: this.config.BUILDINGS.value.WINDOW_MARGIN_FRAC }, + uDoorHeightFrac: { value: this.config.BUILDINGS.value.DOOR_HEIGHT_FRAC }, + uRoofBorderFrac: { value: this.config.BUILDINGS.value.ROOF_BORDER_FRAC }, + // BUILDINGS store — HSL lightness deltas applied to slab and door + // via shadeColor/shadeAndShiftHue in the shader. + uSlabLightnessDelta: { value: this.config.BUILDINGS.value.SLAB_LIGHTNESS_DELTA }, + uDoorLightnessDelta: { value: this.config.BUILDINGS.value.DOOR_LIGHTNESS_DELTA }, + // Deleted-file cross (RUINS store) — consumed in sRGB like the roof it + // composites over, so the hex bytes pass through (see setColorFromHex). + uRuinXEnabled: { value: this.config.RUINS.value.X_ENABLED }, + uRuinXColor: { value: setColorFromHex(new THREE.Color(), this.config.RUINS.value.X_COLOR) }, + uRuinXWidth: { value: this.config.RUINS.value.X_WIDTH }, + // WINDOW_LIGHTING store — per-cell lit/unlit lightness deltas, gap + // thresholds, and the warm-amber tint for old/dim lit panes. + uWindowUnlitLightnessDelta: { value: this.config.BUILDINGS.value.UNLIT_LIGHTNESS_DELTA }, + uWindowGapBaseThreshold: { value: this.config.BUILDINGS.value.GAP_BASE_THRESHOLD }, + uWindowGapAgeBonus: { value: this.config.BUILDINGS.value.GAP_AGE_BONUS }, + // Consumed in sRGB, so the hex bytes pass through unconverted. + uDimGlowColor: { + value: setColorFromHex(new THREE.Color(), this.config.BUILDINGS.value.DIM_GLOW_COLOR), + }, + uLitFreshnessExponent: { value: this.config.BUILDINGS.value.LIT_FRESHNESS_EXPONENT }, + }, + }); + writeSunDir( + this.material.uniforms.uSunDirWorld.value as THREE.Vector3, + LIGHTING_SUN_AZIMUTH_DEG, + LIGHTING_SUN_ELEVATION_DEG + ); + return this.material; + } + + /** Re-apply the committed config to this city's uniforms, so a Save reaches + * all of it without rebuilding any of it. */ + refresh(): void { + if (!this.material) return; + const sceneCfg = this.config.SCENE.value; + const bloomCfg = this.config.BLOOM.value; + this.material.uniforms.uOutlineWidth.value = this.config.BUILDINGS.value.OUTLINE_WIDTH; + // Intensity is zeroed as well as the flag, so the mix is inert even where a + // driver takes the branch differently. + this.material.uniforms.uFogEnabled.value = sceneCfg.FOG_ENABLED; + setColorFromHex(this.material.uniforms.uFogColor.value as THREE.Color, sceneCfg.FOG_COLOR); + this.material.uniforms.uFogIntensity.value = sceneCfg.FOG_ENABLED ? sceneCfg.FOG_INTENSITY : 0; + this.material.uniforms.uFogHeightFrac.value = sceneCfg.FOG_HEIGHT_FRAC; + // Without bloom the windows stay LDR, with nothing for the pass to catch. + this.material.uniforms.uWindowEmissionBoost.value = bloomCfg.ENABLED + ? this.config.BUILDINGS.value.WINDOW_EMISSION + : 0; + // Scene directional lighting (fixed constants — re-seed idempotently). + writeSunDir( + this.material.uniforms.uSunDirWorld.value as THREE.Vector3, + LIGHTING_SUN_AZIMUTH_DEG, + LIGHTING_SUN_ELEVATION_DEG + ); + this.material.uniforms.uAmbient.value = LIGHTING_AMBIENT; + this.material.uniforms.uSunContrast.value = LIGHTING_SUN_CONTRAST; + // Shader-side keys only: the rest bake into attributes, so the whole store + // routes through a rebuild and these come along with it. + const facade = this.config.BUILDINGS.value; + // Age weathering (grime) — [newest, oldest] ranges the shader lerps + // per-building by createdAge. + this.grimeIntensityVec(this.material.uniforms.uGrimeIntensity.value as THREE.Vector2); + (this.material.uniforms.uGrimeCoverage.value as THREE.Vector2).set(...facade.GRIME_COVERAGE); + this.material.uniforms.uSlabHeightFrac.value = facade.SLAB_HEIGHT_FRAC; + this.material.uniforms.uWindowWidthFrac.value = facade.WINDOW_WIDTH_FRAC; + this.material.uniforms.uWindowHeightFrac.value = facade.WINDOW_HEIGHT_FRAC; + this.material.uniforms.uWindowMarginFrac.value = facade.WINDOW_MARGIN_FRAC; + this.material.uniforms.uDoorHeightFrac.value = facade.DOOR_HEIGHT_FRAC; + this.material.uniforms.uRoofBorderFrac.value = facade.ROOF_BORDER_FRAC; + // BUILDINGS store — pure uniform refresh, no rebuild required. + const facadeDetail = this.config.BUILDINGS.value; + this.material.uniforms.uSlabLightnessDelta.value = facadeDetail.SLAB_LIGHTNESS_DELTA; + this.material.uniforms.uDoorLightnessDelta.value = facadeDetail.DOOR_LIGHTNESS_DELTA; + const ruins = this.config.RUINS.value; + this.material.uniforms.uRuinXEnabled.value = ruins.X_ENABLED; + setColorFromHex(this.material.uniforms.uRuinXColor.value as THREE.Color, ruins.X_COLOR); + this.material.uniforms.uRuinXWidth.value = ruins.X_WIDTH; + // WINDOW_LIGHTING store — pure uniform refresh into the pre-allocated + // THREE.Color uniform values. + const windowLighting = this.config.BUILDINGS.value; + this.material.uniforms.uWindowUnlitLightnessDelta.value = windowLighting.UNLIT_LIGHTNESS_DELTA; + this.material.uniforms.uWindowGapBaseThreshold.value = windowLighting.GAP_BASE_THRESHOLD; + this.material.uniforms.uWindowGapAgeBonus.value = windowLighting.GAP_AGE_BONUS; + // DIM_GLOW_COLOR is consumed in sRGB space — see the uniform's initializer. + setColorFromHex( + this.material.uniforms.uDimGlowColor.value as THREE.Color, + windowLighting.DIM_GLOW_COLOR + ); + this.material.uniforms.uLitFreshnessExponent.value = windowLighting.LIT_FRESHNESS_EXPONENT; + } +} diff --git a/app/src/city/components/buildings/outline.ts b/app/src/city/scene/components/buildings/outline.ts similarity index 89% rename from app/src/city/components/buildings/outline.ts rename to app/src/city/scene/components/buildings/outline.ts index 7485a17bc..4257aedc9 100644 --- a/app/src/city/components/buildings/outline.ts +++ b/app/src/city/scene/components/buildings/outline.ts @@ -6,17 +6,19 @@ import * as THREE from 'three'; import { effect } from '@preact/signals'; import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; -import { SafeLineSegmentsGeometry } from '@/city/utils/safeLineSegmentsGeometry'; +import { SafeLineSegmentsGeometry } from '@/city/scene/utils/safeLineSegmentsGeometry'; -import { BUILDINGS } from '@/state/settings/fields/buildings'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; -import { rainbowRgbAt } from '@/city/utils/rainbowChase'; -import { FLOATS_PER_SEGMENT } from '@/city/utils/bufferLayout'; -import { createSafeLineMaterial } from '@/city/utils/safeLineMaterial'; +import type { BuildingsConfig } from '@/city/session/settings/buildings'; +import type { RainbowConfig } from '@/city/session/settings/effects'; +import type { ReadonlySignal } from '@preact/signals'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; +import { rainbowRgbAt } from '@/city/scene/utils/rainbowChase'; +import { FLOATS_PER_SEGMENT } from '@/city/scene/constants/bufferLayout'; +import { createSafeLineMaterial } from '@/city/scene/utils/safeLineMaterial'; import { NodeKind } from '@/types'; import type { CellTile } from './cellTile'; -import type { createPicker } from '@/city/interaction/picker'; -import type { FileTarget } from '@/types'; +import type { createPicker } from '@/city/scene/interaction/picker'; +import type { FileTarget } from '@/city/scene/types/picker'; // Narrow world surface the outline renderer needs (cell lookup only). Supplied // by the buildings component (cells are component-local). @@ -43,13 +45,19 @@ export function createOutlineRenderer({ scene, world: _world, picker, + buildings, + rainbow, }: { canvas: HTMLCanvasElement; scene: THREE.Scene; world: OutlineWorld; picker: ReturnType; + /** This city's outline settings: width, colour and opacity. */ + buildings: ReadonlySignal; + /** Its rainbow chase, which the selected outline runs. */ + rainbow: ReadonlySignal; }) { - const _bo = BUILDINGS.value; + const _bo = buildings.value; // ── Hover outline (single shared mesh, retransformed per frame) ───── const _unitEdgesGeo = new SafeLineSegmentsGeometry(); @@ -146,11 +154,11 @@ export function createOutlineRenderer({ ): void { const k = segIdx * FLOATS_PER_SEGMENT; // Start RGB — consume the scratch tuple before the next call overwrites it. - const [r0, g0, b0] = rainbowRgbAt(timeMs, fracStart); + const [r0, g0, b0] = rainbowRgbAt(timeMs, fracStart, rainbow.value); _selectedColors[k] = r0; _selectedColors[k + 1] = g0; _selectedColors[k + 2] = b0; - const [r1, g1, b1] = rainbowRgbAt(timeMs, fracEnd); + const [r1, g1, b1] = rainbowRgbAt(timeMs, fracEnd, rainbow.value); _selectedColors[k + 3] = r1; _selectedColors[k + 4] = g1; _selectedColors[k + 5] = b1; @@ -216,7 +224,7 @@ export function createOutlineRenderer({ // BUILDINGS Save → push color/width/opacity into the two materials; the // construction-time first fire reproduces the seeded values (no-op). const _stopMaterials = effect(() => { - const outline = BUILDINGS.value; + const outline = buildings.value; hoverLineMat.color.set(outline.OUTLINE_HOVER_COLOR); hoverLineMat.linewidth = outline.OUTLINE_WIDTH; hoverLineMat.opacity = outline.OUTLINE_HOVER_OPACITY; diff --git a/app/src/city/components/buildings/scrubApply.ts b/app/src/city/scene/components/buildings/scrubApply.ts similarity index 94% rename from app/src/city/components/buildings/scrubApply.ts rename to app/src/city/scene/components/buildings/scrubApply.ts index a3b6d1f61..bdfe70415 100644 --- a/app/src/city/components/buildings/scrubApply.ts +++ b/app/src/city/scene/components/buildings/scrubApply.ts @@ -3,16 +3,18 @@ import * as THREE from 'three'; -import type { Building } from '@/types'; +import type { Building } from '@/city/scene/types'; import type { InstancedFacadePanels } from './facadePanels'; import type { BuildingIndex } from './buildingIndex'; import { BuildingLane, type BuildingScrubState } from './scrubState'; -import { setBuildingsTranslucent } from './material'; +import type { BuildingMaterial } from './material'; export interface BuildingScrubApplyCtx { getBuildingIndex(): BuildingIndex | null; getMeshForBuilding(b: Building): { mesh: THREE.InstancedMesh; slot: number } | null; getFacadePanels(): InstancedFacadePanels | null; + /** This city's material: a fade is what makes its buildings translucent. */ + material: BuildingMaterial; } function attr(mesh: THREE.InstancedMesh, name: string): THREE.BufferAttribute | undefined { @@ -118,7 +120,7 @@ export function createBuildingScrubApply(ctx: BuildingScrubApplyCtx) { } for (const a of _attrs) a.needsUpdate = true; - setBuildingsTranslucent(_anyTranslucent); + ctx.material.setTranslucent(_anyTranslucent); // 0, not null: Live's fader reads null as leave-untouched, which would // strand an undriven panel at its shown default. diff --git a/app/src/city/components/buildings/scrubState.ts b/app/src/city/scene/components/buildings/scrubState.ts similarity index 86% rename from app/src/city/components/buildings/scrubState.ts rename to app/src/city/scene/components/buildings/scrubState.ts index 94509a7e3..d233e0215 100644 --- a/app/src/city/components/buildings/scrubState.ts +++ b/app/src/city/scene/components/buildings/scrubState.ts @@ -1,20 +1,21 @@ // How one building renders at a scrub position. No THREE and no meshes, so the // decision table is exercisable without a scene; scrubApply.ts does the writes. -import { getBuildingDimensions } from '@/city/layout/dimensions'; -import type { Building, FileNode } from '@/types'; -import { FadeDetail } from '@/types'; +import { getBuildingDimensions } from '@/city/scene/layout/dimensions'; +import type { FileNode } from '@/types'; +import type { Building } from '@/city/scene/types'; +import { FadeDetail } from '@/city/scene/types'; import { isDataBuilding, isEmptyFile, isUnmeasuredFile } from '@/utils/fileKind'; -import type { PathTimeline } from '@/city/timeline/replay'; +import type { PathTimeline } from '@/city/scene/scrub/replay'; import { PathState, lastModifiedIndexAt, linesAt, presenceAt, ruinStateAt, -} from '@/city/timeline/replay'; -import type { ColorTriple, ScrubFrame } from '@/city/timeline/scrubFrame'; -import { recencyT } from '@/city/utils/recency'; +} from '@/city/scene/scrub/replay'; +import type { ColorTriple, ScrubFrame } from '@/city/scene/scrub/scrubFrame'; +import { recencyT } from '@/city/scene/utils/recency'; import { BuildingKind } from './buildingKind'; import { getBuildingColorForRecency } from './color'; import { tierFor } from './fadeTiers'; @@ -134,11 +135,11 @@ export function resolveBuildingScrubState( ? f.ruinBuildingOpacity : 0; - const createdMs = createdMsFor(input, commitMs); - out.createdAge = - present && f.createdSpread > 0 - ? 1 - Math.max(0, Math.min(1, (createdMs - f.minCreated) / f.createdSpread)) - : 0; + // The standing clock, against the scrubbed moment: a building goes on + // weathering as you scrub forward, however long ago it was last edited. + out.createdAge = present + ? 1 - recencyT(createdMsFor(input, commitMs), f.nowMs, f.fadeCfg.CREATED_HALF_LIFE_DAYS) + : 0; // The union node's size is max-over-history, so only the replay knows what // this file measured HERE. Presence gates height, not lines: media is 0. @@ -146,7 +147,7 @@ export function resolveBuildingScrubState( out.kind = kindFor(b.file, scrubFile, lane); if (present) { - const dims = getBuildingDimensions(scrubFile, f.lineStats, f.byteStats); + const dims = getBuildingDimensions(scrubFile, f.buildingDims, f.lineStats, f.byteStats); out.height = dims.h; out.floors = dims.floors; } else { @@ -166,10 +167,10 @@ export function resolveBuildingScrubState( const recency = recencyT( modifiedMsAt(input, f.pos, commitMs), f.nowMs, - f.fadeCfg.HALF_LIFE_DAYS + f.fadeCfg.MODIFIED_HALF_LIFE_DAYS ); out.modifiedAge = 1 - recency; - out.colorBase = getBuildingColorForRecency(b.file, recency); + out.colorBase = getBuildingColorForRecency(b.file, recency, f.fadeCfg); out.colorToward = null; out.colorMix = 0; } else { @@ -179,7 +180,7 @@ export function resolveBuildingScrubState( out.modifiedAge = 0; if (lane === BuildingLane.Ruin) { // A ruin keeps a muted memory of the file's own hue. - out.colorBase = getBuildingColorForRecency(b.file, RUIN_BASE_RECENCY); + out.colorBase = getBuildingColorForRecency(b.file, RUIN_BASE_RECENCY, f.fadeCfg); out.colorToward = RUIN_GRAY; out.colorMix = f.ruinGrayMix; } else { diff --git a/app/src/city/components/buildings/seed.ts b/app/src/city/scene/components/buildings/seed.ts similarity index 100% rename from app/src/city/components/buildings/seed.ts rename to app/src/city/scene/components/buildings/seed.ts diff --git a/app/src/city/components/buildings/spatialGrid.ts b/app/src/city/scene/components/buildings/spatialGrid.ts similarity index 100% rename from app/src/city/components/buildings/spatialGrid.ts rename to app/src/city/scene/components/buildings/spatialGrid.ts diff --git a/app/src/city/components/buildings/tween.ts b/app/src/city/scene/components/buildings/tween.ts similarity index 96% rename from app/src/city/components/buildings/tween.ts rename to app/src/city/scene/components/buildings/tween.ts index b891e81a2..0d5b6c831 100644 --- a/app/src/city/components/buildings/tween.ts +++ b/app/src/city/scene/components/buildings/tween.ts @@ -4,13 +4,16 @@ // else, and the fader writes iFade, so the two can't conflict by construction. import * as THREE from 'three'; -import { BUILDINGS } from '@/state/settings/fields/buildings'; -import type { Building, EnteringBuilding, StayingBuilding } from '@/types'; +import type { BuildingsConfig } from '@/city/session/settings/buildings'; +import type { ReadonlySignal } from '@preact/signals'; +import type { Building, EnteringBuilding, StayingBuilding } from '@/city/scene/types'; /** Narrow resolver surface the tween queue needs from the buildings * component (re-resolved per frame so tweens survive rebuilds). */ export interface TweenDeps { getMeshForBuilding(b: Building): { mesh: THREE.InstancedMesh; slot: number } | null; + /** This city's transition duration. */ + buildings: ReadonlySignal; } interface Tween { @@ -85,7 +88,7 @@ export function createBuildingTweens(deps: TweenDeps) { }): void { // Once per diff, so a burst shares one duration and the next picks up a // Settings change. - const transitionMs = BUILDINGS.value.BUILDING_TRANSITION_MS; + const transitionMs = deps.buildings.value.BUILDING_TRANSITION_MS; // Entering: grow in from near-zero scale. Y position starts at ~0 // and rises to the final center (h/2) so the base stays grounded. for (const e of diff.entering.buildings) { diff --git a/app/src/city/components/fireflies/authorColor.ts b/app/src/city/scene/components/fireflies/authorColor.ts similarity index 50% rename from app/src/city/components/fireflies/authorColor.ts rename to app/src/city/scene/components/fireflies/authorColor.ts index eb3bacfa4..2be0a4ff6 100644 --- a/app/src/city/components/fireflies/authorColor.ts +++ b/app/src/city/scene/components/fireflies/authorColor.ts @@ -2,34 +2,28 @@ // lightness/chroma so every author reads against the dark sky. Shared by the // commit pane's dot and the fireflies renderer's orbs. -import { oklchToLinearRgb, linearRgbToHex } from '@/city/utils/color/colors'; +import { oklchToLinearRgb, linearRgbToHex } from '@/city/scene/utils/color/colors'; // Base palette used for firefly orbs — saturated, mid-lightness. const ORB_L = 0.78; const ORB_C = 0.18; -// Brighter variant used for orbit-ring hover highlights — same hue + -// chroma as the orb (so the author's identity stays recognizable), only -// the lightness lifts. Going too pastel (lower chroma, much higher L) -// washes the ring toward white and loses the hue, which is exactly -// the wrong signal for "this is author X's orbit". +// The hover ring: same hue and chroma, only the lightness lifts. Going pastel +// washes it toward white and loses the hue, which is the author's identity. const LIGHT_L = 0.84; const LIGHT_C = 0.18; export interface AuthorColor { hex: string; // "#rrggbb" hue: number; // degrees [0, 360) - // readonly: the object is memoized + aliased into every orb of this author; - // mutating it would corrupt all of them. + // readonly: memoized and aliased into every orb of this author. rgb: readonly [number, number, number]; // linear-light, 0..1 each } -// Memoized by hue+lightness+chroma: a repo has far fewer distinct authors than -// commits/orbs, so this collapses ~one OKLCH conversion per orb to one per -// author. The returned object is shared — callers must treat it as read-only. +// Memoized by hue+lightness+chroma: far fewer authors than orbs, so this is one +// OKLCH conversion per author rather than per orb. The value is shared. const _colorCache = new Map(); -// Bound the memo so a long session across many repos can't grow it without -// limit (comfortably fits one huge repo's authors × 2 lightness variants). -// Clearing is safe — values are pure functions of the key. +// Bounded so a long session across many repos cannot grow it without limit. +// Clearing is safe: every value is a pure function of its key. const _COLOR_CACHE_MAX = 1 << 16; function authorColorAt(hue: number, l: number, c: number): AuthorColor { @@ -47,9 +41,8 @@ export function colorForAuthor(hue: number): AuthorColor { return authorColorAt(hue, ORB_L, ORB_C); } -/** Pastel variant of `colorForAuthor` — same hue, higher lightness, lower - * chroma. Used for the per-author orbit-ring hover highlight so each - * hovered orb's ring picks up its author's identity in a softer tone. */ +/** Pastel `colorForAuthor`: the hovered orbit ring, so it still reads as that + * author's, in a softer tone. */ export function lightColorForAuthor(hue: number): AuthorColor { return authorColorAt(hue, LIGHT_L, LIGHT_C); } diff --git a/app/src/city/components/fireflies/fireflies.frag.glsl b/app/src/city/scene/components/fireflies/fireflies.frag.glsl similarity index 100% rename from app/src/city/components/fireflies/fireflies.frag.glsl rename to app/src/city/scene/components/fireflies/fireflies.frag.glsl diff --git a/app/src/city/components/fireflies/fireflies.ts b/app/src/city/scene/components/fireflies/fireflies.ts similarity index 78% rename from app/src/city/components/fireflies/fireflies.ts rename to app/src/city/scene/components/fireflies/fireflies.ts index ed4050c93..35f565a6f 100644 --- a/app/src/city/components/fireflies/fireflies.ts +++ b/app/src/city/scene/components/fireflies/fireflies.ts @@ -7,9 +7,12 @@ import { placeFireflies, type FireflyPlacement } from './firefliesPlacement'; import { createFireflyRenderer, type FireflyRenderer } from './firefliesRenderer'; import { createOrbitRings } from './orbitRings'; import { createFirefliesScrub } from './firefliesScrub'; -import type { TreePlacement } from '@/city/components/trees/treePlacement'; +import type { TreePlacement } from '@/city/scene/components/trees/treePlacement'; import type { CommitEntry, RepoStats } from '@/types'; -import { FIREFLIES } from '@/state/settings/fields/fireflies'; +import type { ReadonlySignal } from '@preact/signals'; +import type { FirefliesConfig } from '@/city/session/settings/fireflies'; +import type { TreesConfig } from '@/city/session/settings/trees'; +import type { RainbowConfig } from '@/city/session/settings/effects'; /** createFireflyAssembly's handle: the renderer plus sha-based hover/select * so callers never manage the sha→index map. */ @@ -35,7 +38,12 @@ export function createFireflyAssembly( placements: TreePlacement[], commits: CommitEntry[] | null, stats: RepoStats | null | undefined, - scannedAt?: string | null, + scannedAt: string | null | undefined, + /** This city's orb and tree settings: an orb hangs on a canopy, so it is + * sized against the same tree config the forest grew from. */ + fireflies: ReadonlySignal, + trees: ReadonlySignal, + rainbow: ReadonlySignal, /** Drawing-buffer canvas — the orbs' point sizes scale to its device-pixel * height. Optional for tests. */ canvas?: HTMLCanvasElement @@ -45,8 +53,8 @@ export function createFireflyAssembly( // Master config gate. When disabled, return an empty parent group so the // caller's group is still safe to add/dispose. - if (!FIREFLIES.value.ENABLED) { - const stub = createFireflyRenderer([]); + if (!fireflies.value.ENABLED) { + const stub = createFireflyRenderer([], fireflies); return { group: parent, setTime: stub.setTime.bind(stub), @@ -59,10 +67,17 @@ export function createFireflyAssembly( dispose: stub.dispose.bind(stub), }; } - const orbs: FireflyPlacement[] = placeFireflies(placements, commits ?? [], stats, scannedAt); - const rings = createOrbitRings(orbs); - const renderer = createFireflyRenderer(orbs, canvas); - const scrub = createFirefliesScrub(orbs, commits, stats, scannedAt); + const orbs: FireflyPlacement[] = placeFireflies( + placements, + commits ?? [], + stats, + scannedAt, + fireflies.value, + trees.value + ); + const rings = createOrbitRings(orbs, fireflies, rainbow); + const renderer = createFireflyRenderer(orbs, fireflies, canvas); + const scrub = createFirefliesScrub(orbs, commits, stats, fireflies, trees); // Both arrive per frame, from the same controller, and both feed one resize. let _scrubCommit: number | null = null; let _scrubNow: number | null = null; diff --git a/app/src/city/components/fireflies/fireflies.vert.glsl b/app/src/city/scene/components/fireflies/fireflies.vert.glsl similarity index 100% rename from app/src/city/components/fireflies/fireflies.vert.glsl rename to app/src/city/scene/components/fireflies/fireflies.vert.glsl diff --git a/app/src/city/components/fireflies/firefliesPlacement.ts b/app/src/city/scene/components/fireflies/firefliesPlacement.ts similarity index 90% rename from app/src/city/components/fireflies/firefliesPlacement.ts rename to app/src/city/scene/components/fireflies/firefliesPlacement.ts index 0118099e9..d9e7b2ab7 100644 --- a/app/src/city/components/fireflies/firefliesPlacement.ts +++ b/app/src/city/scene/components/fireflies/firefliesPlacement.ts @@ -4,15 +4,15 @@ // treeEncoding, the same functions the renderer builds the tree from. import type { CommitEntry, RepoStats } from '@/types'; -import type { TreePlacement } from '@/city/components/trees/treePlacement'; -import { TREES } from '@/state/settings/fields/trees'; -import { FIREFLIES } from '@/state/settings/fields/fireflies'; +import type { TreePlacement } from '@/city/scene/components/trees/treePlacement'; +import type { TreesConfig } from '@/city/session/settings/trees'; +import type { FirefliesConfig } from '@/city/session/settings/fireflies'; import { - computeAgeRange, + ageMoment, computeSizeRange, treeHeight, treeRadius, -} from '@/city/components/trees/treeEncoding'; +} from '@/city/scene/components/trees/treeEncoding'; import { colorForAuthor, lightColorForAuthor } from './authorColor'; export interface FireflyPlacement { @@ -88,12 +88,12 @@ export function placeFireflies( placements: TreePlacement[], commits: CommitEntry[] | null, stats: RepoStats | null | undefined, - scannedAt?: string | null + scannedAt: string | null | undefined, + fireflyConfig: FirefliesConfig, + treesConfig: TreesConfig ): FireflyPlacement[] { if (!commits || commits.length === 0) return []; - const fireflyConfig = FIREFLIES.value; - // Backend-precomputed, crediting each distinct author of a commit once. The // list is sorted by count, so [0] is the busiest. const authors = stats?.authors ?? []; @@ -105,11 +105,11 @@ export function placeFireflies( // dot can't drift apart. const hueByAuthor = new Map(authors.map((a) => [a.name, a.hue])); - const cfg = TREES.value; + const cfg = treesConfig; // Shared with the tree renderer, scan date included, or the orbs sit at a // height the trees they belong to never reach. - const ageRange = computeAgeRange(stats, scannedAt); + const scanDay = ageMoment(scannedAt); const sizeRange = computeSizeRange(stats); const out: FireflyPlacement[] = []; @@ -120,8 +120,8 @@ export function placeFireflies( // Canopy radius/height come from treeEncoding — the same source the // tree renderer uses — so orbs stay pinned to their trees. - const canopyRadius = treeRadius(commit, ageRange, sizeRange, cfg); - const height = treeHeight(commit, ageRange, cfg); + const canopyRadius = treeRadius(commit, scanDay, sizeRange, cfg); + const height = treeHeight(commit, scanDay, cfg); for (const author of commit.authors ?? []) { // Orbs are trees times authors, so a capped forest can still balloon past diff --git a/app/src/city/components/fireflies/firefliesRenderer.ts b/app/src/city/scene/components/fireflies/firefliesRenderer.ts similarity index 94% rename from app/src/city/components/fireflies/firefliesRenderer.ts rename to app/src/city/scene/components/fireflies/firefliesRenderer.ts index a83223b07..eba9ed2f1 100644 --- a/app/src/city/components/fireflies/firefliesRenderer.ts +++ b/app/src/city/scene/components/fireflies/firefliesRenderer.ts @@ -4,9 +4,10 @@ // driver (Samsung Xclipse) under both browsers' GL stacks; ~50 bytes/orb. import * as THREE from 'three'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; -import { VEC3_COMPONENTS } from '@/city/utils/bufferLayout'; -import { FIREFLIES } from '@/state/settings/fields/fireflies'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; +import { VEC3_COMPONENTS } from '@/city/scene/constants/bufferLayout'; +import type { ReadonlySignal } from '@preact/signals'; +import type { FirefliesConfig } from '@/city/session/settings/fireflies'; import type { FireflyPlacement } from './firefliesPlacement'; import vertexShader from './fireflies.vert.glsl?raw'; import fragmentShader from './fireflies.frag.glsl?raw'; @@ -30,6 +31,7 @@ export const FIREFLY_ORBS_MESH = 'fireflies-orbs'; export function createFireflyRenderer( orbs: FireflyPlacement[], + fireflies: ReadonlySignal, canvas?: HTMLCanvasElement ): FireflyRenderer { const group = new THREE.Group(); @@ -48,7 +50,7 @@ export function createFireflyRenderer( }; } - const cfg = FIREFLIES.value; + const cfg = fireflies.value; const uTime = { value: 0 }; const uBobAmp = { value: cfg.BOB_AMPLITUDE }; @@ -180,7 +182,7 @@ export function createFireflyRenderer( inflateBoundingSphere(uBobAmp.value); }, refresh() { - const next = FIREFLIES.value; + const next = fireflies.value; uBobAmp.value = next.BOB_AMPLITUDE; uBobSpeed.value = next.BOB_SPEED; uPulseAmp.value = next.PULSE_AMPLITUDE; diff --git a/app/src/city/components/fireflies/firefliesScrub.ts b/app/src/city/scene/components/fireflies/firefliesScrub.ts similarity index 89% rename from app/src/city/components/fireflies/firefliesScrub.ts rename to app/src/city/scene/components/fireflies/firefliesScrub.ts index a58bb90ec..689cb0c73 100644 --- a/app/src/city/components/fireflies/firefliesScrub.ts +++ b/app/src/city/scene/components/fireflies/firefliesScrub.ts @@ -4,15 +4,15 @@ // rings read the same numbers when one is built. import type { CommitEntry, RepoStats } from '@/types'; -import { TREES } from '@/state/settings/fields/trees'; -import { FIREFLIES } from '@/state/settings/fields/fireflies'; +import type { ReadonlySignal } from '@preact/signals'; +import type { TreesConfig } from '@/city/session/settings/trees'; +import type { FirefliesConfig } from '@/city/session/settings/fireflies'; import { - computeAgeRange, computeSizeRange, treeHeight, treeRadius, - type AgeRange, -} from '@/city/components/trees/treeEncoding'; + type AgeMoment, +} from '@/city/scene/components/trees/treeEncoding'; import { epochDayAt } from '@/utils/dates'; import { scaleForCommits, type FireflyPlacement } from './firefliesPlacement'; @@ -26,13 +26,13 @@ export function createFirefliesScrub( orbs: FireflyPlacement[], commits: CommitEntry[] | null, stats: RepoStats | null | undefined, - scannedAt?: string | null + fireflies: ReadonlySignal, + trees: ReadonlySignal ): FirefliesScrub { const history = commits ?? []; // The busiest author's all-time total, the fixed maximum sizes are read // against, so an orb grows through the scrub instead of holding its rank. const maxCommits = stats?.authors?.[0]?.commits ?? 0; - const ageRange = computeAgeRange(stats, scannedAt); const sizeRange = computeSizeRange(stats); // The sizes the placements were built with, to restore on the way back to // Live without recomputing them and drifting by a rounding step. @@ -79,7 +79,7 @@ export function createFirefliesScrub( _appliedDay = day; countTo(Math.min(maxCommitIndex, history.length - 1)); - const cfgFireflies = FIREFLIES.value; + const cfgFireflies = fireflies.value; const scales = new Map(); for (const [author, n] of counts) { scales.set(author, scaleForCommits(n, maxCommits, cfgFireflies)); @@ -90,8 +90,8 @@ export function createFirefliesScrub( // Tree sizes at this date, from the same formulas the forest grows by, so // an orb keeps its place just outside the canopy. - const cfg = TREES.value; - const scrubbed: AgeRange = { ...ageRange, scanned: day }; + const cfg = trees.value; + const scrubbed: AgeMoment = day; const heightAt = new Map(); const radiusAt = new Map(); diff --git a/app/src/city/components/fireflies/index.ts b/app/src/city/scene/components/fireflies/index.ts similarity index 87% rename from app/src/city/components/fireflies/index.ts rename to app/src/city/scene/components/fireflies/index.ts index 4543ea7fc..9e3777996 100644 --- a/app/src/city/components/fireflies/index.ts +++ b/app/src/city/scene/components/fireflies/index.ts @@ -6,10 +6,9 @@ import * as THREE from 'three'; import { effect, untracked } from '@preact/signals'; -import { FIREFLIES } from '@/state/settings/fields/fireflies'; import { NodeKind } from '@/types'; import type { CommitEntry, RepoStats } from '@/types'; -import type { TreePlacement } from '@/city/components/trees/treePlacement'; +import type { TreePlacement } from '@/city/scene/components/trees/treePlacement'; import type { FrameContext, SceneComponent, SceneContext } from '../../types'; import { armOnFirstTick } from '../../utils/armOnFirstTick'; @@ -42,12 +41,12 @@ export interface FirefliesComponent extends SceneComponent { } export function createFireflies(ctx: SceneContext): FirefliesComponent { - // Persistent outer group — added to the scene once by createCity. rebuild() + // Persistent outer group — added to the scene once by createCityScene. rebuild() // swaps the inner assembly's group in and out of this group. const group = new THREE.Group(); group.name = 'city-fireflies'; - const { cityState } = ctx; + const { build } = ctx; let _inner: Fireflies | null = null; @@ -68,24 +67,33 @@ export function createFireflies(ctx: SceneContext): FirefliesComponent { clear(); // Deliberately does NOT push current hover/selection into the fresh // renderer — see the arming block's no-push rule below. - _inner = createFireflyAssembly(placements, commits, stats, scannedAt, ctx.canvas); + _inner = createFireflyAssembly( + placements, + commits, + stats, + scannedAt, + ctx.config.FIREFLIES, + ctx.config.TREES, + ctx.config.RAINBOW, + ctx.canvas + ); group.add(_inner.group); } - // FIREFLIES Save → refresh uniforms in place; structural keys remain - // Rebuild-routed. Safe at construction (reads only FIREFLIES signals). + // An orb Save refreshes the uniforms in place; structural keys are + // Rebuild-routed. Safe at construction: it reads only this city's config. const stopTheme = effect(() => { - void FIREFLIES.value; + void ctx.config.FIREFLIES.value; _inner?.refresh(); }); // Rebuild off treePlacements (lockstep with trees). untracked() stops a // FIREFLIES subscription reallocating orbs on every slider drag. const stopPlacements = effect(() => { - const placements = cityState.treePlacements.value; + const placements = build.treePlacements.value; if (placements) untracked(() => { - const manifest = cityState.manifest.peek(); + const manifest = build.manifest.peek(); rebuild(placements, manifest?.commits ?? null, manifest?.stats, manifest?.scanned_at); }); else clear(); diff --git a/app/src/city/components/fireflies/orbitRings.ts b/app/src/city/scene/components/fireflies/orbitRings.ts similarity index 91% rename from app/src/city/components/fireflies/orbitRings.ts rename to app/src/city/scene/components/fireflies/orbitRings.ts index e88c5b80c..f3ede3efe 100644 --- a/app/src/city/components/fireflies/orbitRings.ts +++ b/app/src/city/scene/components/fireflies/orbitRings.ts @@ -3,8 +3,10 @@ // three-co-author commit shows three orbits. Hover tints each ring with its // author's pastel; selected shares one vertexColors material for the chase. import * as THREE from 'three'; -import { FIREFLIES } from '@/state/settings/fields/fireflies'; -import { rainbowRgbAt } from '@/city/utils/rainbowChase'; +import type { ReadonlySignal } from '@preact/signals'; +import type { FirefliesConfig } from '@/city/session/settings/fireflies'; +import type { RainbowConfig } from '@/city/session/settings/effects'; +import { rainbowRgbAt } from '@/city/scene/utils/rainbowChase'; import type { FireflyPlacement } from './firefliesPlacement'; const TUBULAR_SEGMENTS = 96; // segments around the loop @@ -91,14 +93,14 @@ function ensureColorBuffer(geom: THREE.BufferGeometry): Float32Array { /** TubeGeometry is indexed `j * radialSlices + i`. Every vertex on a tubular * slice takes the same hue, so the chase rotates smoothly. */ -function writeRainbowToTube(mesh: THREE.Mesh, timeMs: number): void { +function writeRainbowToTube(mesh: THREE.Mesh, timeMs: number, rainbow: RainbowConfig): void { const geom = mesh.geometry as THREE.BufferGeometry; const buf = ensureColorBuffer(geom); const tubularSlices = TUBULAR_SEGMENTS + 1; const radialSlices = RADIAL_SEGMENTS + 1; for (let j = 0; j < tubularSlices; j++) { // One hue per tubular slice; all radial verts on the slice share it. - const [r, g, b] = rainbowRgbAt(timeMs, j / TUBULAR_SEGMENTS); + const [r, g, b] = rainbowRgbAt(timeMs, j / TUBULAR_SEGMENTS, rainbow); for (let i = 0; i < radialSlices; i++) { const vertexIdx = j * radialSlices + i; const k = vertexIdx * 3; @@ -115,11 +117,15 @@ function writeRainbowToTube(mesh: THREE.Mesh, timeMs: number): void { * graph without repeating the literal. */ export const ORBIT_RINGS_GROUP = 'firefly-orbit-rings'; -export function createOrbitRings(orbs: FireflyPlacement[]): OrbitRings { +export function createOrbitRings( + orbs: FireflyPlacement[], + fireflies: ReadonlySignal, + rainbow: ReadonlySignal +): OrbitRings { const group = new THREE.Group(); group.name = ORBIT_RINGS_GROUP; - const cfg = FIREFLIES.value; + const cfg = fireflies.value; if (!cfg.ORBIT_RING_ENABLED || orbs.length === 0) { return { @@ -200,7 +206,7 @@ export function createOrbitRings(orbs: FireflyPlacement[]): OrbitRings { } function buildHoverMeshes(slotOrbs: FireflyPlacement[]): void { - const thickness = FIREFLIES.value.ORBIT_RING_THICKNESS; + const thickness = fireflies.value.ORBIT_RING_THICKNESS; for (const orb of slotOrbs) { const geom = buildTubeGeometry(orb, thickness); const mat = makeHoverMaterial(orb.lightRgb); @@ -212,7 +218,7 @@ export function createOrbitRings(orbs: FireflyPlacement[]): OrbitRings { } function buildSelectedMeshes(slotOrbs: FireflyPlacement[]): void { - const thickness = FIREFLIES.value.ORBIT_RING_THICKNESS; + const thickness = fireflies.value.ORBIT_RING_THICKNESS; for (const orb of slotOrbs) { const geom = buildTubeGeometry(orb, thickness); // Pre-allocate the color attribute so the renderer sees it on the @@ -271,12 +277,12 @@ export function createOrbitRings(orbs: FireflyPlacement[]): OrbitRings { update(timeMs: number) { if (selected.meshes.length === 0) return; for (const mesh of selected.meshes) { - writeRainbowToTube(mesh, timeMs); + writeRainbowToTube(mesh, timeMs, rainbow.value); } }, refresh() { - const next = FIREFLIES.value; + const next = fireflies.value; group.visible = next.ORBIT_RING_ENABLED; }, diff --git a/app/src/city/components/footprint/footprint.frag.glsl b/app/src/city/scene/components/footprint/footprint.frag.glsl similarity index 100% rename from app/src/city/components/footprint/footprint.frag.glsl rename to app/src/city/scene/components/footprint/footprint.frag.glsl diff --git a/app/src/city/components/footprint/footprint.vert.glsl b/app/src/city/scene/components/footprint/footprint.vert.glsl similarity index 100% rename from app/src/city/components/footprint/footprint.vert.glsl rename to app/src/city/scene/components/footprint/footprint.vert.glsl diff --git a/app/src/city/components/footprint/index.ts b/app/src/city/scene/components/footprint/index.ts similarity index 92% rename from app/src/city/components/footprint/index.ts rename to app/src/city/scene/components/footprint/index.ts index f8e7ae019..336905975 100644 --- a/app/src/city/components/footprint/index.ts +++ b/app/src/city/scene/components/footprint/index.ts @@ -6,18 +6,16 @@ import * as THREE from 'three'; import { effect } from '@preact/signals'; -import { FOOTPRINT } from '@/state/settings/fields/footprint'; -import { RUINS } from '@/state/settings/fields/ruins'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; -import type { CityLayout } from '@/types'; -import { setColorFromHex } from '@/city/utils/color/setColorFromHex'; -import { rectOfBuilding, rectOfStreet } from '@/city/layout/rect'; -import type { Rect } from '@/city/layout/rect'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; +import type { CityLayout } from '@/city/scene/types'; +import { setColorFromHex } from '@/city/scene/utils/color/setColorFromHex'; +import { rectOfBuilding, rectOfStreet } from '@/city/scene/layout/rect'; +import type { Rect } from '@/city/scene/layout/rect'; import type { SceneComponent, SceneContext } from '../../types'; -import { onSettings } from '../../utils/onSettings'; +import { onConfig } from '../../utils/onConfig'; import { BuildingLane } from '../buildings/scrubState'; -import type { ScrubStates } from '@/city/timeline/scrubPass'; +import type { ScrubStates } from '@/city/scene/scrub/scrubPass'; import FOOTPRINT_VERT from './footprint.vert.glsl?raw'; import FOOTPRINT_FRAG from './footprint.frag.glsl?raw'; @@ -37,8 +35,8 @@ export interface Footprint extends SceneComponent { } export function createFootprint(ctx: SceneContext): Footprint { - const { cityState } = ctx; - // Persistent outer group — added to the scene once by createCity. + const { build } = ctx; + // Persistent outer group — added to the scene once by createCityScene. // rebuild() swaps the inner InstancedMesh in and out of this group. const group = new THREE.Group(); group.name = 'city-footprint'; @@ -116,7 +114,7 @@ export function createFootprint(ctx: SceneContext): Footprint { } function rebuild(layout: CityLayout): void { - const cfg = FOOTPRINT.value; + const cfg = ctx.config.FOOTPRINT.value; const halo = Math.max(0, cfg.HALO_WIDTH); // Dispose prior mesh first (swap pattern mirrors gem). @@ -172,7 +170,7 @@ export function createFootprint(ctx: SceneContext): Footprint { const colorUniform = new THREE.Color(); setColorFromHex(colorUniform, cfg.COLOR); const ruinColorUniform = new THREE.Color(); - setColorFromHex(ruinColorUniform, RUINS.value.ROAD_COLOR); + setColorFromHex(ruinColorUniform, ctx.config.RUINS.value.ROAD_COLOR); const mat = new THREE.ShaderMaterial({ vertexShader: FOOTPRINT_VERT, @@ -219,8 +217,8 @@ export function createFootprint(ctx: SceneContext): Footprint { // Colour, radius and enabled: halo width is structural and takes the rebuild // path instead. Guarded, since it also runs once before any mesh exists. - const stopEffect = onSettings(FOOTPRINT, () => { - const c = FOOTPRINT.value; + const stopEffect = onConfig(ctx.config.FOOTPRINT, () => { + const c = ctx.config.FOOTPRINT.value; if (material) { setColorFromHex(material.uniforms.uColor.value as THREE.Color, c.COLOR); material.uniforms.uCornerRadius.value = @@ -232,14 +230,14 @@ export function createFootprint(ctx: SceneContext): Footprint { // Ruin road color — reacts to the committed RUINS.ROAD_COLOR (updates on Save); // rebuild seeds a fresh material's value, this keeps it current afterward. const stopRuinColor = effect(() => { - const hex = RUINS.value.ROAD_COLOR; + const hex = ctx.config.RUINS.value.ROAD_COLOR; if (material) setColorFromHex(material.uniforms.uRuinColor.value as THREE.Color, hex); }); // On layout, not structureRevision: per-building dims recompute on every // apply, so the slabs have to re-match even when nothing structural moved. const stopLayout = effect(() => { - const layout = cityState.layout.value; + const layout = build.layout.value; if (layout) rebuild(layout); }); diff --git a/app/src/city/components/gem/anchor.ts b/app/src/city/scene/components/gem/anchor.ts similarity index 94% rename from app/src/city/components/gem/anchor.ts rename to app/src/city/scene/components/gem/anchor.ts index 10f7d357f..224367a91 100644 --- a/app/src/city/components/gem/anchor.ts +++ b/app/src/city/scene/components/gem/anchor.ts @@ -2,7 +2,7 @@ // (the center of the root street's origin-end cap). Computed from the // street geometry; shared by the gem mesh and tree placement. -import { StreetAxis } from '@/types'; +import { StreetAxis } from '@/city/scene/types'; type RootStreetGeometry = { x: number; diff --git a/app/src/city/components/gem/index.ts b/app/src/city/scene/components/gem/index.ts similarity index 88% rename from app/src/city/components/gem/index.ts rename to app/src/city/scene/components/gem/index.ts index 21879ab7f..acc740a44 100644 --- a/app/src/city/components/gem/index.ts +++ b/app/src/city/scene/components/gem/index.ts @@ -1,21 +1,19 @@ // city/components/gem/index.ts — the root gem component: rebuilds the inner -// mesh off cityState.rootStreet, repaints on GEM Saves, animates in tick(). +// mesh off build.rootStreet, repaints on GEM Saves, animates in tick(). // Built before the picker/camera exist, so it reads ctx.picker only in // tick() (live by the first frame), never at construction. import * as THREE from 'three'; import { effect, untracked } from '@preact/signals'; -import { GEM } from '@/state/settings/fields/gem'; -import { BLOOM } from '@/state/settings/fields/effects'; import { NodeKind } from '@/types'; -import type { Street } from '@/types'; -import { disposeObject3D } from '@/city/utils/disposeObject3D'; +import type { Street } from '@/city/scene/types'; +import { disposeObject3D } from '@/city/scene/utils/disposeObject3D'; import type { FrameContext, SceneComponent, SceneContext } from '../../types'; -import { onSettings } from '../../utils/onSettings'; +import { onConfig } from '../../utils/onConfig'; import { createRootGem, GEM_HOVER_LIFT_FRAC } from './mesh'; -import { gemFaceColors, writeFaceColors, type Rgb } from './palette'; +import { gemFaceColorsFor, writeFaceColors, type Rgb } from './palette'; // Cycle a Color in place through the palette (one loop per `period` // seconds); `offset` phases multiple halos apart without allocating. @@ -55,7 +53,10 @@ export interface Gem extends SceneComponent { } export function createGem(ctx: SceneContext): Gem { - const { cityState } = ctx; + const { config } = ctx; + // One memo per city: the per-frame glow allocates nothing at rest. + const faceColors = gemFaceColorsFor(config.GEM); + const { build } = ctx; // Persistent outer group — added to the scene once. rebuild() swaps the // inner gem in and out of this group. const group = new THREE.Group(); @@ -78,7 +79,7 @@ export function createGem(ctx: SceneContext): Gem { function rebuild(street: Street): void { // Build the fresh inner gem, then swap it in and dispose the old one. - const gemGroup = createRootGem(street); + const gemGroup = createRootGem(street, config.GEM.value, config.GEM_SIZING.value); const inner = (gemGroup.userData.gem as THREE.Group) || null; _disposeInnerGem(); @@ -92,17 +93,17 @@ export function createGem(ctx: SceneContext): Gem { if (inner) group.add(inner); } - // untracked() is load-bearing: createRootGem reads GEM.value, and a GEM + // untracked() is load-bearing: createRootGem reads the gem config, and a // subscription would recreate the pickable body sans cityRevision bump. const stopLayout = effect(() => { - const rootStreet = cityState.rootStreet.value; + const rootStreet = build.rootStreet.value; if (rootStreet) untracked(() => rebuild(rootStreet)); }); // GEM Save → repaint in place. Safe at construction: null guards no-op // until the first rebuild. - const stopEffect = onSettings(GEM, () => { - const gemAppearance = GEM.value; + const stopEffect = onConfig(config.GEM, () => { + const gemAppearance = config.GEM.value; if (edges?.material?.color) { edges.material.color.set(gemAppearance.EDGE_COLOR); @@ -122,7 +123,7 @@ export function createGem(ctx: SceneContext): Gem { // tweaks skip a full rebuild. if (body?.geometry?.attributes.color) { const colorAttr = body.geometry.attributes.color as THREE.BufferAttribute; - writeFaceColors(colorAttr.array as Float32Array, gemFaceColors.value); + writeFaceColors(colorAttr.array as Float32Array, faceColors.value); colorAttr.needsUpdate = true; } if (gem && gem.userData.streetWidth != null) { @@ -150,7 +151,7 @@ export function createGem(ctx: SceneContext): Gem { function tick(_dt: number, frame: FrameContext): void { if (!gem) return; - const gemCfg = GEM.value; + const gemCfg = config.GEM.value; // Absolute time (seconds since render-loop start), NOT dt. const t = frame.time; gem.rotation.y = t * gemCfg.ROTATION_SPEED; @@ -178,7 +179,7 @@ export function createGem(ctx: SceneContext): Gem { if (outer) outer.quaternion.copy(_glowQuat); if (gemCfg.GLOW_ANIMATE_COLORS) { // Memoized computed — cached array, zero per-frame parsing/allocation. - const colors = gemFaceColors.value; + const colors = faceColors.value; const period = Math.max(0.001, gemCfg.GLOW_CYCLE_PERIOD_SECONDS); if (inner) _setPaletteColor(inner.material.color, colors, t, period, 0); if (outer) _setPaletteColor(outer.material.color, colors, t, period, 0.5); @@ -189,7 +190,7 @@ export function createGem(ctx: SceneContext): Gem { } // HDR push: scaling the LDR halo color past 1.0 is what the bloom // threshold picks up; gated on BLOOM.ENABLED for the flat mode. - const gemEmission = BLOOM.value.ENABLED ? gemCfg.GLOW_EMISSION : 1.0; + const gemEmission = config.BLOOM.value.ENABLED ? gemCfg.GLOW_EMISSION : 1.0; if (gemEmission !== 1) { if (inner) inner.material.color.multiplyScalar(gemEmission); if (outer) outer.material.color.multiplyScalar(gemEmission); diff --git a/app/src/city/components/gem/mesh.ts b/app/src/city/scene/components/gem/mesh.ts similarity index 92% rename from app/src/city/components/gem/mesh.ts rename to app/src/city/scene/components/gem/mesh.ts index 1a4ca27a5..c12003547 100644 --- a/app/src/city/components/gem/mesh.ts +++ b/app/src/city/scene/components/gem/mesh.ts @@ -4,14 +4,14 @@ // plaza, so no separate pad mesh exists. import * as THREE from 'three'; -import { GEM, GEM_SIZING, type GemSizingConfig } from '@/state/settings/fields/gem'; -import { NEUTRAL_POLYGON_OFFSET } from '@/city/utils/neutralPolygonOffset'; -import { BYTE_MAX } from '@/city/utils/bufferLayout'; +import type { GemConfig, GemSizingConfig } from '@/city/session/settings/gem'; +import { NEUTRAL_POLYGON_OFFSET } from '@/city/scene/constants/polygonOffset'; +import { BYTE_MAX } from '@/city/scene/constants/bufferLayout'; import { NodeKind } from '@/types'; import { gemAnchorXZ } from './anchor'; import { paletteColors, writeFaceColors } from './palette'; import { buildGemGeometry } from './shapes'; -import type { Street } from '@/types'; +import type { Street } from '@/city/scene/types'; // Hover-lift as a fraction of street width — fixed, not user-tunable; // index.ts recomputes baseY from it on Save. @@ -80,9 +80,11 @@ export function gemRadiusFor(streetWidth: number, sizing: GemSizingConfig): numb return Math.max(streetWidth * sizing.RADIUS_AS_STREET_FRAC, sizing.MIN_RADIUS); } -export function createRootGem(street: Street): THREE.Group { - const sizing = GEM_SIZING.value; - const appearance = GEM.value; +export function createRootGem( + street: Street, + appearance: GemConfig, + sizing: GemSizingConfig +): THREE.Group { const edgeColor = appearance.EDGE_COLOR; const group = new THREE.Group(); @@ -97,9 +99,9 @@ export function createRootGem(street: Street): THREE.Group { const gemZ = anchor.y; // ---- Gem: per-face colored polyhedron ------------------------------------- - const geo = buildGemGeometry(GEM.value.SIDES, radius); + const geo = buildGemGeometry(appearance.SIDES, radius); - const faceColors = paletteColors(GEM.value); + const faceColors = paletteColors(appearance); const vertexCount = geo.attributes.position.count; const colorAttr = new Float32Array(vertexCount * 3); @@ -129,7 +131,7 @@ export function createRootGem(street: Street): THREE.Group { // Halo: two PlaneGeometry quads (hot core + atmosphere), NOT THREE.Sprite // — a specialized GPU path some mobile drivers corrupt into flashes. const gem = new THREE.Group(); - const glowCfg = GEM.value; + const glowCfg = appearance; const glowTex = _makeGlowTexture(); const glowQuadGeo = new THREE.PlaneGeometry(1, 1); const innerGlow = new THREE.Mesh( diff --git a/app/src/city/components/gem/palette.ts b/app/src/city/scene/components/gem/palette.ts similarity index 81% rename from app/src/city/components/gem/palette.ts rename to app/src/city/scene/components/gem/palette.ts index 173dae5d8..83231a3f8 100644 --- a/app/src/city/components/gem/palette.ts +++ b/app/src/city/scene/components/gem/palette.ts @@ -1,11 +1,11 @@ // city/components/gem/palette.ts — the one place that knows the gem's flat // FACE_1..FACE_8 keys become an ordered list of triples, so the geometry bake, -// the Save rewrite and the glow lerp cannot drift. gemFaceColors is memoized -// over the live store, so the per-frame glow allocates nothing at rest. +// the Save rewrite and the glow lerp cannot drift. One memo per city, so the +// per-frame glow allocates nothing at rest. import * as THREE from 'three'; -import { computed } from '@preact/signals'; +import { computed, type ReadonlySignal } from '@preact/signals'; -import { GEM } from '@/state/settings/fields/gem'; +import type { GemConfig } from '@/city/session/settings/gem'; /** Structural slice of GEM settings — only the face-color keys, so tests * (and any future palette source) can pass a minimal object. */ @@ -43,7 +43,9 @@ export function paletteColors(palette: GemFacePalette): Rgb[] { /** The live GEM store's face palette, parsed once per GEM change (Save) and * cached — same array identity across reads until the store updates. */ -export const gemFaceColors = computed(() => paletteColors(GEM.value)); +export function gemFaceColorsFor(gem: ReadonlySignal): ReadonlySignal { + return computed(() => paletteColors(gem.value)); +} /** Palette into a vertex-colour buffer, 9 floats per triangle: the 3 vertices * of a face share its colour. Shared by the initial bake and the Save rewrite. */ diff --git a/app/src/city/components/gem/shapes.ts b/app/src/city/scene/components/gem/shapes.ts similarity index 61% rename from app/src/city/components/gem/shapes.ts rename to app/src/city/scene/components/gem/shapes.ts index d4dbcae2f..8084c14aa 100644 --- a/app/src/city/components/gem/shapes.ts +++ b/app/src/city/scene/components/gem/shapes.ts @@ -1,12 +1,10 @@ -// city/components/gem/shapes.ts — SIDES → THREE geometry builders for the -// root gem's body. The key set is the canonical GEM_SIDES vocabulary from -// constants/gem.ts; the Record type makes this table compile-time exhaustive -// against it (a shape added or removed there errors here, and vice versa). -// mesh.ts builds the body through buildGemGeometry() — no hand-written -// switch over the shape set anywhere. +// city/scene/components/gem/shapes.ts — SIDES → geometry for the root gem's +// body. Keyed by the GEM_SIDES vocabulary, as a Record, so the table is +// exhaustive at compile time: a shape added there errors here until it is +// built. mesh.ts goes through buildGemGeometry, never a switch. import * as THREE from 'three'; -import { GEM_SIDES_DEFAULT, type GemSides } from '@/constants/gem'; +import { GEM_SIDES_DEFAULT, type GemSides } from '@/city/scene/constants/gem'; /** Face count → geometry builder. detail 0 keeps the raw polyhedron * (un-subdivided), so the face count matches the key. */ diff --git a/app/src/city/components/island/index.ts b/app/src/city/scene/components/island/index.ts similarity index 81% rename from app/src/city/components/island/index.ts rename to app/src/city/scene/components/island/index.ts index 07a5f3cf7..07f556f5b 100644 --- a/app/src/city/components/island/index.ts +++ b/app/src/city/scene/components/island/index.ts @@ -5,13 +5,13 @@ import * as THREE from 'three'; import { effect } from '@preact/signals'; -import { ISLAND } from '@/state/settings/fields/island'; -import { getWorldBounds, type WorldBounds } from '@/city/utils/floorBounds'; +import type { IslandConfig } from '@/city/session/settings/island'; +import { getWorldBounds, type WorldBounds } from '@/city/scene/utils/floorBounds'; import { buildIslandGeometry, type IslandBuildParams } from './islandGeometry'; import { createIslandMaterial } from './islandShader'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; import type { SceneComponent, SceneContext } from '../../types'; -import { onSettings } from '../../utils/onSettings'; +import { onConfig } from '../../utils/onConfig'; // Coplanar with the city, because it is the ground the city stands on. Dropping // it to dodge a z-fight suspended every building and tree above the grass. @@ -22,8 +22,11 @@ export interface Island extends SceneComponent { setBounds(bounds: WorldBounds): void; } -function buildParams(bounds: WorldBounds, seedFromBounds: number): IslandBuildParams { - const g = ISLAND.value; +function buildParams( + bounds: WorldBounds, + seedFromBounds: number, + g: IslandConfig +): IslandBuildParams { return { sides: g.SIDES, irregularity: g.IRREGULARITY, @@ -52,11 +55,12 @@ export function islandSeedFromBounds(b: WorldBounds): number { return h >>> 0; } -// Reads only ctx.cityState (to size itself reactively off latestWorldBounds — +// Reads only ctx.build (to size itself reactively off latestWorldBounds — // see the bounds effect below); no picker/camera/renderer needed at construction. export function createIsland(ctx: SceneContext): Island { - const { cityState } = ctx; - let currentBounds = getWorldBounds(null); + const { build, config } = ctx; + const island = config.ISLAND; + let currentBounds = getWorldBounds(null, config.WORLD.value); // No city yet, so no ground: the fallback rectangle exists to give the mesh a // shape, not to paint an island over whatever the view put behind the canvas. let sized = false; @@ -65,14 +69,14 @@ export function createIsland(ctx: SceneContext): Island { group.visible = false; // Island mesh. - let params = buildParams(currentBounds, islandSeedFromBounds(currentBounds)); - const mats = ISLAND.value; + let params = buildParams(currentBounds, islandSeedFromBounds(currentBounds), island.value); + const mats = island.value; let geometry = buildIslandGeometry(params, { GRASS: mats.GRASS_COLOR, GRASS_SIDE: mats.GRASS_SIDE_COLOR, ROCK: mats.ROCK_COLOR, }); - const material = createIslandMaterial(); + const material = createIslandMaterial(island.value); const islandMesh = new THREE.Mesh(geometry, material); islandMesh.renderOrder = RENDER_ORDERS.VALLEY_FLOOR; islandMesh.frustumCulled = false; @@ -82,8 +86,8 @@ export function createIsland(ctx: SceneContext): Island { function setBounds(newBounds: WorldBounds): void { currentBounds = newBounds; geometry.dispose(); - params = buildParams(currentBounds, islandSeedFromBounds(currentBounds)); - const m = ISLAND.value; + params = buildParams(currentBounds, islandSeedFromBounds(currentBounds), island.value); + const m = island.value; geometry = buildIslandGeometry(params, { GRASS: m.GRASS_COLOR, GRASS_SIDE: m.GRASS_SIDE_COLOR, @@ -92,23 +96,23 @@ export function createIsland(ctx: SceneContext): Island { islandMesh.geometry = geometry; group.position.set(currentBounds.cx, ISLAND_TOP_Y, currentBounds.cz); sized = true; - group.visible = ISLAND.peek().ENABLED; + group.visible = island.peek().ENABLED; } // The bounds reference is stable across a reuse apply, so this fires on real // changes only, with no gate of its own. const stopBounds = effect(() => { - const bounds = cityState.latestWorldBounds.value; + const bounds = build.latestWorldBounds.value; if (bounds) setBounds(bounds); }); // Also runs once at construction, re-applying what the constructor baked. - const stopEffect = onSettings(ISLAND, () => { + const stopEffect = onConfig(island, () => { // Vertex colours are baked into the geometry, so a colour change rebuilds. // Only once sized: the fallback rectangle is not ground worth painting. if (sized) setBounds(currentBounds); - group.visible = sized && ISLAND.value.ENABLED; - const m = ISLAND.value; + group.visible = sized && island.value.ENABLED; + const m = island.value; (material.uniforms.uHemiSkyColor!.value as THREE.Color).set(m.HEMI_SKY_COLOR); (material.uniforms.uHemiGroundColor!.value as THREE.Color).set(m.HEMI_GROUND_COLOR); material.uniforms.uGrassTexture!.value = m.GRASS_TEXTURE; diff --git a/app/src/city/components/island/island.frag.glsl b/app/src/city/scene/components/island/island.frag.glsl similarity index 100% rename from app/src/city/components/island/island.frag.glsl rename to app/src/city/scene/components/island/island.frag.glsl diff --git a/app/src/city/components/island/island.vert.glsl b/app/src/city/scene/components/island/island.vert.glsl similarity index 100% rename from app/src/city/components/island/island.vert.glsl rename to app/src/city/scene/components/island/island.vert.glsl diff --git a/app/src/city/components/island/islandGeometry.ts b/app/src/city/scene/components/island/islandGeometry.ts similarity index 100% rename from app/src/city/components/island/islandGeometry.ts rename to app/src/city/scene/components/island/islandGeometry.ts diff --git a/app/src/city/components/island/islandShader.ts b/app/src/city/scene/components/island/islandShader.ts similarity index 87% rename from app/src/city/components/island/islandShader.ts rename to app/src/city/scene/components/island/islandShader.ts index 2db2e6d55..e89da54dc 100644 --- a/app/src/city/components/island/islandShader.ts +++ b/app/src/city/scene/components/island/islandShader.ts @@ -3,13 +3,12 @@ // by the city's own lighting. import * as THREE from 'three'; -import { ISLAND } from '@/state/settings/fields/island'; +import type { IslandConfig } from '@/city/session/settings/island'; import vertSrc from './island.vert.glsl?raw'; import fragSrc from './island.frag.glsl?raw'; -export function createIslandMaterial(): THREE.ShaderMaterial { - const mats = ISLAND.value; +export function createIslandMaterial(mats: IslandConfig): THREE.ShaderMaterial { return new THREE.ShaderMaterial({ vertexShader: vertSrc, fragmentShader: fragSrc, diff --git a/app/src/city/components/pathLine/index.ts b/app/src/city/scene/components/pathLine/index.ts similarity index 87% rename from app/src/city/components/pathLine/index.ts rename to app/src/city/scene/components/pathLine/index.ts index 6305fee1f..585c04076 100644 --- a/app/src/city/components/pathLine/index.ts +++ b/app/src/city/scene/components/pathLine/index.ts @@ -3,11 +3,9 @@ // picker-driven effects, so it is armed on the first tick, not at construction. import * as THREE from 'three'; -import { STREETS } from '@/state/settings/fields/streets'; - import type { FrameContext, SceneComponent, SceneContext } from '../../types'; import { armOnFirstTick } from '../../utils/armOnFirstTick'; -import { onSettings } from '../../utils/onSettings'; +import { onConfig } from '../../utils/onConfig'; import { createPathLineRenderer } from './renderer'; /** Public contract for the pathLine component. */ @@ -20,7 +18,7 @@ export interface PathLine extends SceneComponent { } export function createPathLine(ctx: SceneContext): PathLine { - const { cityState } = ctx; + const { build } = ctx; // Added to the scene once; the renderer parents its meshes in at arming. // Draw order comes from RENDER_ORDERS.PATH_LINE, not graph position. const group = new THREE.Group(); @@ -32,10 +30,13 @@ export function createPathLine(ctx: SceneContext): PathLine { // renderer's effects would be born dead. The sticky flag survives dispose. const _arm = armOnFirstTick(ctx, () => { _inner = createPathLineRenderer({ + streets: ctx.config.STREETS, + streetTiers: ctx.config.STREET_TIERS, + rainbow: ctx.config.RAINBOW, canvas: ctx.canvas, scene: group, picker: ctx.picker!, - cityState, + build, }); return [ () => { @@ -47,7 +48,7 @@ export function createPathLine(ctx: SceneContext): PathLine { // untracked, because refreshMaterials reads picker.hover/selection and this // effect must subscribe to STREETS alone. - const stopTheme = onSettings(STREETS, () => _inner?.refreshMaterials()); + const stopTheme = onConfig(ctx.config.STREETS, () => _inner?.refreshMaterials()); // tick() — arms the renderer on the first call, then advances the rainbow // chase on the selection line. @@ -63,7 +64,7 @@ export function createPathLine(ctx: SceneContext): PathLine { function dispose(): void { stopTheme(); // Inner dispose (run via _arm's teardown) also stops its picker effects + - // its cityState rebuild effect. + // its build rebuild effect. _arm.dispose(); } diff --git a/app/src/city/components/pathLine/renderer.ts b/app/src/city/scene/components/pathLine/renderer.ts similarity index 78% rename from app/src/city/components/pathLine/renderer.ts rename to app/src/city/scene/components/pathLine/renderer.ts index 9dad13b8d..1702cbf2f 100644 --- a/app/src/city/components/pathLine/renderer.ts +++ b/app/src/city/scene/components/pathLine/renderer.ts @@ -5,23 +5,24 @@ import * as THREE from 'three'; import { effect, untracked } from '@preact/signals'; import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; -import { SafeLineSegmentsGeometry } from '@/city/utils/safeLineSegmentsGeometry'; +import { SafeLineSegmentsGeometry } from '@/city/scene/utils/safeLineSegmentsGeometry'; -import { STREETS, STREET_TIERS } from '@/state/settings/fields/streets'; -import { createSafeLineMaterial } from '@/city/utils/safeLineMaterial'; -import { PATH_LINE_ELEVATION, HOVER_PATH_LINE_ELEVATION } from '@/city/constants/streets'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; +import type { StreetsConfig, StreetTiersConfig } from '@/city/session/settings/streets'; +import type { RainbowConfig } from '@/city/session/settings/effects'; +import { createSafeLineMaterial } from '@/city/scene/utils/safeLineMaterial'; +import { PATH_LINE_ELEVATION, HOVER_PATH_LINE_ELEVATION } from '@/city/scene/constants/streets'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; import { NodeKind } from '@/types'; -import { computePathPoints } from '@/city/layout/streetPath'; -import { rainbowRgbAt } from '@/city/utils/rainbowChase'; -import type { PickTarget } from '@/types/picker'; +import { computePathPoints } from '@/city/scene/layout/streetPath'; +import { rainbowRgbAt } from '@/city/scene/utils/rainbowChase'; +import type { PickTarget } from '@/city/scene/types/picker'; import type { ReadonlySignal } from '@preact/signals'; -import type { CityState } from '@/city/state'; +import type { CityBuild } from '@/city/scene/build'; /** LINEWIDTH_PCT → screen pixels off the narrowest street tier, so the line * stays proportional to the streets at any zoom (worldUnits: false). */ -export function computePathLinewidthPixels(pct: number): number { - const tiers = STREET_TIERS.value.TIERS; +export function computePathLinewidthPixels(pct: number, streetTiers: StreetTiersConfig): number { + const tiers = streetTiers.TIERS; if (!tiers.length) return pct / 100; // degenerate fallback const minWidth = Math.min(...tiers.map((t) => t.width)); return minWidth * (pct / 100); @@ -38,20 +39,28 @@ export function createPathLineRenderer({ canvas, scene, picker, - cityState, + build, + streets, + streetTiers, + rainbow, }: { canvas: HTMLCanvasElement; /** Parent for the two line meshes; draw order comes from * RENDER_ORDERS.PATH_LINE, not graph position. */ scene: THREE.Object3D; picker: PickerSignals; - cityState: CityState; + build: CityBuild; + /** This city's street settings: the line is sized off its narrowest tier. */ + streets: ReadonlySignal; + streetTiers: ReadonlySignal; + /** The chase the selected path runs. */ + rainbow: ReadonlySignal; }) { // ── Selection path line (rainbow vertex colors) ──────────────────── - const _pl = STREETS.value; + const _pl = streets.value; const pathLineMat = createSafeLineMaterial({ vertexColors: true, - linewidth: computePathLinewidthPixels(_pl.PATH_LINEWIDTH_PCT), + linewidth: computePathLinewidthPixels(_pl.PATH_LINEWIDTH_PCT, streetTiers.value), transparent: true, opacity: 0.0, depthTest: true, @@ -72,8 +81,8 @@ export function createPathLineRenderer({ // ── Hover preview path line (single solid color, faded) ──────────── // Width is shared with the selection line — reads PATH_LINE.LINEWIDTH_PCT. const hoverPathLineMat = createSafeLineMaterial({ - color: STREETS.value.HOVER_PATH_COLOR, - linewidth: computePathLinewidthPixels(STREETS.value.PATH_LINEWIDTH_PCT), + color: streets.value.HOVER_PATH_COLOR, + linewidth: computePathLinewidthPixels(streets.value.PATH_LINEWIDTH_PCT, streetTiers.value), transparent: true, opacity: 0.0, depthTest: true, @@ -102,18 +111,14 @@ export function createPathLineRenderer({ function _updatePathLine(): void { const sel = picker.selection.value; - const gemPos = cityState.gemWorldPos.peek(); + const gemPos = build.gemWorldPos.peek(); if (!gemPos || !sel) { pathLine.visible = false; pathLineMat.opacity = 0; pathSegmentCount = 0; return; } - const pts = computePathPoints( - sel, - { x: gemPos.x, z: gemPos.z }, - cityState.streetsByDirMap.peek() - ); + const pts = computePathPoints(sel, { x: gemPos.x, z: gemPos.z }, build.streetsByDirMap.peek()); if (pts.length < 2) { pathLine.visible = false; pathLineMat.opacity = 0; @@ -138,14 +143,14 @@ export function createPathLineRenderer({ if (_pathColorsBuf.length !== pathSegmentCount * 6) { _pathColorsBuf = new Float32Array(pathSegmentCount * 6); } - pathLineMat.opacity = STREETS.value.PATH_OPACITY; + pathLineMat.opacity = streets.value.PATH_OPACITY; pathLine.visible = true; } function _updateHoverPathLine(): void { const hov = picker.hover.value; - const gemPos = cityState.gemWorldPos.peek(); - const cfg = STREETS.value; + const gemPos = build.gemWorldPos.peek(); + const cfg = streets.value; function hide() { hoverPathLine.visible = false; hoverPathLineMat.opacity = 0; @@ -153,11 +158,7 @@ export function createPathLineRenderer({ if (!gemPos || !hov) return hide(); if (hov.kind === NodeKind.Gem) return hide(); if (_isHoverSameAsSelection()) return hide(); - const pts = computePathPoints( - hov, - { x: gemPos.x, z: gemPos.z }, - cityState.streetsByDirMap.peek() - ); + const pts = computePathPoints(hov, { x: gemPos.x, z: gemPos.z }, build.streetsByDirMap.peek()); if (pts.length < 2) return hide(); const elev = HOVER_PATH_LINE_ELEVATION; const flat: number[] = []; @@ -188,8 +189,8 @@ export function createPathLineRenderer({ // Recompute both lines when the gem moves or the city rebuilds; // untracked() keeps the subscription to exactly those two signals. const _disposeRebuildEffect = effect(() => { - void cityState.gemWorldPos.value; - void cityState.cityRevision.value; + void build.gemWorldPos.value; + void build.cityRevision.value; untracked(() => { _updatePathLine(); _updateHoverPathLine(); @@ -203,11 +204,11 @@ export function createPathLineRenderer({ const n = pathSegmentCount; for (let s = 0; s < n; s++) { // Consume the start RGB before the second call overwrites the scratch. - const [r0, g0, b0] = rainbowRgbAt(timeMs, s / n); + const [r0, g0, b0] = rainbowRgbAt(timeMs, s / n, rainbow.value); _pathColorsBuf[s * 6] = r0; _pathColorsBuf[s * 6 + 1] = g0; _pathColorsBuf[s * 6 + 2] = b0; - const [r1, g1, b1] = rainbowRgbAt(timeMs, (s + 1) / n); + const [r1, g1, b1] = rainbowRgbAt(timeMs, (s + 1) / n, rainbow.value); _pathColorsBuf[s * 6 + 3] = r1; _pathColorsBuf[s * 6 + 4] = g1; _pathColorsBuf[s * 6 + 5] = b1; @@ -216,11 +217,14 @@ export function createPathLineRenderer({ } function refreshMaterials(): void { - const pl = STREETS.value; - pathLineMat.linewidth = computePathLinewidthPixels(pl.PATH_LINEWIDTH_PCT); + const pl = streets.value; + pathLineMat.linewidth = computePathLinewidthPixels(pl.PATH_LINEWIDTH_PCT, streetTiers.value); if (pathLine.visible) pathLineMat.opacity = pl.PATH_OPACITY; hoverPathLineMat.color.set(pl.HOVER_PATH_COLOR); - hoverPathLineMat.linewidth = computePathLinewidthPixels(pl.PATH_LINEWIDTH_PCT); + hoverPathLineMat.linewidth = computePathLinewidthPixels( + pl.PATH_LINEWIDTH_PCT, + streetTiers.value + ); _updateHoverPathLine(); } diff --git a/app/src/city/components/repoLabel/bounds.ts b/app/src/city/scene/components/repoLabel/bounds.ts similarity index 89% rename from app/src/city/components/repoLabel/bounds.ts rename to app/src/city/scene/components/repoLabel/bounds.ts index a0e92ffc2..1b26bd101 100644 --- a/app/src/city/components/repoLabel/bounds.ts +++ b/app/src/city/scene/components/repoLabel/bounds.ts @@ -4,8 +4,8 @@ // frames a different city than R does (issue #62). import { aspectForName } from './textCanvas'; -import type { RepoLabelConfig } from '@/state/settings/fields/gem'; -import type { BuildingDimensionsConfig } from '@/state/settings/fields/buildings'; +import type { RepoLabelConfig } from '@/city/session/settings/gem'; +import type { BuildingDimensionsConfig } from '@/city/session/settings/buildings'; export interface RepoLabelBounds { centerX: number; diff --git a/app/src/city/components/repoLabel/holoBeam.frag.glsl b/app/src/city/scene/components/repoLabel/holoBeam.frag.glsl similarity index 100% rename from app/src/city/components/repoLabel/holoBeam.frag.glsl rename to app/src/city/scene/components/repoLabel/holoBeam.frag.glsl diff --git a/app/src/city/components/repoLabel/holoQuad.vert.glsl b/app/src/city/scene/components/repoLabel/holoQuad.vert.glsl similarity index 100% rename from app/src/city/components/repoLabel/holoQuad.vert.glsl rename to app/src/city/scene/components/repoLabel/holoQuad.vert.glsl diff --git a/app/src/city/components/repoLabel/holoText.frag.glsl b/app/src/city/scene/components/repoLabel/holoText.frag.glsl similarity index 100% rename from app/src/city/components/repoLabel/holoText.frag.glsl rename to app/src/city/scene/components/repoLabel/holoText.frag.glsl diff --git a/app/src/city/components/repoLabel/index.ts b/app/src/city/scene/components/repoLabel/index.ts similarity index 91% rename from app/src/city/components/repoLabel/index.ts rename to app/src/city/scene/components/repoLabel/index.ts index a103ceb53..c823b14d5 100644 --- a/app/src/city/components/repoLabel/index.ts +++ b/app/src/city/scene/components/repoLabel/index.ts @@ -6,12 +6,10 @@ import * as THREE from 'three'; import { effect } from '@preact/signals'; -import { BUILDING_DIMENSIONS } from '@/state/settings/fields/buildings'; -import { REPO_LABEL } from '@/state/settings/fields/gem'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; import type { FrameContext, SceneComponent, SceneContext } from '../../types'; -import { NEUTRAL_POLYGON_OFFSET } from '@/city/utils/neutralPolygonOffset'; +import { NEUTRAL_POLYGON_OFFSET } from '@/city/scene/constants/polygonOffset'; import vertSrc from './holoQuad.vert.glsl?raw'; import beamFragSrc from './holoBeam.frag.glsl?raw'; import textFragSrc from './holoText.frag.glsl?raw'; @@ -75,10 +73,11 @@ export interface RepoLabelDeps { getGem: () => THREE.Object3D | null; } -// Reads only ctx.cityState + deps.getGem; the camera arrives per-frame via +// Reads only ctx.build + deps.getGem; the camera arrives per-frame via // FrameContext, never at construction. export function createRepoLabel(ctx: SceneContext, deps: RepoLabelDeps): RepoLabel { - const { cityState } = ctx; + const { config } = ctx; + const { build } = ctx; const group = new THREE.Group(); group.name = 'repoLabel'; @@ -103,11 +102,11 @@ export function createRepoLabel(ctx: SceneContext, deps: RepoLabelDeps): RepoLab // world Y — recomputed on settings changes AND per frame (gem bob). function _updateBeamGeometry(): void { if (!beamMesh) return; - const cfg = REPO_LABEL.value; + const cfg = config.REPO_LABEL.value; const halfFont = cfg.FONT_SIZE / 2; const beamRadius = cfg.FONT_SIZE * BEAM_RADIUS_FRAC; - const dims = BUILDING_DIMENSIONS.value; + const dims = config.BUILDING_DIMENSIONS.value; const maxBldgH = dims.MAX_FLOORS * dims.FLOOR_HEIGHT; const heightWorld = maxBldgH * (cfg.HEIGHT_PCT / 100); @@ -124,9 +123,9 @@ export function createRepoLabel(ctx: SceneContext, deps: RepoLabelDeps): RepoLab } function _applyTransform(): void { - const cfg = REPO_LABEL.value; + const cfg = config.REPO_LABEL.value; const halfFont = cfg.FONT_SIZE / 2; - const dims = BUILDING_DIMENSIONS.value; + const dims = config.BUILDING_DIMENSIONS.value; const maxBldgH = dims.MAX_FLOORS * dims.FLOOR_HEIGHT; const heightWorld = maxBldgH * (cfg.HEIGHT_PCT / 100); // Group origin = panel center. Panel bottom = heightWorld above the @@ -145,13 +144,13 @@ export function createRepoLabel(ctx: SceneContext, deps: RepoLabelDeps): RepoLab } function _applyOpacity(): void { - const opacity = REPO_LABEL.value.OPACITY; + const opacity = config.REPO_LABEL.value.OPACITY; if (panelMat) panelMat.uniforms.uOpacity.value = opacity; if (beamMat) beamMat.uniforms.uOpacity.value = opacity; } function _applyColors(): void { - const cfg = REPO_LABEL.value; + const cfg = config.REPO_LABEL.value; if (beamMat) (beamMat.uniforms.uColor.value as THREE.Color).set(cfg.BEAM_COLOR); if (panelMat) (panelMat.uniforms.uTint.value as THREE.Color).set(cfg.TEXT_COLOR); } @@ -199,7 +198,7 @@ export function createRepoLabel(ctx: SceneContext, deps: RepoLabelDeps): RepoLab side: THREE.DoubleSide, ...NEUTRAL_POLYGON_OFFSET, uniforms: { - uColor: { value: new THREE.Color(REPO_LABEL.value.BEAM_COLOR) }, + uColor: { value: new THREE.Color(config.REPO_LABEL.value.BEAM_COLOR) }, uTime: { value: 0 }, uOpacity: { value: 1.0 }, }, @@ -220,7 +219,7 @@ export function createRepoLabel(ctx: SceneContext, deps: RepoLabelDeps): RepoLab uniforms: { uMap: { value: textTex.texture }, uTime: { value: 0 }, - uTint: { value: new THREE.Color(REPO_LABEL.value.TEXT_COLOR) }, + uTint: { value: new THREE.Color(config.REPO_LABEL.value.TEXT_COLOR) }, uOpacity: { value: 1.0 }, }, }); @@ -271,8 +270,8 @@ export function createRepoLabel(ctx: SceneContext, deps: RepoLabelDeps): RepoLab // Manifest changes every apply (setRepoName re-runs); on a non-reuse apply // the gem rebuilt in the same batch, so getGem() is already fresh. const stopAnchor = effect(() => { - const manifest = cityState.manifest.value; - const gemWorldPos = cityState.gemWorldPos.value; + const manifest = build.manifest.value; + const gemWorldPos = build.gemWorldPos.value; if (!manifest) return; setRepoName(manifest.tree.name); setAnchor(gemWorldPos ?? new THREE.Vector3()); @@ -289,7 +288,7 @@ export function createRepoLabel(ctx: SceneContext, deps: RepoLabelDeps): RepoLab function tick(dt: number, ctx: FrameContext): void { if (!panelMesh || !panelMat) return; - const cfg = REPO_LABEL.value; + const cfg = config.REPO_LABEL.value; if (!cfg.ENABLED) return; const dtScaled = dt * cfg.ANIMATION_SPEED; panelMat.uniforms.uTime.value += dtScaled; @@ -314,8 +313,8 @@ export function createRepoLabel(ctx: SceneContext, deps: RepoLabelDeps): RepoLab return repoLabelBounds( repoName, { x: anchorX, y: anchorY, z: anchorZ }, - REPO_LABEL.value, - BUILDING_DIMENSIONS.value + config.REPO_LABEL.value, + config.BUILDING_DIMENSIONS.value ); } diff --git a/app/src/city/components/repoLabel/textCanvas.ts b/app/src/city/scene/components/repoLabel/textCanvas.ts similarity index 100% rename from app/src/city/components/repoLabel/textCanvas.ts rename to app/src/city/scene/components/repoLabel/textCanvas.ts diff --git a/app/src/city/components/sky/index.ts b/app/src/city/scene/components/sky/index.ts similarity index 88% rename from app/src/city/components/sky/index.ts rename to app/src/city/scene/components/sky/index.ts index fb926c98f..b436c4fdc 100644 --- a/app/src/city/components/sky/index.ts +++ b/app/src/city/scene/components/sky/index.ts @@ -4,13 +4,12 @@ // effect, its per-frame work and its GPU teardown. import * as THREE from 'three'; -import { SCENE } from '@/state/settings/fields/scene'; -import { CAMERA_FAR } from '@/city/constants/camera'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; -import { setColorFromHex } from '@/city/utils/color/setColorFromHex'; +import { CAMERA_FAR } from '@/city/scene/constants/camera'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; +import { setColorFromHex } from '@/city/scene/utils/color/setColorFromHex'; import type { FrameContext, SceneComponent, SceneContext } from '../../types'; -import { onSettings } from '../../utils/onSettings'; +import { onConfig } from '../../utils/onConfig'; import skyVertSrc from './sky.vert.glsl?raw'; import skyFragSrc from './sky.frag.glsl?raw'; @@ -47,7 +46,7 @@ export function createSky(ctx: SceneContext): Sky { const geometry = new THREE.IcosahedronGeometry(radius, ICOSAHEDRON_DETAIL); - const cfg = SCENE.value; + const cfg = ctx.config.SCENE.value; const material = new THREE.ShaderMaterial({ vertexShader: skyVertSrc, @@ -81,10 +80,10 @@ export function createSky(ctx: SceneContext): Sky { group.frustumCulled = false; group.userData.cyberpunkValley = 'sky'; - // Pushes SCENE into the uniforms with no rebuild. Runs once at construction - // too, re-applying what the constructor already baked. - const stopEffect = onSettings(SCENE, () => { - const c = SCENE.value; + // Pushes this city's scene settings into the uniforms with no rebuild. Runs + // once at construction too, re-applying what the constructor already baked. + const stopEffect = onConfig(ctx.config.SCENE, () => { + const c = ctx.config.SCENE.value; setColorFromHex(material.uniforms.uSkyColor.value as THREE.Color, c.SKY_COLOR); material.uniforms.uStarsEnabled.value = c.STARS_ENABLED ? 1.0 : 0.0; material.uniforms.uStarDensity.value = c.STARS_DENSITY; diff --git a/app/src/city/components/sky/sky.frag.glsl b/app/src/city/scene/components/sky/sky.frag.glsl similarity index 100% rename from app/src/city/components/sky/sky.frag.glsl rename to app/src/city/scene/components/sky/sky.frag.glsl diff --git a/app/src/city/components/sky/sky.vert.glsl b/app/src/city/scene/components/sky/sky.vert.glsl similarity index 100% rename from app/src/city/components/sky/sky.vert.glsl rename to app/src/city/scene/components/sky/sky.vert.glsl diff --git a/app/src/city/components/streets/index.ts b/app/src/city/scene/components/streets/index.ts similarity index 94% rename from app/src/city/components/streets/index.ts rename to app/src/city/scene/components/streets/index.ts index cadb2fa02..c09b7e93a 100644 --- a/app/src/city/components/streets/index.ts +++ b/app/src/city/scene/components/streets/index.ts @@ -6,15 +6,14 @@ import * as THREE from 'three'; import { effect, untracked } from '@preact/signals'; -import { STREETS } from '@/state/settings/fields/streets'; -import { RUINS } from '@/state/settings/fields/ruins'; -import { setColorFromHex } from '@/city/utils/color/setColorFromHex'; -import { NodeKind, StreetAxis } from '@/types'; -import type { CityLayout, Street } from '@/types'; +import { setColorFromHex } from '@/city/scene/utils/color/setColorFromHex'; +import { NodeKind } from '@/types'; +import { StreetAxis } from '@/city/scene/types'; +import type { CityLayout, Street } from '@/city/scene/types'; import type { FrameContext, SceneComponent, SceneContext } from '../../types'; import { armOnFirstTick } from '../../utils/armOnFirstTick'; -import { onSettings } from '../../utils/onSettings'; +import { onConfig } from '../../utils/onConfig'; import { createMergedSidewalkMesh, createMergedAsphaltMesh, @@ -24,7 +23,7 @@ import { import { createStreetLabels, disposeStreetLabelResources } from './streetLabels'; import { RUINED_STREET_DIRS } from './scrubState'; import type { StreetScrubState } from './scrubState'; -import { disposeObject3D } from '@/city/utils/disposeObject3D'; +import { disposeObject3D } from '@/city/scene/utils/disposeObject3D'; type FlatMesh = THREE.Mesh; @@ -61,7 +60,7 @@ export interface Streets extends SceneComponent { } export function createStreets(ctx: SceneContext): Streets { - const { cityState } = ctx; + const { build } = ctx; // Persistent outer group — added to the scene once. rebuild() swaps the inner // street meshes + labels in and out of this group. const group = new THREE.Group(); @@ -102,7 +101,7 @@ export function createStreets(ctx: SceneContext): Streets { // Tint colors as THREE.Color (written into the merged sidewalk's per-vertex // color attribute). The theme effect refreshes them whenever STREETS mutates. - const _swc0 = STREETS.value; + const _swc0 = ctx.config.STREETS.value; const _defColor = new THREE.Color(_swc0.SIDEWALK_DEFAULT); const _hovColor = new THREE.Color(_swc0.SIDEWALK_HOVER); const _selColor = new THREE.Color(_swc0.SIDEWALK_SELECTED); @@ -256,7 +255,12 @@ export function createStreets(ctx: SceneContext): Streets { const streets = layout.streets ?? []; // Sidewalks + asphalt: one merged mesh each (see createMerged*Mesh). - const built = createMergedSidewalkMesh(streets, 0); + const built = createMergedSidewalkMesh( + streets, + 0, + ctx.config.STREETS.value, + ctx.config.RUINS.value + ); sidewalkMesh = built?.mesh ?? null; sidewalkRanges = built?.ranges ?? []; sidewalkRangeByPath = new Map(); @@ -264,7 +268,12 @@ export function createStreets(ctx: SceneContext): Streets { pickables = sidewalkMesh ? [sidewalkMesh] : []; if (sidewalkMesh) group.add(sidewalkMesh); - const asphaltBuilt = createMergedAsphaltMesh(streets, 0); + const asphaltBuilt = createMergedAsphaltMesh( + streets, + 0, + ctx.config.STREETS.value, + ctx.config.RUINS.value + ); asphaltMesh = asphaltBuilt?.mesh ?? null; if (asphaltMesh) group.add(asphaltMesh); @@ -281,7 +290,7 @@ export function createStreets(ctx: SceneContext): Streets { // Built for every street and culled per frame instead of at build time, so // zooming in always brings a nearby label back. for (const street of streets) { - const labels = createStreetLabels(street); + const labels = createStreetLabels(street, ctx.config.STREETS.value); for (const label of labels) { group.add(label); labelGroups.push(label); @@ -294,15 +303,15 @@ export function createStreets(ctx: SceneContext): Streets { // untracked, or baking the colours subscribes this to STREETS and a Refresh // Save recreates every mesh, orphaning the picker's pickables. const stopLayout = effect(() => { - void cityState.structureRevision.value; - const layout = cityState.layout.peek(); + void build.structureRevision.value; + const layout = build.layout.peek(); if (layout) untracked(() => rebuild(layout)); }); // Repaints in place on a STREETS Save. Reads only settings, so it is safe at // construction, and no-ops over the empty arrays before the first rebuild. - const stopTheme = onSettings(STREETS, () => { - const streets = STREETS.value; + const stopTheme = onConfig(ctx.config.STREETS, () => { + const streets = ctx.config.STREETS.value; _defColor.set(streets.SIDEWALK_DEFAULT); _hovColor.set(streets.SIDEWALK_HOVER); @@ -340,8 +349,8 @@ export function createStreets(ctx: SceneContext): Streets { // rebuild() seeds a fresh mesh's ruin colour; this keeps it current after. const stopRuinColor = effect(() => { - const road = RUINS.value.ROAD_COLOR; - const border = RUINS.value.SIDEWALK_COLOR; + const road = ctx.config.RUINS.value.ROAD_COLOR; + const border = ctx.config.RUINS.value.SIDEWALK_COLOR; const a = asphaltMesh?.material.userData.uRuinColor as { value: THREE.Color } | undefined; if (a) setColorFromHex(a.value, road); const s = sidewalkMesh?.material.userData.uRuinColor as { value: THREE.Color } | undefined; diff --git a/app/src/city/components/streets/scrubState.ts b/app/src/city/scene/components/streets/scrubState.ts similarity index 96% rename from app/src/city/components/streets/scrubState.ts rename to app/src/city/scene/components/streets/scrubState.ts index 38fb6f114..1bb7caf93 100644 --- a/app/src/city/components/streets/scrubState.ts +++ b/app/src/city/scene/components/streets/scrubState.ts @@ -2,7 +2,7 @@ // buildings. No meshes, no signals: the pass gathers, this decides, the // component applies. -import type { Street } from '@/types'; +import type { Street } from '@/city/scene/types'; // The picker rejects hits on these; buildings use iKind instead. Republished by // the streets component every scrub frame. diff --git a/app/src/city/components/streets/streetLabels.ts b/app/src/city/scene/components/streets/streetLabels.ts similarity index 93% rename from app/src/city/components/streets/streetLabels.ts rename to app/src/city/scene/components/streets/streetLabels.ts index 3c9731893..c207bbf16 100644 --- a/app/src/city/components/streets/streetLabels.ts +++ b/app/src/city/scene/components/streets/streetLabels.ts @@ -4,12 +4,17 @@ // with an ellipsis rather than shrink further. import * as THREE from 'three'; -import { STREETS } from '@/state/settings/fields/streets'; -import { LABEL_FONT_FAMILY, LABEL_FONT_WEIGHT, LABEL_ELEVATION } from '@/city/constants/streets'; +import type { StreetsConfig } from '@/city/session/settings/streets'; +import { + LABEL_FONT_FAMILY, + LABEL_FONT_WEIGHT, + LABEL_ELEVATION, +} from '@/city/scene/constants/streets'; import { asphaltDims } from './streets'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; -import { NodeKind, StreetAxis } from '@/types'; -import type { Street } from '@/types'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; +import { NodeKind } from '@/types'; +import { StreetAxis } from '@/city/scene/types'; +import type { Street } from '@/city/scene/types'; // Hardcoded label constants — these have no visible effect at normal viewing // distances, so they are baked in here rather than exposed as UI controls. @@ -28,11 +33,11 @@ const LABEL_ELLIPSIS = '…'; function _buildLabelTexture( text: string, + streets: StreetsConfig, maxAspect?: number ): { texture: THREE.CanvasTexture; aspect: number; text: string } { // High source resolution so close-zoom doesn't reveal bilinear blur; the // world-space plane size is unchanged. - const streets = STREETS.value; const fontSpec = `${LABEL_FONT_WEIGHT} ${LABEL_FONT_SIZE_PX}px ${LABEL_FONT_FAMILY}`; const measure = document.createElement('canvas').getContext('2d')!; measure.font = fontSpec; @@ -90,11 +95,10 @@ function _truncateToFit( return text.slice(0, lo) + LABEL_ELLIPSIS; } -export function createStreetLabels(street: Street): THREE.Group[] { +export function createStreetLabels(street: Street, streets: StreetsConfig): THREE.Group[] { const text = street.label || ''; if (!text) return []; - const streets = STREETS.value; const orders = RENDER_ORDERS; // Subtract the cap diameter too: the label is rectangular and would poke its @@ -106,7 +110,7 @@ export function createStreetLabels(street: Street): THREE.Group[] { // Height scales with street width and width follows the text's aspect, then // the whole thing is fit to usableLength. const naturalHeight = street.width * streets.LABEL_HEIGHT_FRAC; - let info = _buildLabelTexture(text); + let info = _buildLabelTexture(text, streets); let worldH = naturalHeight; let worldW = worldH * info.aspect; @@ -122,7 +126,7 @@ export function createStreetLabels(street: Street): THREE.Group[] { // this aspect to fit. const maxAspect = usableLength / worldH; info.texture.dispose(); - info = _buildLabelTexture(text, maxAspect); + info = _buildLabelTexture(text, streets, maxAspect); worldW = worldH * info.aspect; } } diff --git a/app/src/city/components/streets/streets.ts b/app/src/city/scene/components/streets/streets.ts similarity index 92% rename from app/src/city/components/streets/streets.ts rename to app/src/city/scene/components/streets/streets.ts index a92cc7385..60ffa80a2 100644 --- a/app/src/city/components/streets/streets.ts +++ b/app/src/city/scene/components/streets/streets.ts @@ -5,13 +5,14 @@ import * as THREE from 'three'; import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; -import { STREETS } from '@/state/settings/fields/streets'; -import { RUINS } from '@/state/settings/fields/ruins'; -import { ASPHALT_WIDTH_FRAC } from '@/city/constants/streets'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; -import { setColorFromHex } from '@/city/utils/color/setColorFromHex'; -import { CapStyle, JoinSide, NodeKind, StreetAxis } from '@/types'; -import type { Street } from '@/types'; +import type { StreetsConfig } from '@/city/session/settings/streets'; +import type { RuinsConfig } from '@/city/session/settings/ruins'; +import { ASPHALT_WIDTH_FRAC } from '@/city/scene/constants/streets'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; +import { setColorFromHex } from '@/city/scene/utils/color/setColorFromHex'; +import { NodeKind } from '@/types'; +import { CapStyle, JoinSide, StreetAxis } from '@/city/scene/types'; +import type { Street } from '@/city/scene/types'; // `joinSide` says which end of a child street merges into its parent, so that // end can be capped flat. @@ -167,7 +168,9 @@ export interface AsphaltRange { // it keeps per-vertex colour and a face→street map, so it stays pickable. export function createMergedSidewalkMesh( streets: StreetWithJoin[], - yBase: number + yBase: number, + streetsCfg: StreetsConfig, + ruinsCfg: RuinsConfig ): { mesh: FlatMesh; ranges: SidewalkRange[] } | null { if (streets.length === 0) return null; const geos: THREE.BufferGeometry[] = []; @@ -190,7 +193,7 @@ export function createMergedSidewalkMesh( // The material's base colour stays white: vertex colours multiply into it, so // anything else would tint the whole run. - const def = new THREE.Color(STREETS.value.SIDEWALK_DEFAULT); + const def = new THREE.Color(streetsCfg.SIDEWALK_DEFAULT); const colors = new Float32Array(vAcc * 3); for (let i = 0; i < vAcc; i++) { colors[i * 3] = def.r; @@ -213,7 +216,7 @@ export function createMergedSidewalkMesh( }); // Sidewalk border tint uses SIDEWALK_COLOR (distinct from the asphalt's road color). const ruinUniform = { value: new THREE.Color() }; - setColorFromHex(ruinUniform.value, RUINS.value.SIDEWALK_COLOR); + setColorFromHex(ruinUniform.value, ruinsCfg.SIDEWALK_COLOR); injectStreetOpacity(mat, { ruin: ruinUniform }); mat.userData.uRuinColor = ruinUniform; const mesh = new THREE.Mesh(merged, mat) as FlatMesh; @@ -268,7 +271,9 @@ function buildAsphaltGeometry(street: StreetWithJoin, yBase: number): THREE.Buff // calls collapse to one. The per-street ranges are what Timeline fades. export function createMergedAsphaltMesh( streets: StreetWithJoin[], - yBase: number + yBase: number, + streetsCfg: StreetsConfig, + ruinsCfg: RuinsConfig ): { mesh: FlatMesh; ranges: AsphaltRange[] } | null { if (streets.length === 0) return null; const geos: THREE.BufferGeometry[] = []; @@ -286,11 +291,11 @@ export function createMergedAsphaltMesh( seedOpacityAttribute(merged); // Written per-street by the scrub controller; all 0 in live mode. merged.setAttribute('aRuin', new THREE.BufferAttribute(new Float32Array(vAcc), 1)); - const mat = flatGroundMaterial(STREETS.value.ASPHALT_COLOR, RENDER_ORDERS.ASPHALT); + const mat = flatGroundMaterial(streetsCfg.ASPHALT_COLOR, RENDER_ORDERS.ASPHALT); // Shared with the shader (onBeforeCompile) AND the component's tint-color // effects, which keep .value current on a Save. Stored on userData for them. const ruinUniform = { value: new THREE.Color() }; - setColorFromHex(ruinUniform.value, RUINS.value.ROAD_COLOR); + setColorFromHex(ruinUniform.value, ruinsCfg.ROAD_COLOR); injectStreetOpacity(mat, { ruin: ruinUniform }); mat.userData.uRuinColor = ruinUniform; const mesh = new THREE.Mesh(merged, mat) as FlatMesh; diff --git a/app/src/city/components/trees/index.ts b/app/src/city/scene/components/trees/index.ts similarity index 92% rename from app/src/city/components/trees/index.ts rename to app/src/city/scene/components/trees/index.ts index 854ffed0f..ef0070f99 100644 --- a/app/src/city/components/trees/index.ts +++ b/app/src/city/scene/components/trees/index.ts @@ -6,7 +6,6 @@ import * as THREE from 'three'; import { effect, untracked } from '@preact/signals'; -import { TREES } from '@/state/settings/fields/trees'; import type { BusynessThresholds, CommitEntry, RepoStats } from '@/types'; import type { FrameContext, SceneComponent, SceneContext } from '../../types'; @@ -44,12 +43,12 @@ export interface TreesComponent extends SceneComponent { } export function createTrees(ctx: SceneContext): TreesComponent { - // Persistent outer group — added to the scene once by createCity. rebuild() + // Persistent outer group — added to the scene once by createCityScene. rebuild() // swaps the inner tree renderer's group in and out of this group. const group = new THREE.Group(); group.name = 'city-trees'; - const { cityState } = ctx; + const { build, config } = ctx; let _inner: Trees | null = null; // Declared BEFORE the theme effect below — the effect body runs @@ -72,13 +71,13 @@ export function createTrees(ctx: SceneContext): TreesComponent { scannedAt?: string | null ): void { clear(); - _inner = createTreeRenderer(placements, commits, busyness, stats, scannedAt); + _inner = createTreeRenderer(placements, commits, busyness, stats, scannedAt, config.TREES); group.add(_inner.group); } - // Reads only TREES, so it is safe at construction, before the picker. + // Reads only its tree settings, so it is safe at construction, before the picker. const stopTheme = effect(() => { - void TREES.value; + void config.TREES.value; _inner?.refresh(); _outline?.refreshMaterials(); }); @@ -86,12 +85,12 @@ export function createTrees(ctx: SceneContext): TreesComponent { // The build places the trees and publishes them with the rest of the city, so // this renders a signal like every other component: nothing pops in later. const stopPlacements = effect(() => { - const placements = cityState.treePlacements.value; + const placements = build.treePlacements.value; untracked(() => { if (!placements) { clear(); } else { - const manifest = cityState.manifest.peek(); + const manifest = build.manifest.peek(); rebuild( placements, manifest?.commits ?? null, @@ -111,6 +110,8 @@ export function createTrees(ctx: SceneContext): TreesComponent { scene: ctx.scene, picker: ctx.picker!, getTrees: () => _inner, + trees: config.TREES, + rainbow: config.RAINBOW, }); return [ () => { diff --git a/app/src/city/components/trees/outline.ts b/app/src/city/scene/components/trees/outline.ts similarity index 87% rename from app/src/city/components/trees/outline.ts rename to app/src/city/scene/components/trees/outline.ts index 63cf2f5c2..d350c1de1 100644 --- a/app/src/city/components/trees/outline.ts +++ b/app/src/city/scene/components/trees/outline.ts @@ -5,16 +5,17 @@ import * as THREE from 'three'; import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; -import { SafeLineSegmentsGeometry } from '@/city/utils/safeLineSegmentsGeometry'; - -import { TREES } from '@/state/settings/fields/trees'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; -import { rainbowRgbAt } from '@/city/utils/rainbowChase'; -import { FLOATS_PER_SEGMENT } from '@/city/utils/bufferLayout'; -import { createSafeLineMaterial } from '@/city/utils/safeLineMaterial'; +import { SafeLineSegmentsGeometry } from '@/city/scene/utils/safeLineSegmentsGeometry'; + +import type { TreesConfig } from '@/city/session/settings/trees'; +import type { RainbowConfig } from '@/city/session/settings/effects'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; +import { rainbowRgbAt } from '@/city/scene/utils/rainbowChase'; +import { FLOATS_PER_SEGMENT } from '@/city/scene/constants/bufferLayout'; +import { createSafeLineMaterial } from '@/city/scene/utils/safeLineMaterial'; import { NodeKind } from '@/types'; import { buildCanopyEdges } from './treeRenderer'; -import type { PickTarget } from '@/types/picker'; +import type { PickTarget } from '@/city/scene/types/picker'; import type { ReadonlySignal } from '@preact/signals'; import { effect } from '@preact/signals'; @@ -35,10 +36,21 @@ interface CreateArgs { /** Late-bound: trees are built after this renderer is created. Returns * null when no manifest has been applied yet. */ getTrees: () => TreesHandle | null; + /** This city's tree settings: the outline follows what it is drawn around. */ + trees: ReadonlySignal; + /** Its rainbow chase, which the selected outline runs. */ + rainbow: ReadonlySignal; } -export function createTreeOutlineRenderer({ canvas, scene, picker, getTrees }: CreateArgs) { - const _cfg = TREES.value; +export function createTreeOutlineRenderer({ + canvas, + scene, + picker, + getTrees, + trees, + rainbow, +}: CreateArgs) { + const _cfg = trees.value; // One shared silhouette: every tree uses the same facet count, so there's // no per-tier geometry swap. @@ -142,7 +154,7 @@ export function createTreeOutlineRenderer({ canvas, scene, picker, getTrees }: C if (!_selColorBuf) return; // One hue per segment, rotating around the silhouette over time. for (let i = 0; i < _selSegCount; i++) { - const [r, g, b] = rainbowRgbAt(timeMs, i / _selSegCount); + const [r, g, b] = rainbowRgbAt(timeMs, i / _selSegCount, rainbow.value); const k = i * FLOATS_PER_SEGMENT; _selColorBuf[k] = r; _selColorBuf[k + 1] = g; @@ -172,7 +184,7 @@ export function createTreeOutlineRenderer({ canvas, scene, picker, getTrees }: C } function refreshMaterials(): void { - const c = TREES.value; + const c = trees.value; hoverLineMat.color.set(c.OUTLINE_HOVER_COLOR); hoverLineMat.linewidth = c.OUTLINE_WIDTH; hoverLineMat.opacity = c.OUTLINE_HOVER_OPACITY; diff --git a/app/src/city/components/trees/tree.frag.glsl b/app/src/city/scene/components/trees/tree.frag.glsl similarity index 100% rename from app/src/city/components/trees/tree.frag.glsl rename to app/src/city/scene/components/trees/tree.frag.glsl diff --git a/app/src/city/components/trees/tree.vert.glsl b/app/src/city/scene/components/trees/tree.vert.glsl similarity index 100% rename from app/src/city/components/trees/tree.vert.glsl rename to app/src/city/scene/components/trees/tree.vert.glsl diff --git a/app/src/city/components/trees/treeEncoding.ts b/app/src/city/scene/components/trees/treeEncoding.ts similarity index 71% rename from app/src/city/components/trees/treeEncoding.ts rename to app/src/city/scene/components/trees/treeEncoding.ts index 96e9295b8..178ac1159 100644 --- a/app/src/city/components/trees/treeEncoding.ts +++ b/app/src/city/scene/components/trees/treeEncoding.ts @@ -4,21 +4,13 @@ // degenerate case (no stats, no commits, no spread) collapses to the midpoint. import type { CommitEntry, BusynessThresholds, RepoStats } from '@/types'; -import type { TreesConfig } from '@/state/settings/fields/trees'; -import { recencyT } from '@/city/utils/recency'; +import type { TreesConfig } from '@/city/session/settings/trees'; +import { recencyT } from '@/city/scene/utils/recency'; import { epochDay } from '@/utils/dates'; -export interface AgeRange { - /** Epoch days of the oldest commit. */ - oldest: number; - /** Epoch days of the newest commit. */ - newest: number; - /** newest - oldest. 0 when there is no meaningful range. */ - span: number; - /** What "now" means to every commit. Not Date.now(): a live clock would - * drift tree heights and break the goldens. */ - scanned: number; -} +/** The moment every commit is aged against, in epoch days. Not Date.now(): a + * live clock would drift tree heights and break the goldens. */ +export type AgeMoment = number; export interface SizeRange { min: number; @@ -53,20 +45,9 @@ function clamp01(t: number): number { return t; } -/** The commit-date range and the scan date measured against it. Omitting the - * scan date treats the newest commit as now, so it can't look abandoned. */ -export function computeAgeRange( - stats: RepoStats | null | undefined, - scannedAt?: string | null -): AgeRange { - const cd = stats?.commitDates; - if (!cd || cd.oldest === null || cd.newest === null) { - return { oldest: 0, newest: 0, span: 0, scanned: 0 }; - } - const oldest = dateToDays(cd.oldest); - const newest = dateToDays(cd.newest); - const scanned = scannedAt == null ? newest : Math.max(newest, dateToDays(scannedAt)); - return { oldest, newest, span: newest - oldest, scanned }; +/** When the repo was read, as the day trees age against. */ +export function ageMoment(scannedAt?: string | null): AgeMoment { + return scannedAt == null ? 0 : dateToDays(scannedAt); } /** The files-changed range, from the backend's leaders. Zeroes with no stats, @@ -77,12 +58,6 @@ export function computeSizeRange(stats: RepoStats | null | undefined): SizeRange return { min, max, span: max - min }; } -export function ageT(commit: CommitEntry, range: AgeRange): number { - if (range.span <= 0) return 0.5; - const d = dateToDays(commit.date); - return clamp01((d - range.oldest) / range.span); -} - export function sizeT(commit: CommitEntry, range: SizeRange): number { if (range.span <= 0) return 0.5; return clamp01((commit.files - range.min) / range.span); @@ -116,31 +91,27 @@ export function dailyCountTByIndex( * uses. Shared with the firefly orbits, which would otherwise drift. */ export function treeHeight( commit: CommitEntry | null | undefined, - ageRange: AgeRange, + now: AgeMoment, cfg: TreesConfig ): number { const minHeight = cfg.MIN_HEIGHT; const maxHeight = cfg.MAX_HEIGHT; if (!commit) return (minHeight + maxHeight) * 0.5; - const maturity = 1 - commitRecency(commit, ageRange, cfg); + const maturity = 1 - commitRecency(commit, now, cfg); return minHeight + maturity * (maxHeight - minHeight); } /** Epoch days here (the scanner emits YYYY-MM-DD), converted on the way into * the shared scale. */ -function commitRecency(commit: CommitEntry, ageRange: AgeRange, cfg: TreesConfig): number { - return recencyT( - dateToDays(commit.date) * MS_PER_DAY, - ageRange.scanned * MS_PER_DAY, - cfg.HALF_LIFE_DAYS - ); +function commitRecency(commit: CommitEntry, now: AgeMoment, cfg: TreesConfig): number { + return recencyT(dateToDays(commit.date) * MS_PER_DAY, now * MS_PER_DAY, cfg.HALF_LIFE_DAYS); } /** A tree's radius, from its commit's file count, attenuated by age so a young * tree isn't adult-wide. Shared with the firefly orbit radius. */ export function treeRadius( commit: CommitEntry | null | undefined, - ageRange: AgeRange, + now: AgeMoment, sizeRange: SizeRange, cfg: TreesConfig ): number { @@ -158,7 +129,7 @@ export function treeRadius( } // Clamp height range to avoid divide-by-zero when min == max. const heightRange = Math.max(0.001, maxHeight - minHeight); - const heightRatio = (treeHeight(commit, ageRange, cfg) - minHeight) / heightRange; + const heightRatio = (treeHeight(commit, now, cfg) - minHeight) / heightRange; const floor = Math.max(0, Math.min(1, cfg.WIDTH_AGE_FLOOR)); const ageAttenuation = floor + (1 - floor) * heightRatio; return baseRadius * ageAttenuation; diff --git a/app/src/city/components/trees/treePlacement.ts b/app/src/city/scene/components/trees/treePlacement.ts similarity index 90% rename from app/src/city/components/trees/treePlacement.ts rename to app/src/city/scene/components/trees/treePlacement.ts index 948d3199c..a950030f3 100644 --- a/app/src/city/components/trees/treePlacement.ts +++ b/app/src/city/scene/components/trees/treePlacement.ts @@ -5,16 +5,16 @@ import RBush from 'rbush'; import * as THREE from 'three'; -import { TREES } from '@/state/settings/fields/trees'; -import { FOOTPRINT } from '@/state/settings/fields/footprint'; -import { ISLAND } from '@/state/settings/fields/island'; + import { getWorldBounds } from '../../utils/floorBounds'; import { buildTopPolygon, pointInIslandPolygon } from '../island/islandGeometry'; import { islandSeedFromBounds } from '../island'; -import { StreetAxis } from '@/types'; +import { StreetAxis } from '@/city/scene/types'; import { gemAnchorXZ } from '../gem/anchor'; -import type { Building, CityBbox, Street } from '@/types'; -import type { IslandConfig } from '@/state/settings/fields/island'; +import type { Building, CityBbox, Street } from '@/city/scene/types'; +import type { IslandConfig, WorldConfig } from '@/city/session/settings/island'; +import type { TreesConfig } from '@/city/session/settings/trees'; +import type { FootprintConfig } from '@/city/session/settings/footprint'; interface Rect { minX: number; @@ -39,14 +39,24 @@ export interface LayoutGeometry { bbox?: CityBbox; } +/** What the forest is grown from: this city's values, passed in because this + * runs in a worker as often as on the main thread. */ +export interface TreePlacementConfig { + trees: TreesConfig; + footprint: FootprintConfig; + /** The island's shape: the extent trees are sampled over, and the polygon + * they are culled to. Disabled, the ground is a rect and nothing culls. */ + island: IslandConfig; + /** The ground buffer the forest is sampled across. */ + world: WorldConfig; +} + export interface PlaceTreesOptions { /** Number of trees to plant — one per commit. */ commitCount: number; /** Scene height, so a small but tall city gets a buffer to match. */ cityHeight?: number; - /** The worker's copy of the island config, since it can't read the store. - * Null disables the polygon rejection pass. */ - islandGeoOverride?: IslandConfig | null; + config: TreePlacementConfig; } /** Ceiling on grid resolution, so a multi-million-commit repo can't OOM the @@ -111,10 +121,10 @@ function gemCenterFromLayout(layout: LayoutGeometry, bbox: CityBbox): { x: numbe export function placeTrees( layout: LayoutGeometry, - bboxOverride?: CityBbox, - options: PlaceTreesOptions = { commitCount: 0 } + bboxOverride: CityBbox | undefined, + options: PlaceTreesOptions ): TreePlacement[] { - const cfg = TREES.value; + const cfg = options.config.trees; if (!cfg.ENABLED) return []; const bbox = bboxOverride ?? layout.bbox; @@ -123,7 +133,7 @@ export function placeTrees( const treeTarget = Math.max(0, options.commitCount | 0); if (treeTarget === 0) return []; - const footprint = FOOTPRINT.value; + const footprint = options.config.footprint; const halo = footprint.ENABLED ? Math.max(0, footprint.HALO_WIDTH) : 0; // Build rbush of every layout rect, inflated by the halo. @@ -140,12 +150,12 @@ export function placeTrees( if (rects.length > 0) rtree.load(rects); const hasRects = rects.length > 0; - const bounds = getWorldBounds(bbox, options.cityHeight ?? 0); + const bounds = getWorldBounds(bbox, options.config.world, options.cityHeight ?? 0); const center = gemCenterFromLayout(layout, bbox); // Sampled to the island polygon's extent, not the rect it circumscribes: the // ears past the rect corners would otherwise read as bare ground. - const sides = options.islandGeoOverride?.SIDES ?? ISLAND.value.SIDES; + const sides = options.config.island.SIDES; // Jitter only shrinks vertices inward, so baseScale bounds the extent. const polygonScale = Math.SQRT2 / Math.cos(Math.PI / sides); const sampleHalfW = bounds.halfWidth * polygonScale; @@ -180,8 +190,8 @@ export function placeTrees( // The same polygon islandMesh renders, pulled inward by the inset so the // clearance from the edge reads as uniform however irregular it is. let islandPolygon: THREE.Vector3[] | null = null; - if (options.islandGeoOverride !== null) { - const islandGeo = options.islandGeoOverride ?? ISLAND.value; + { + const islandGeo = options.config.island; if (islandGeo.ENABLED) { const rawPolygon = buildTopPolygon({ sides: islandGeo.SIDES, diff --git a/app/src/city/components/trees/treePlacementClient.ts b/app/src/city/scene/components/trees/treePlacementClient.ts similarity index 81% rename from app/src/city/components/trees/treePlacementClient.ts rename to app/src/city/scene/components/trees/treePlacementClient.ts index 358641791..9f1ee03db 100644 --- a/app/src/city/components/trees/treePlacementClient.ts +++ b/app/src/city/scene/components/trees/treePlacementClient.ts @@ -3,10 +3,11 @@ // arrives, and falls back to a synchronous call where Worker is unavailable. import { placeTrees, type TreePlacement, type LayoutGeometry } from './treePlacement'; import { MSG } from './treePlacementProtocol'; -import { TREES, type TreesConfig } from '@/state/settings/fields/trees'; -import { FOOTPRINT, type FootprintConfig } from '@/state/settings/fields/footprint'; -import { ISLAND, WORLD, type IslandConfig, type WorldConfig } from '@/state/settings/fields/island'; -import type { CityBbox, CityLayout } from '@/types'; +import type { TreesConfig } from '@/city/session/settings/trees'; +import type { FootprintConfig } from '@/city/session/settings/footprint'; +import type { IslandConfig, WorldConfig } from '@/city/session/settings/island'; +import type { CityConfig } from '@/city/session/config'; +import type { CityBbox, CityLayout } from '@/city/scene/types'; interface PendingRequest { resolve(placements: TreePlacement[]): void; @@ -47,16 +48,15 @@ function _slimLayout(layout: CityLayout): LayoutGeometry { }; } -function _snapshot(): ConfigSnapshot { - return { - trees: TREES.value, - footprint: FOOTPRINT.value, - islandGeo: ISLAND.value, - world: WORLD.value, - }; -} +export function createTreePlacementClient(config: CityConfig): TreePlacementClient { + // Read per request, not once: a Save changes what the next forest grows from. + const snapshot = (): ConfigSnapshot => ({ + trees: config.TREES.value, + footprint: config.FOOTPRINT.value, + islandGeo: config.ISLAND.value, + world: config.WORLD.value, + }); -export function createTreePlacementClient(): TreePlacementClient { const pending = new Map(); let nextId = 1; let disposed = false; @@ -114,7 +114,17 @@ export function createTreePlacementClient(): TreePlacementClient { reject: PendingRequest['reject'] ): void { try { - const placements = placeTrees(layout, bbox, { commitCount, cityHeight }); + const snap = snapshot(); + const placements = placeTrees(layout, bbox, { + commitCount, + cityHeight, + config: { + trees: snap.trees, + footprint: snap.footprint, + island: snap.islandGeo, + world: snap.world, + }, + }); queueMicrotask(() => { if (!pending.has(id)) return; pending.delete(id); @@ -154,7 +164,7 @@ export function createTreePlacementClient(): TreePlacementClient { bbox, commitCount, cityHeight, - configSnapshot: _snapshot(), + configSnapshot: snapshot(), }); }); } diff --git a/app/src/city/components/trees/treePlacementProtocol.ts b/app/src/city/scene/components/trees/treePlacementProtocol.ts similarity index 100% rename from app/src/city/components/trees/treePlacementProtocol.ts rename to app/src/city/scene/components/trees/treePlacementProtocol.ts diff --git a/app/src/city/components/trees/treePlacementWorker.ts b/app/src/city/scene/components/trees/treePlacementWorker.ts similarity index 73% rename from app/src/city/components/trees/treePlacementWorker.ts rename to app/src/city/scene/components/trees/treePlacementWorker.ts index c23dd5066..6581af1db 100644 --- a/app/src/city/components/trees/treePlacementWorker.ts +++ b/app/src/city/scene/components/trees/treePlacementWorker.ts @@ -2,11 +2,11 @@ // placement: a slim layout plus a config snapshot in, TreePlacement[] out. // Pure compute, no DOM and no three.js. import { placeTrees, type TreePlacement, type LayoutGeometry } from './treePlacement'; -import { TREES, type TreesConfig } from '@/state/settings/fields/trees'; -import { FOOTPRINT, type FootprintConfig } from '@/state/settings/fields/footprint'; -import { WORLD, type WorldConfig } from '@/state/settings/fields/island'; -import type { IslandConfig } from '@/state/settings/fields/island'; -import type { CityBbox } from '@/types'; +import type { TreesConfig } from '@/city/session/settings/trees'; +import type { FootprintConfig } from '@/city/session/settings/footprint'; +import type { WorldConfig } from '@/city/session/settings/island'; +import type { IslandConfig } from '@/city/session/settings/island'; +import type { CityBbox } from '@/city/scene/types'; type TreesValue = TreesConfig; type FootprintValue = FootprintConfig; @@ -37,21 +37,19 @@ type PlaceResponse = | { type: typeof MSG.RESPONSE_OK; id: number; placements: TreePlacement[] } | { type: typeof MSG.RESPONSE_ERROR; id: number; message: string }; -function _applySnapshot(snap: PlaceRequest['configSnapshot']): void { - TREES.value = { ...TREES.value, ...snap.trees }; - FOOTPRINT.value = { ...FOOTPRINT.value, ...snap.footprint }; - WORLD.value = { ...WORLD.value, ...snap.world }; -} - self.addEventListener('message', (event: MessageEvent) => { const data = event.data; if (!data || data.type !== MSG.REQUEST) return; try { - _applySnapshot(data.configSnapshot); const placements = placeTrees(data.layout, data.bbox, { commitCount: data.commitCount, cityHeight: data.cityHeight, - islandGeoOverride: data.configSnapshot.islandGeo, + config: { + trees: data.configSnapshot.trees, + footprint: data.configSnapshot.footprint, + island: data.configSnapshot.islandGeo, + world: data.configSnapshot.world, + }, }); const reply: PlaceResponse = { type: MSG.RESPONSE_OK, diff --git a/app/src/city/components/trees/treeRenderer.ts b/app/src/city/scene/components/trees/treeRenderer.ts similarity index 94% rename from app/src/city/components/trees/treeRenderer.ts rename to app/src/city/scene/components/trees/treeRenderer.ts index 649cab5f8..0ea04502f 100644 --- a/app/src/city/components/trees/treeRenderer.ts +++ b/app/src/city/scene/components/trees/treeRenderer.ts @@ -4,29 +4,33 @@ // tree owns a contiguous vertex range, so picking maps faceIndex → placement. import * as THREE from 'three'; -import { TREES } from '@/state/settings/fields/trees'; -import { RENDER_ORDERS } from '@/city/types/renderOrders'; +import type { TreesConfig } from '@/city/session/settings/trees'; +import type { ReadonlySignal } from '@preact/signals'; +import { RENDER_ORDERS } from '@/city/scene/constants/renderOrders'; import type { TreePlacement } from './treePlacement'; import type { CommitEntry, BusynessThresholds, RepoStats } from '@/types'; import { - computeAgeRange, + ageMoment, computeSizeRange, dailyCountTByIndex, treeHeight, treeRadius, - type AgeRange, + type AgeMoment, type SizeRange, } from './treeEncoding'; -import { interpolateOklch } from '@/city/utils/color/colors'; -import { setColorFromHex } from '@/city/utils/color/setColorFromHex'; +import { interpolateOklch } from '@/city/scene/utils/color/colors'; +import { setColorFromHex } from '@/city/scene/utils/color/setColorFromHex'; -import { TREES_PER_CHUNK } from '@/city/utils/instanceChunkSize'; -import { BYTE_MAX, VEC3_COMPONENTS, VERTS_PER_TRIANGLE } from '@/city/utils/bufferLayout'; -import { NEUTRAL_POLYGON_OFFSET } from '@/city/utils/neutralPolygonOffset'; +import { TREES_PER_CHUNK } from '@/city/scene/constants/chunkSize'; +import { BYTE_MAX, VEC3_COMPONENTS, VERTS_PER_TRIANGLE } from '@/city/scene/constants/bufferLayout'; +import { NEUTRAL_POLYGON_OFFSET } from '@/city/scene/constants/polygonOffset'; import treeVertSrc from './tree.vert.glsl?raw'; import treeFragSrc from './tree.frag.glsl?raw'; -import { sunDir } from '@/city/utils/shaders/sunDir'; -import { LIGHTING_SUN_AZIMUTH_DEG, LIGHTING_SUN_ELEVATION_DEG } from '@/city/constants/lighting'; +import { sunDir } from '@/city/scene/utils/shaders/sunDir'; +import { + LIGHTING_SUN_AZIMUTH_DEG, + LIGHTING_SUN_ELEVATION_DEG, +} from '@/city/scene/constants/lighting'; import { epochDay, epochDayAt } from '@/utils/dates'; export interface Trees { @@ -161,9 +165,10 @@ export function createTreeRenderer( commits: CommitEntry[] | null, busyness: BusynessThresholds, stats: RepoStats | null | undefined, - scannedAt?: string | null + scannedAt: string | null | undefined, + trees: ReadonlySignal ): Trees { - let cfg = TREES.value; + let cfg = trees.value; // Heights/widths come from treeEncoding — shared with the firefly field. const trunkHeightFrac = cfg.TRUNK_HEIGHT_FRAC; @@ -174,7 +179,7 @@ export function createTreeRenderer( // Ranges come from backend-precomputed stats, not a client-side scan; // scannedAt drives the absolute-age staleness lift on height. - const ageRange: AgeRange = computeAgeRange(stats, scannedAt); + const scanDay: AgeMoment = ageMoment(scannedAt); const sizeRange: SizeRange = computeSizeRange(stats); /** Resolve the commit a placement points at, or null when the index is @@ -189,11 +194,11 @@ export function createTreeRenderer( // HEIGHT follows AGE, WIDTH follows FILES (age-attenuated); formulas live // in treeEncoding so firefly orbits derive from identical math. function perTreeHeight(i: number): number { - return treeHeight(commitForPlacement(i), ageRange, cfg); + return treeHeight(commitForPlacement(i), scanDay, cfg); } function perTreeRadius(i: number): number { - return treeRadius(commitForPlacement(i), ageRange, sizeRange, cfg); + return treeRadius(commitForPlacement(i), scanDay, sizeRange, cfg); } // The day the scrub sits on, or null in Live. The shader regrows what it @@ -203,7 +208,7 @@ export function createTreeRenderer( function sizeAt(i: number): { height: number; radius: number } { if (_scrubDay === null) return { height: heights[i], radius: radii[i] }; const commit = commitForPlacement(i); - const scrubbed: AgeRange = { ...ageRange, scanned: _scrubDay }; + const scrubbed: AgeMoment = _scrubDay; return { height: treeHeight(commit, scrubbed, cfg), radius: treeRadius(commit, scrubbed, sizeRange, cfg), @@ -410,7 +415,7 @@ export function createTreeRenderer( mergedMaterial.uniforms.uWidthAgeFloor = { value: Math.max(0, Math.min(1, cfg.WIDTH_AGE_FLOOR)) }; // The day the heights were baked against: Live leaves the geometry untouched, // Timeline moves it to the scrubbed day. - mergedMaterial.uniforms.uNowDay = { value: ageRange.scanned }; + mergedMaterial.uniforms.uNowDay = { value: scanDay }; const group = new THREE.Group(); group.name = 'trees'; @@ -419,7 +424,7 @@ export function createTreeRenderer( for (const m of mergedMeshes) group.add(m); function refresh(): void { - cfg = TREES.value; + cfg = trees.value; group.visible = cfg.ENABLED; for (const m of mergedMeshes) m.visible = cfg.ENABLED; @@ -529,7 +534,7 @@ export function createTreeRenderer( /** The day the scrub sits on, so every tree is the size it was then. Null * (Live) restores the scan date, where the ratio is 1. */ function setScrubNow(nowMs: number | null): void { - const day = nowMs === null ? ageRange.scanned : epochDayAt(nowMs); + const day = nowMs === null ? scanDay : epochDayAt(nowMs); if (day === mergedMaterial.uniforms.uNowDay.value) return; _scrubDay = nowMs === null ? null : day; mergedMaterial.uniforms.uNowDay.value = day; diff --git a/app/src/city/utils/bufferLayout.ts b/app/src/city/scene/constants/bufferLayout.ts similarity index 100% rename from app/src/city/utils/bufferLayout.ts rename to app/src/city/scene/constants/bufferLayout.ts diff --git a/app/src/constants/buildings.ts b/app/src/city/scene/constants/buildings.ts similarity index 100% rename from app/src/constants/buildings.ts rename to app/src/city/scene/constants/buildings.ts diff --git a/app/src/city/constants/camera.ts b/app/src/city/scene/constants/camera.ts similarity index 100% rename from app/src/city/constants/camera.ts rename to app/src/city/scene/constants/camera.ts diff --git a/app/src/city/utils/instanceChunkSize.ts b/app/src/city/scene/constants/chunkSize.ts similarity index 100% rename from app/src/city/utils/instanceChunkSize.ts rename to app/src/city/scene/constants/chunkSize.ts diff --git a/app/src/constants/gem.ts b/app/src/city/scene/constants/gem.ts similarity index 100% rename from app/src/constants/gem.ts rename to app/src/city/scene/constants/gem.ts diff --git a/app/src/city/constants/lighting.ts b/app/src/city/scene/constants/lighting.ts similarity index 100% rename from app/src/city/constants/lighting.ts rename to app/src/city/scene/constants/lighting.ts diff --git a/app/src/city/utils/neutralPolygonOffset.ts b/app/src/city/scene/constants/polygonOffset.ts similarity index 100% rename from app/src/city/utils/neutralPolygonOffset.ts rename to app/src/city/scene/constants/polygonOffset.ts diff --git a/app/src/city/types/renderOrders.ts b/app/src/city/scene/constants/renderOrders.ts similarity index 100% rename from app/src/city/types/renderOrders.ts rename to app/src/city/scene/constants/renderOrders.ts diff --git a/app/src/city/constants/streets.ts b/app/src/city/scene/constants/streets.ts similarity index 100% rename from app/src/city/constants/streets.ts rename to app/src/city/scene/constants/streets.ts diff --git a/app/src/city/capture/captureHarness.ts b/app/src/city/scene/debug/capture/captureHarness.ts similarity index 83% rename from app/src/city/capture/captureHarness.ts rename to app/src/city/scene/debug/capture/captureHarness.ts index 6ecac81e4..325e1d5d6 100644 --- a/app/src/city/capture/captureHarness.ts +++ b/app/src/city/scene/debug/capture/captureHarness.ts @@ -6,9 +6,8 @@ import { effect } from '@preact/signals'; import { isDebugMode } from '@/utils/debugMode'; -import { SCENE_HANDLE } from '@/city/sceneHandle'; -import { MANIFEST } from '@/state/stores/manifest'; -import { REBUILD_STATUS, RebuildStatus } from '@/state/stores/progress'; +import { RebuildStatus } from '@/city/session/stores/progress'; +import type { CitySession } from '@/city/session/session'; import type { Manifest } from '@/types'; import { SHOTS, type ShotOverrides } from './shots'; @@ -20,7 +19,7 @@ const SETTLE_MS = 2200; const POSE_RETRY_MS = 400; const MAX_POSE_ATTEMPTS = 120; -export function initCaptureHarness(): void { +export function initCaptureHarness(session: CitySession): void { const params = new URLSearchParams(window.location.search); const shot = params.get('shot'); if (!shot || !isDebugMode()) return; @@ -46,9 +45,10 @@ export function initCaptureHarness(): void { let posed = false; const stop = effect(() => { - const handle = SCENE_HANDLE.value; - const manifest = MANIFEST.value as Manifest; - if (posed || !handle || !manifest || REBUILD_STATUS.value !== RebuildStatus.Idle) return; + const handle = session.scene.value; + const manifest = session.manifest.current.value as Manifest; + if (posed || !handle || !manifest) return; + if (session.progress.rebuildStatus.value !== RebuildStatus.Idle) return; // A skeleton also reaches Idle, and framing on its root-street bbox locks // onto a close-up. Reading anchors subscribes to bbox, so this re-fires. if (handle.rig.captureAnchors().tallestHeight <= 0) return; @@ -63,7 +63,8 @@ export function initCaptureHarness(): void { const tryPose = () => { let ready = true; try { - ready = pose(h, MANIFEST.peek() as Manifest, overrides) !== false; + ready = + pose(h, session.manifest.current.peek() as Manifest, overrides, session) !== false; } catch (err) { console.error(`[capture] shot "${shot}" pose failed`, err); } diff --git a/app/src/city/capture/shots.ts b/app/src/city/scene/debug/capture/shots.ts similarity index 86% rename from app/src/city/capture/shots.ts rename to app/src/city/scene/debug/capture/shots.ts index e207bc193..7ecc6b445 100644 --- a/app/src/city/capture/shots.ts +++ b/app/src/city/scene/debug/capture/shots.ts @@ -3,17 +3,20 @@ // grows, and every pose reads ?elev=&az=&dist= overrides so a shot can be dialled // in live before the numbers are baked in. Debug-only. -import type { SceneHandle } from '@/city/sceneHandle'; +import type { CityScene } from '@/city/scene/types'; import { NodeKind, type Manifest, type DirNode } from '@/types'; -import { CAMERA } from '@/state/settings/fields/camera'; -import { CameraMode } from '@/city/render/cameraRig'; -import { TIMELINE_MODE, SCRUB_MAX, TIMELINE_BUNDLE, setScrubPos } from '@/state/stores/timeline'; -import { loadTimelineScene } from '@/hooks/useTimelineMode'; +import { CAMERA } from '@/city/session/settings/camera'; +import { CameraMode } from '@/city/scene/render/cameraRig'; +import type { CitySession } from '@/city/session/session'; -/** Set the default-view angle (degrees); the rig re-frames the whole city to - * it. Elevation is height above the horizon, azimuth the swing around the gem. */ -function angle(elevation: number, azimuth: number): void { - CAMERA.value = { ...CAMERA.value, ELEVATION: elevation, AZIMUTH: azimuth }; +/** This city's default-view angle in degrees, which the rig re-frames to. The + * panel's own values are left alone: a shot poses one city, not the app. */ +function angle(session: CitySession, elevation: number, azimuth: number): void { + session.config.override(CAMERA, { + ...session.config.CAMERA.peek(), + ELEVATION: elevation, + AZIMUTH: azimuth, + }); } /** Live overrides from ?elev=&az=&dist=, undefined when absent/non-numeric. */ @@ -26,14 +29,16 @@ export interface ShotOverrides { /** Pose the camera for one shot. false when its target isn't ready yet, which * is the harness's cue to retry. */ export type ShotPose = ( - handle: SceneHandle, + handle: CityScene, manifest: Manifest, - o: ShotOverrides + o: ShotOverrides, + /** The project being shot: its history, for a shot that scrubs. */ + session: CitySession ) => boolean | void; /** A placed tree's bounds. A named commit often has no tree, so this walks * commits most-authors-first: the one it lands on carries the most orbs. */ -function placedTree(handle: SceneHandle, manifest: Manifest) { +function placedTree(handle: CityScene, manifest: Manifest) { const byAuthors = [...manifest.commits].sort((x, y) => y.authors.length - x.authors.length); for (const c of byAuthors) { const tree = handle.rig.treeAnchor(c.sha); @@ -93,8 +98,8 @@ export const SHOTS: Record = { }); }, // Whole-city framing: the rig fits the entire city to the chosen angle. - overview: (handle, _m, o) => { - angle(o.elev ?? 46, o.az ?? 34); + overview: (handle, _m, o, session) => { + angle(session, o.elev ?? 46, o.az ?? 34); handle.rig.reset(); }, @@ -109,18 +114,19 @@ export const SHOTS: Record = { // The city part-built, at the defaults. The load is async, so this returns // false until the mode and bundle are live and the harness retries. - timeline: (handle, _m, o) => { - if (!TIMELINE_MODE.peek()) { + timeline: (handle, _m, o, session) => { + const timeline = session.timeline; + if (!timeline.mode.peek()) { if (!_timelineKickedOff) { _timelineKickedOff = true; - void loadTimelineScene(); + void session.timelineMode.loadScene(); } return false; } - const bundle = TIMELINE_BUNDLE.peek(); + const bundle = timeline.bundle.peek(); if (!bundle || bundle.commits.length === 0) return false; - setScrubPos(Math.floor(SCRUB_MAX.peek() * 0.5)); - angle(o.elev ?? 44, o.az ?? 32); + timeline.setScrubPos(Math.floor(timeline.scrubMax.peek() * 0.5)); + angle(session, o.elev ?? 44, o.az ?? 32); handle.rig.reset(); }, diff --git a/app/src/city/diagnostics.ts b/app/src/city/scene/debug/diagnostics.ts similarity index 90% rename from app/src/city/diagnostics.ts rename to app/src/city/scene/debug/diagnostics.ts index 8cd9a7796..4ab241ee9 100644 --- a/app/src/city/diagnostics.ts +++ b/app/src/city/scene/debug/diagnostics.ts @@ -1,18 +1,19 @@ // city/diagnostics.ts — the console diagnostics and the formatters they print -// through. The entry points read cityState; the formatters read nothing but +// through. The entry points read build; the formatters read nothing but // their arguments, so they test in isolation. import * as THREE from 'three'; -import { layoutCityWithTrace } from './layout/algorithm'; -import { findLayoutOverlaps, LayoutOverlapCategory } from './layout/overlaps'; -import type { LayoutOverlap } from './layout/overlaps'; -import type { ChildPlacementTrace, StemPlacementTrace } from './layout/stemSolver'; -import type { WorldRect } from './layout/occupancyIndex'; -import type { CityState } from './state'; +import { layoutCityWithTrace } from '@/city/scene/layout/algorithm'; +import type { LayoutConfig } from '@/city/scene/layout/protocol'; +import { findLayoutOverlaps, LayoutOverlapCategory } from '@/city/scene/layout/overlaps'; +import type { LayoutOverlap } from '@/city/scene/layout/overlaps'; +import type { ChildPlacementTrace, StemPlacementTrace } from '@/city/scene/layout/stemSolver'; +import type { WorldRect } from '@/city/scene/layout/occupancyIndex'; +import type { CityBuild } from '@/city/scene/build'; // Unexpected overlaps warn with a line each; a clean run logs a summary. -export function runCollisionCheck(cityState: CityState): void { - const layout = cityState.layout.value; +export function runCollisionCheck(build: CityBuild): void { + const layout = build.layout.value; if (!layout) { console.warn('[collision] no layout — apply a manifest first'); return; @@ -32,13 +33,16 @@ export function runCollisionCheck(cityState: CityState): void { // Re-run layout with tracing on the current manifest and print the stem- // placement trace. No-ops with a warning when no manifest has been applied yet. -export function runStemPlacementDiagnostic(cityState: CityState): void { - const m = cityState.manifest.value; +export function runStemPlacementDiagnostic(build: CityBuild, cfg: LayoutConfig): void { + const m = build.manifest.value; if (!m) { console.warn('[stem-diag] no manifest — apply one first'); return; } - const { trace } = layoutCityWithTrace(m as unknown as Parameters[0]); + const { trace } = layoutCityWithTrace( + m as unknown as Parameters[0], + cfg + ); for (const line of _formatStemDiagnostic(trace)) { console.log(line); } diff --git a/app/src/city/index.ts b/app/src/city/scene/index.ts similarity index 66% rename from app/src/city/index.ts rename to app/src/city/scene/index.ts index 944b00f01..f6da14003 100644 --- a/app/src/city/index.ts +++ b/app/src/city/scene/index.ts @@ -1,55 +1,53 @@ -// city/index.ts — the city composer. createCity(canvas, manifest) folds the +// city/index.ts — the city composer. createCityScene(canvas, manifest) folds the // scene and component construction and the rendering pipeline into one async // factory, then drives the frame loop via startFrameLoop. Returns the handle // App.tsx / the City component consume. import * as THREE from 'three'; -import { effect, untracked } from '@preact/signals'; +import { effect, signal, untracked } from '@preact/signals'; -import type { Manifest, RangeStat } from '@/types'; -import { CURRENT_SOURCE_KEY } from '@/state/stores/source'; -import { MANIFEST } from '@/state/stores/manifest'; -import { markIdle } from '@/state/stores/progress'; -import { TIMELINE_MODE, SCRUB_DRAGGING, SCRUB_POS } from '@/state/stores/timeline'; +import type { RangeStat } from '@/types'; +import { until } from '@/city/scene/utils/until'; import { registerShaderChunks } from './utils/shaders/registerShaderChunks'; import { createBuildings } from './components/buildings'; import { makeHeightContext } from './layout/dimensions'; -import { createScrubController } from './timeline/scrubController'; -import type { PathTimeline } from './timeline/replay'; +import { createScrubController } from './scrub/scrubController'; +import type { PathTimeline } from './scrub/replay'; import { createLayoutClient } from './layout'; import { createTreePlacementClient } from './components/trees/treePlacementClient'; -import { createCityState } from './state'; +import { createCityBuild } from './build'; import { runCollisionCheck, runStemPlacementDiagnostic, runTreeGroundingDiagnostic, -} from './diagnostics'; +} from './debug/diagnostics'; import { createGem } from './components/gem'; import { createSky } from './components/sky'; import { createIsland, ISLAND_TOP_Y } from './components/island'; import { createRepoLabel } from './components/repoLabel'; import { repoLabelBounds } from './components/repoLabel/bounds'; -import { REPO_LABEL } from '@/state/settings/fields/gem'; -import { BUILDING_DIMENSIONS } from '@/state/settings/fields/buildings'; import { createFootprint } from './components/footprint'; import { createStreets } from './components/streets'; import { createTrees } from './components/trees'; import { createFireflies } from './components/fireflies'; import { createPathLine } from './components/pathLine'; -import type { City, SceneComponent, SceneContext } from './types'; +import type { CityScene, SceneComponent, SceneContext } from './types'; +import type { CitySession } from '@/city/session/session'; +import type { Manifest } from '@/types'; import { createCameraRig, CameraMode } from './render/cameraRig'; import { createPicker } from './interaction/picker'; import { createInputHandlers } from './interaction/inputHandlers'; -import { showTooltip, hideTooltip } from './interaction/tooltip'; +import { showTooltip, hideTooltip } from './interaction/tooltip/tooltip'; import { createPostFx } from './render/postFx'; import { startFrameLoop } from './render/frameLoop'; import { registerRenderer as registerFacadePanelRenderer } from './components/buildings/facadePanelTextureArray'; +import { BuildingMaterial } from './components/buildings/material'; -export async function createCity( +export async function createCityScene( canvas: HTMLCanvasElement, - { cameraMode = CameraMode.Project }: { cameraMode?: CameraMode } = {} -): Promise { + session: CitySession +): Promise { // Must precede any ShaderMaterial so #include directives resolve. registerShaderChunks(); @@ -69,21 +67,33 @@ export async function createCity( renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); registerFacadePanelRenderer(renderer); - const layoutClient = createLayoutClient(); + const layoutClient = createLayoutClient(session.config); // Both off-thread build workers, owned here and handed to the store that runs // the build. Lazy: neither spawns until its first compute(). - const treePlacementClient = createTreePlacementClient(); - const cityState = createCityState(layoutClient, treePlacementClient); - // Pulled off cityState for the City handle; components never wire into - // these — they rebuild reactively off cityState's signals. - const { applyManifest, buildStagesFor, invalidateLayoutCache } = cityState; + const treePlacementClient = createTreePlacementClient(session.config); + // Before the scene state, which pushes the icon atlas it builds into it. + const buildingMaterial = new BuildingMaterial(session.config); + const build = createCityBuild( + layoutClient, + treePlacementClient, + session.progress, + buildingMaterial, + session.config + ); + // Pulled off build for the City handle; components never wire into + // these — they rebuild reactively off build's signals. + const { applyManifest, buildStagesFor, invalidateLayoutCache } = build; // picker is backfilled below (built after the components it reads); // armOnFirstTick defers picker-dependent setup, so the null cast is safe. const ctx = { scene, canvas, - cityState, + build, + timeline: session.timeline, + config: session.config, + buildingMaterial, + chrome: session.chrome, picker: null, } as unknown as SceneContext; @@ -118,60 +128,77 @@ export async function createCity( // No boot apply: a scene with no manifest is a real state, so the components // start empty and the first applyManifest is the first city there has been. - // The rig reads its framing inputs from cityState directly; deps carries + // The rig reads its framing inputs from build directly; deps carries // only component-geometry accessors state can't reach. const rig = createCameraRig({ canvas, - cityState, - mode: cameraMode, + build, + mode: session.cameraMode ?? CameraMode.Project, + config: session.config, deps: { // From the manifest + settings, never the label's meshes: those land on // the first tick, and framing that waits frames a different city (#62). getRepoLabelBounds: () => repoLabelBounds( - cityState.manifest.peek()?.tree?.name, - cityState.gemWorldPos.peek(), - REPO_LABEL.peek(), - BUILDING_DIMENSIONS.peek() + build.manifest.peek()?.tree?.name, + build.gemWorldPos.peek(), + session.config.REPO_LABEL.peek(), + session.config.BUILDING_DIMENSIONS.peek() ), getTreeBoundsBySha: (sha) => trees.getRenderer()?.getTreeBoundsBySha(sha) ?? null, }, }); - // Reframe only on a real source change. cityRevision bumps after the apply's + // Reframe only on a real subject change. cityRevision bumps after the apply's // batch flushed, so the camera is aimed at a built city, not half of one. - let lastReframedSourceKey: string | null = null; + let framedSubject: string | null = null; const stopReframe = effect(() => { - void cityState.cityRevision.value; - const key = CURRENT_SOURCE_KEY.peek(); - if (key === null || key === lastReframedSourceKey) return; - // No city yet: claiming the key here would make the first real one, which - // is what there is to frame, skip its reframe. - if (cityState.manifest.peek() === null) return; - lastReframedSourceKey = key; + void build.cityRevision.value; + const subject = session.source.key.peek(); + if (subject === null || subject === framedSubject) return; + // No city yet: claiming the subject here would make the first real one, + // which is what there is to frame, skip its reframe. + if (build.manifest.peek() === null) return; + framedSubject = subject; untracked(() => rig.reset()); }); - // The build is over when the city is ON SCREEN, not when applyStructure - // returns: it starts the rebuilds without holding them (see render/LOADING.md). + // On screen means presented, not applyStructure returning (render/LOADING.md). + // Holds the revision whose frame the compositor has actually shown. + const presented = signal(-1); + const gone = signal(false); const stopOnScreen = effect(() => { - void cityState.cityRevision.value; - if (cityState.manifest.peek() === null) return; + const revision = build.cityRevision.value; + if (build.manifest.peek() === null) return; untracked(() => { void buildings.whenSettled().then(() => { // Two frames: render() only issues the GL commands, and the pixels land // a compositor pass later. - requestAnimationFrame(() => requestAnimationFrame(() => markIdle())); + requestAnimationFrame(() => + requestAnimationFrame(() => { + // max, not assign: a superseded build's frame can land after a + // newer one's, and the newer one is what is on screen. + presented.value = Math.max(presented.peek(), revision); + session.progress.markIdle(); + }) + ); }); }); }); - const postFx = createPostFx(renderer, scene, rig.camera); + /** Resolves once a frame carrying the latest build has been presented, or at + * once when one has. A disposed city never presents again, so that frees it. */ + function whenOnScreen(): Promise { + return until(() => gone.value || presented.value >= build.cityRevision.value); + } + + const postFx = createPostFx(renderer, scene, rig.camera, session.config.BLOOM); postFx.setSize(canvas.clientWidth, canvas.clientHeight); const picker = createPicker({ canvas, camera: rig.camera, - cityState, + build, + timeline: session.timeline, world: { getStreetPickables: () => streets.getPickables(), getRootGem: () => gem.getRootGroup(), @@ -179,7 +206,7 @@ export async function createCity( getTrees: () => trees.getRenderer(), getBuildingByPath: (p) => buildings.getBuildingByPath(p), getSidewalkByDir: (p) => streets.getSidewalkByDir(p), - getStreetByDir: (p) => cityState.streetsByDirMap.peek()[p] ?? null, + getStreetByDir: (p) => build.streetsByDirMap.peek()[p] ?? null, getBuildingIndex: () => buildings.getBuildingIndex(), }, }); @@ -192,7 +219,9 @@ export async function createCity( picker, rig, renderer, - cityState, + build, + timeline: session.timeline, + chrome: session.chrome, showTooltip, hideTooltip, onResize() { @@ -212,7 +241,7 @@ export async function createCity( onResetView: rig.reset, }); - // Scrub controller: built on entering Timeline mode (useTimelineMode); held here to dispose on uninstall. + // Scrub controller: built on entering Timeline mode; held here to dispose on uninstall. let _scrubController: ReturnType | null = null; // Reused scratch vector to avoid per-frame allocations from renderer.getSize(). @@ -243,8 +272,8 @@ export async function createCity( after() { // Drop a selection the scrub removed, but not mid-drag: closing the right // sidebar then reflows the track under the pointer and jumps the position. - if (!TIMELINE_MODE.peek() || SCRUB_DRAGGING.peek()) return; - const pos = SCRUB_POS.peek(); + if (!session.timeline.mode.peek() || session.timeline.dragging.peek()) return; + const pos = session.timeline.scrubPos.peek(); if (pos === _lastPrunedScrubPos) return; _lastPrunedScrubPos = pos; picker.pruneScrubHiddenSelection(); @@ -258,6 +287,8 @@ export async function createCity( ): void { _scrubController?.dispose(); _scrubController = createScrubController({ + timeline: session.timeline, + config: session.config, buildings: { getBuildingIndex: () => buildings.getBuildingIndex(), applyScrub: (states) => buildings.applyScrub(states), @@ -267,9 +298,9 @@ export async function createCity( picker, timelines, commitLineRanges, - heightCtx: makeHeightContext(cityState.manifest.peek()?.stats), - scannedAt: cityState.manifest.peek()?.scanned_at, - streetsByDir: cityState.streetsByDirMap.peek(), + heightCtx: makeHeightContext(build.manifest.peek()?.stats), + scannedAt: build.manifest.peek()?.scanned_at, + streetsByDir: build.streetsByDirMap.peek(), scrubGates: [ { setScrubCommit: (i) => trees.setScrubCommit(i), @@ -293,17 +324,30 @@ export async function createCity( }; // Every Timeline exit. The union city holds buildings that do not exist at - // HEAD, so only a rebuild from the live MANIFEST is a valid live city. + // HEAD, so only a rebuild from the live manifest is a valid live city. const stopTimelineTeardown = effect(() => { - if (TIMELINE_MODE.value || !_scrubController) return; + if (session.timeline.mode.value || !_scrubController) return; timelineApi.uninstallScrubController(); - const live = MANIFEST.peek() as Manifest | null; + // The store's value spans the skeleton the stream emits before it is fully + // typed; the scene takes manifests. + const live = session.manifest.current.peek() as Manifest | null; // Best-effort: a dispose or a newer apply can supersede this mid-flight, // and neither is a failure worth surfacing from a teardown. if (live) void applyManifest(live).catch(() => {}); }); + /** Re-pack what is already on screen: the settings path. A union city under + * a scrubber was not built from one manifest, so it reassembles instead. */ + async function repack(): Promise { + if (session.timeline.mode.peek()) return session.timelineMode.reassemble(); + const showing = build.manifest.peek(); + if (showing) await applyManifest(showing); + } + return { + manifest: build.manifest, + whenOnScreen, + repack, scene, picker, rig, @@ -314,8 +358,14 @@ export async function createCity( * canonical MANIFEST signal (the live tree renderer + the two diagnostics). */ world: { getTrees: () => trees.getRenderer(), - runCollisionCheck: () => runCollisionCheck(cityState), - runStemPlacementDiagnostic: () => runStemPlacementDiagnostic(cityState), + runCollisionCheck: () => runCollisionCheck(build), + runStemPlacementDiagnostic: () => + runStemPlacementDiagnostic(build, { + streetLayout: session.config.STREET_LAYOUT.peek(), + buildingDimensions: session.config.BUILDING_DIMENSIONS.peek(), + gemSizing: session.config.GEM_SIZING.peek(), + streetTiers: session.config.STREET_TIERS.peek().TIERS, + }), runTreeGroundingDiagnostic: () => runTreeGroundingDiagnostic(trees.getRenderer()?.group ?? null, ISLAND_TOP_Y), }, @@ -323,6 +373,9 @@ export async function createCity( /** Full teardown, loop FIRST, renderer LAST — else a remount stacks a * ghost city whose picker still answers raycasts. */ dispose(): void { + // Nothing more will be presented, so anything waiting for a frame is + // waiting for one that is not coming. + gone.value = true; stopFrameLoop(); stopReframe(); stopOnScreen(); diff --git a/app/src/city/interaction/inputHandlers.ts b/app/src/city/scene/interaction/inputHandlers.ts similarity index 91% rename from app/src/city/interaction/inputHandlers.ts rename to app/src/city/scene/interaction/inputHandlers.ts index 9be2fad6b..fcf046a53 100644 --- a/app/src/city/interaction/inputHandlers.ts +++ b/app/src/city/scene/interaction/inputHandlers.ts @@ -11,22 +11,23 @@ const TOUCH_CLICK_MOVE_THRESHOLD_PX = 12; const TOUCH_CLICK_TIME_THRESHOLD_MS = 700; const INPUT_HOVER_COMMIT_MS = 35; import { KEY_BINDINGS, TEXT_INPUT_TAGS } from '@/constants/keyboard'; -import { OVERLAY_OPEN, openSelectionPane } from '@/state/stores/chrome'; -import { focusSelection } from '@/city/sceneHandle'; import { NodeKind } from '@/types'; -import { scrubbedStatsFor } from '@/state/stores/timeline'; -import type { PickTarget } from '@/types'; -import { hoverTooltipContent, type TooltipContent } from './tooltipText'; +import type { TimelineStore } from '@/city/session/stores/timeline'; +import type { PickTarget } from '@/city/scene/types/picker'; +import type { CityChrome } from '@/city/scene/types'; +import { hoverTooltipContent, type TooltipContent } from './tooltip/text'; import type { createPicker } from './picker'; import type { createCameraRig } from '../render/cameraRig'; -import type { CityState } from '../state'; +import type { CityBuild } from '../build'; export function createInputHandlers({ canvas, picker, rig, renderer, - cityState, + build, + timeline, + chrome, showTooltip, hideTooltip, onResize, @@ -36,7 +37,11 @@ export function createInputHandlers({ picker: ReturnType; rig: ReturnType; renderer: THREE.WebGLRenderer; - cityState: CityState; + build: CityBuild; + /** This city's history, for the hover readout at a scrubbed commit. */ + timeline: TimelineStore | null; + /** What this city asks of whatever it is mounted in. */ + chrome: CityChrome; showTooltip: (content: TooltipContent, x: number, y: number) => void; hideTooltip: () => void; onResize: () => void; @@ -91,12 +96,12 @@ export function createInputHandlers({ } // peek: a hover handler wants the current name, never a subscription. - const rootName = cityState.manifest.peek()?.tree?.name ?? null; + const rootName = build.manifest.peek()?.tree?.name ?? null; const scrubLines = newHover?.kind === NodeKind.File && newHover.file?.path != null - ? (scrubbedStatsFor(newHover.file.path)?.lines ?? null) + ? (timeline?.scrubbedStatsFor(newHover.file.path)?.lines ?? null) : null; - const tooltipText = hoverTooltipContent(newHover, rootName, scrubLines); + const tooltipText = hoverTooltipContent(newHover, rootName, scrubLines, timeline); if (tooltipText) { showTooltip(tooltipText, e.clientX, e.clientY); canvas.style.cursor = 'pointer'; @@ -140,13 +145,13 @@ export function createInputHandlers({ // it: that is the way back to a pane you closed. const next = picker.interpretHit(hit); if (_sameHover(next, picker.selection.value)) { - openSelectionPane(); + chrome.showDetails(); return; } picker.setSelection(next); // Picking a node is asking what it is, so a pane put away for the last one // comes back for this one. - openSelectionPane(); + chrome.showDetails(); } // ── Bindings ─────────────────────────────────────────────────────── @@ -209,7 +214,7 @@ export function createInputHandlers({ // A modal owns keyboard input while open — don't let scene shortcuts // (Esc-deselect, R, F) fire underneath it. - if (OVERLAY_OPEN.value) return; + if (chrome.keyboardBusy()) return; if (KEY_BINDINGS.CLEAR_SELECTION.keys.includes(ev.key)) { picker.setSelection(null); @@ -218,9 +223,13 @@ export function createInputHandlers({ // No manifest rebuild — reload the page for that. onResetView(); } else if (KEY_BINDINGS.FOCUS_SELECTION.keys.includes(ev.key)) { - // The command the panes' Focus buttons call. The gem isn't selectable: - // clicking it resets the view instead. - focusSelection(); + // What the panes' Focus button does, from this city's own picker: aim at + // the selection, and get the chrome out of the way. + const sel = picker.selection.peek(); + if (sel) { + rig.focusSelection(sel); + chrome.revealCity(); + } } }); diff --git a/app/src/city/interaction/picker.ts b/app/src/city/scene/interaction/picker.ts similarity index 87% rename from app/src/city/interaction/picker.ts rename to app/src/city/scene/interaction/picker.ts index 9bffb17dc..8ca17f2e9 100644 --- a/app/src/city/interaction/picker.ts +++ b/app/src/city/scene/interaction/picker.ts @@ -1,36 +1,40 @@ // city/interaction/picker.ts — owns the raycaster + the hover/selection state -// machine on @preact/signals. selection is the source of truth; the in-memory -// PICKER_SELECTION_KEY (path/sha) is derived from it and re-resolved to a live -// target on world rebuilds, so selections survive mesh swaps. Not persisted. +// machine on @preact/signals, per city. selection is the source of truth; the +// in-memory selectionKey (path/sha) is derived from it and re-resolved to a +// live target on world rebuilds, so selections survive mesh swaps. import * as THREE from 'three'; import { ObjectBVH } from 'three-mesh-bvh'; import { signal, effect, untracked } from '@preact/signals'; import { NodeKind } from '@/types'; -import { sidewalkStreetForFace } from '@/city/components/streets/streets'; -import { BuildingKind } from '@/city/components/buildings/buildingKind'; -import { TIMELINE_MODE, SCRUB_POS, TIMELINE_BUNDLE } from '@/state/stores/timeline'; -import { RUINED_STREET_DIRS } from '@/city/components/streets/scrubState'; +import { sidewalkStreetForFace } from '@/city/scene/components/streets/streets'; +import { BuildingKind } from '@/city/scene/components/buildings/buildingKind'; +import type { TimelineStore } from '@/city/session/stores/timeline'; +import { RUINED_STREET_DIRS } from '@/city/scene/components/streets/scrubState'; -import type { CommitEntry, PickTarget, PickerWorld, PickerSelectionKey } from '@/types'; -import type { CityState } from '@/city/state'; - -// In-memory selection key. Reset to null on a fresh load; survives in-session -// world rebuilds via the re-resolution below. Never written to localStorage. -export const PICKER_SELECTION_KEY = signal(null); +import type { CommitEntry } from '@/types'; +import type { PickerWorld } from '@/city/scene/types/picker'; +import type { PickTarget, PickerSelectionKey } from '@/city/scene/types/picker'; +import type { CityBuild } from '@/city/scene/build'; export function createPicker({ canvas, camera, world, - cityState, + build, + timeline, }: { canvas: HTMLCanvasElement; camera: THREE.Camera; world: PickerWorld; - cityState: CityState; + build: CityBuild; + /** This city's history, when something scrubs it. */ + timeline: TimelineStore | null; }) { const hover = signal(null); const selection = signal(null); + // In-memory selection key, per city. Reset to null on a fresh load; survives + // in-session world rebuilds via the re-resolution below. Never persisted. + const selectionKey = signal(null); const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); @@ -77,19 +81,19 @@ export function createPicker({ const sel = selection.value; if (_suspendKeyDerive) return; if (!sel) { - PICKER_SELECTION_KEY.value = null; + selectionKey.value = null; return; } if (sel.kind === NodeKind.File && sel.file?.path != null) { - PICKER_SELECTION_KEY.value = { kind: NodeKind.File, path: sel.file.path }; + selectionKey.value = { kind: NodeKind.File, path: sel.file.path }; return; } if (sel.kind === NodeKind.Directory && sel.dir?.path != null) { - PICKER_SELECTION_KEY.value = { kind: NodeKind.Directory, path: sel.dir.path }; + selectionKey.value = { kind: NodeKind.Directory, path: sel.dir.path }; return; } if (sel.kind === NodeKind.Commit && sel.commit?.sha) { - PICKER_SELECTION_KEY.value = { kind: NodeKind.Commit, sha: sel.commit.sha }; + selectionKey.value = { kind: NodeKind.Commit, sha: sel.commit.sha }; return; } }); @@ -99,7 +103,7 @@ export function createPicker({ // Key → selection re-resolution on rebuild: a manifest swap stales every // live mesh ref; the key keeps the selected node alive. function _resolveKeyToSelection() { - const key = PICKER_SELECTION_KEY.value; + const key = selectionKey.value; _refreshPickables(); // also refresh pickables on every rebuild if (!key) { @@ -122,7 +126,7 @@ export function createPicker({ }; } else { selection.value = null; - PICKER_SELECTION_KEY.value = null; + selectionKey.value = null; } _suspendKeyDerive = false; return; @@ -140,7 +144,7 @@ export function createPicker({ }; } else { selection.value = null; - PICKER_SELECTION_KEY.value = null; + selectionKey.value = null; } _suspendKeyDerive = false; return; @@ -154,7 +158,7 @@ export function createPicker({ selection.value = target; } else { selection.value = null; - PICKER_SELECTION_KEY.value = null; + selectionKey.value = null; } _suspendKeyDerive = false; return; @@ -170,7 +174,7 @@ export function createPicker({ // Rebuild reaction: untracked() keeps this subscribed ONLY to // cityRevision — the body reads/writes signals that would re-fire it. const _disposeCityRevEffect = effect(() => { - void cityState.cityRevision.value; + void build.cityRevision.value; untracked(() => { _clearHoverOnRebuild(); _resolveKeyToSelection(); @@ -180,10 +184,10 @@ export function createPicker({ // here are the ones the components just built. Fires once at construction too. // The scrub rewrites building matrices per frame but the BVH caches bounds - // at build time — invalidate on SCRUB_POS or hitboxes freeze mid-scrub. + // at build time — invalidate on the scrub position or hitboxes freeze. const _disposeScrubBvhEffect = effect(() => { - void SCRUB_POS.value; - if (!TIMELINE_MODE.peek()) return; + void timeline?.scrubPos.value; + if (!timeline?.mode.peek()) return; _bvh = null; _bvhDirty = true; }); @@ -237,7 +241,7 @@ export function createPicker({ /** The commit itself, for one the city drew no tree for. Timeline's list is * the one the scrubber names; Live's comes off the manifest. */ function _commitBySha(sha: string): CommitEntry | null { - const commits = TIMELINE_BUNDLE.peek()?.commits ?? cityState.manifest.peek()?.commits ?? []; + const commits = timeline?.bundle.peek()?.commits ?? build.manifest.peek()?.commits ?? []; return commits.find((c) => c.sha === sha) ?? null; } @@ -386,7 +390,7 @@ export function createPicker({ slot: number | undefined ): PickTarget | null { if (slot == null) return null; - if (TIMELINE_MODE.peek() && _buildingScrubHidden(mesh, slot)) return null; + if (timeline?.mode.peek() && _buildingScrubHidden(mesh, slot)) return null; const building = world.getBuildingIndex()?.byCellSlot(`${cellId}:${slot}`); if (!building?.file) return null; return { @@ -395,7 +399,7 @@ export function createPicker({ data: building, file: building.file, instanceId: slot, - isRuin: TIMELINE_MODE.peek() && _buildingIsRuin(mesh, slot), + isRuin: !!timeline?.mode.peek() && _buildingIsRuin(mesh, slot), }; } @@ -403,7 +407,7 @@ export function createPicker({ * its street through the faceIndex map baked onto userData. */ function sidewalkTargetFor(hit: THREE.Intersection): PickTarget | null { const mesh = hit.object as THREE.Mesh; - if (TIMELINE_MODE.peek() && _streetScrubHidden(mesh, hit.face?.a)) return null; + if (timeline?.mode.peek() && _streetScrubHidden(mesh, hit.face?.a)) return null; const street = sidewalkStreetForFace(hit.object, hit.faceIndex ?? 0); if (!street?.dir) return null; return { @@ -411,7 +415,7 @@ export function createPicker({ sidewalk: mesh, street, dir: street.dir, - isRuin: TIMELINE_MODE.peek() && RUINED_STREET_DIRS.has(street.dir.path), + isRuin: !!timeline?.mode.peek() && RUINED_STREET_DIRS.has(street.dir.path), vertexHint: hit.face?.a, }; } @@ -456,7 +460,7 @@ export function createPicker({ return { hover, selection, - selectionKey: PICKER_SELECTION_KEY, + selectionKey, setHover, setSelection, clearSelection, diff --git a/app/src/city/interaction/tooltipText.ts b/app/src/city/scene/interaction/tooltip/text.ts similarity index 91% rename from app/src/city/interaction/tooltipText.ts rename to app/src/city/scene/interaction/tooltip/text.ts index 3562688d9..5bacd0f83 100644 --- a/app/src/city/interaction/tooltipText.ts +++ b/app/src/city/scene/interaction/tooltip/text.ts @@ -3,10 +3,11 @@ // shares the selection pane's stat builders, so hovering a building and // selecting it cannot report different numbers. import { NodeKind } from '@/types'; -import type { PickTarget } from '@/types'; +import type { PickTarget } from '@/city/scene/types/picker'; import { formatRelativeAge } from '@/utils/dates'; import { ROOT_PATH } from '@/constants/manifest'; -import { fileStatItems, directoryStatItems } from '@/components/panes/PaneStats/statItems'; +import { fileStatItems, directoryStatItems } from '@/utils/statItems'; +import type { TimelineStore } from '@/city/session/stores/timeline'; /** Longest path rendered before the middle segments collapse to an ellipsis. */ const PATH_BUDGET_CHARS = 44; @@ -57,8 +58,10 @@ export function middleTruncatePath(path: string, budget = PATH_BUDGET_CHARS): st export function hoverTooltipContent( target: PickTarget | null, rootName: string | null, - // Timeline: lines at the scrubbed commit, or at deletion for a file already gone. - scrubLines?: number | null + // Timeline: lines at the scrubbed commit, or at deletion for a file already + // gone, and the history those come from. + scrubLines?: number | null, + timeline?: TimelineStore | null ): TooltipContent | null { if (!target) return null; const deleted = isDeletedTarget(target); @@ -88,7 +91,7 @@ export function hoverTooltipContent( return { title: f.name || rel, path: middleTruncatePath(withRoot(parent, rootName)), - stats: fileStatItems(node, { dates: false }).map((i) => i.text), + stats: fileStatItems(node, { dates: false, timeline }).map((i) => i.text), deleted, }; } diff --git a/app/src/city/interaction/tooltip.css b/app/src/city/scene/interaction/tooltip/tooltip.css similarity index 100% rename from app/src/city/interaction/tooltip.css rename to app/src/city/scene/interaction/tooltip/tooltip.css diff --git a/app/src/city/interaction/tooltip.ts b/app/src/city/scene/interaction/tooltip/tooltip.ts similarity index 98% rename from app/src/city/interaction/tooltip.ts rename to app/src/city/scene/interaction/tooltip/tooltip.ts index 6d1a75eac..38cdaa903 100644 --- a/app/src/city/interaction/tooltip.ts +++ b/app/src/city/scene/interaction/tooltip/tooltip.ts @@ -2,7 +2,7 @@ // you hover a building, street or commit. Imperative show/move/hide, because // the canvas has no component tree to render into. import './tooltip.css'; -import type { TooltipContent } from './tooltipText'; +import type { TooltipContent } from './text'; // Tooltip placement — fixed, not user-tunable. const TOOLTIP_OFFSET_PX = 14; diff --git a/app/src/city/layout/algorithm.ts b/app/src/city/scene/layout/algorithm.ts similarity index 93% rename from app/src/city/layout/algorithm.ts rename to app/src/city/scene/layout/algorithm.ts index 3cbb55927..6f892b470 100644 --- a/app/src/city/layout/algorithm.ts +++ b/app/src/city/scene/layout/algorithm.ts @@ -3,11 +3,11 @@ // files line the sides, its subdirectories branch off perpendicular, and each // placement takes the smallest stem that clears everything already placed. -import { STREET_LAYOUT } from '@/state/settings/fields/streets'; -import { BUILDING_DIMENSIONS } from '@/state/settings/fields/buildings'; -import { GEM_SIZING } from '@/state/settings/fields/gem'; -import { BuildingOrient, JoinSide, NodeKind, StreetAxis } from '@/types'; -import type { Building, CityLayout, RangeStat, RepoStats, Street } from '@/types'; +import type { LayoutConfig } from './protocol'; +import { NodeKind } from '@/types'; +import { BuildingOrient, JoinSide, StreetAxis } from '@/city/scene/types'; +import type { RangeStat, RepoStats } from '@/types'; +import type { Building, CityLayout, Street } from '@/city/scene/types'; import { parentDirPath } from '../utils/path'; import { rectOfBuilding, rectOfStreet } from './rect'; import type { Rect } from './rect'; @@ -53,24 +53,25 @@ export function estimateDirReaches( lineStats: RangeStat, byteStats: RangeStat, parentStreetWidth: number | undefined, - cache: Map + cache: Map, + cfg: LayoutConfig ): DirReaches { const cached = cache.get(dir); if (cached) return cached; - const streetLayout = STREET_LAYOUT.value; + const streetLayout = cfg.streetLayout; const buildingGap = streetLayout.BUILDING_GAP; const streetGap = streetLayout.STREET_GAP; const parentJoinPad = streetLayout.PARENT_JOIN_PAD; const rootEndPad = streetLayout.ROOT_END_PAD; - const bldgDims = BUILDING_DIMENSIONS.value; + const bldgDims = cfg.buildingDimensions; const distFromRoad = bldgDims.DISTANCE_FROM_ROAD; - const gemSizing = GEM_SIZING.value; + const gemSizing = cfg.gemSizing; const gemRadiusFrac = gemSizing.RADIUS_AS_STREET_FRAC; // Padding chain — mirrors _layoutDir exactly so the estimate matches the // real placement's bounds. - const myStreetWidth = _streetWidthForDir(dir); + const myStreetWidth = _streetWidthForDir(dir, cfg.streetTiers); const openEndPad = myStreetWidth / 2 + distFromRoad; const joinEndBaseline = parentStreetWidth ? parentStreetWidth / 2 + parentJoinPad : rootEndPad; const endPad = parentStreetWidth @@ -103,12 +104,24 @@ export function estimateDirReaches( // A building reserves buildingGap; a branching side street reserves streetGap. let myGap: number; if (child.type === NodeKind.File) { - const dim = getBuildingDimensions(child as FileLike, lineStats, byteStats); + const dim = getBuildingDimensions( + child as FileLike, + cfg.buildingDimensions, + lineStats, + byteStats + ); alongContrib = dim.w; perpContrib = myStreetWidth / 2 + distFromRoad + dim.d; myGap = buildingGap; } else { - const sub = estimateDirReaches(child as DirLike, lineStats, byteStats, myStreetWidth, cache); + const sub = estimateDirReaches( + child as DirLike, + lineStats, + byteStats, + myStreetWidth, + cache, + cfg + ); // A perpendicular subdir spans 2*perpReach along the parent's axis (both // sides of its road) and alongReach across it (join to end, one-sided). alongContrib = 2 * sub.perpReach; @@ -221,6 +234,7 @@ function _layoutDir( byteStats: RangeStat, occupancy: WorldOccupancy, reachCache: Map, + cfg: LayoutConfig, trace?: StemPlacementTrace, /** Parent's FINAL road length, so the phantom covers its whole body rather * than the extent at this recursion's start. Undefined at the top level. */ @@ -230,22 +244,22 @@ function _layoutDir( onPlaced?: () => void ): void { // ----- Tunables (one .value per call) ----- - const streetLayout = STREET_LAYOUT.value; + const streetLayout = cfg.streetLayout; const buildingGap = streetLayout.BUILDING_GAP; const streetGap = streetLayout.STREET_GAP; const parentJoinPad = streetLayout.PARENT_JOIN_PAD; const rootEndPad = streetLayout.ROOT_END_PAD; - const bldgDims = BUILDING_DIMENSIONS.value; + const bldgDims = cfg.buildingDimensions; const distFromRoad = bldgDims.DISTANCE_FROM_ROAD; // ----- Padding chain ----- - const myStreetWidth = _streetWidthForDir(dir); + const myStreetWidth = _streetWidthForDir(dir, cfg.streetTiers); const openEndPad = myStreetWidth / 2 + distFromRoad; const joinEndBaseline = parentStreetWidth ? parentStreetWidth / 2 + parentJoinPad : rootEndPad; const endPad = parentStreetWidth ? Math.max(joinEndBaseline, openEndPad) : Math.max(rootEndPad, openEndPad); - const gemSizing = GEM_SIZING.value; + const gemSizing = cfg.gemSizing; const gemRadiusFrac = gemSizing.RADIUS_AS_STREET_FRAC; // Off the gem's own diameter, so the dead space scales with it. Same // MIN_RADIUS floor the gem mesh uses, or a narrow root street under-reserves. @@ -282,7 +296,12 @@ function _layoutDir( for (const child of children) { if (child.type === NodeKind.File) { // ----- File leaf: compute rects in child-local frame ----- - const dim = getBuildingDimensions(child as FileLike, lineStats, byteStats); + const dim = getBuildingDimensions( + child as FileLike, + cfg.buildingDimensions, + lineStats, + byteStats + ); const along = dim.w; const perpDepth = dim.d; const perpCenter = myStreetWidth / 2 + distFromRoad + perpDepth / 2; @@ -383,7 +402,7 @@ function _layoutDir( if (boundaryHigh > maxBoundaryAlong) maxBoundaryAlong = boundaryHigh; } else { // ----- Subdir: recurse in a local occupancy, then commit ----- - const subStreetWidth = _streetWidthForDir(child as DirLike); + const subStreetWidth = _streetWidthForDir(child as DirLike, cfg.streetTiers); const childResult: SubtreeResult = { alongReach: subStreetWidth / 2, streets: [], @@ -404,6 +423,7 @@ function _layoutDir( byteStats, localOccupancy, reachCache, + cfg, trace, myReaches?.alongReach, onPlaced @@ -551,21 +571,29 @@ function _layoutDir( // null: the renderer calls getBuildingColor before drawing. /** @param onPlaced Called once per node as the packer commits it, so a caller * that knows the node count can turn the pack into a percent. */ -export function layoutCity(manifest: ManifestLike | DirLike, onPlaced?: () => void): CityLayout { - return _layoutCityInternal(manifest, undefined, onPlaced).layout; +export function layoutCity( + manifest: ManifestLike | DirLike, + cfg: LayoutConfig, + onPlaced?: () => void +): CityLayout { + return _layoutCityInternal(manifest, cfg, undefined, onPlaced).layout; } // Same layout output, plus the per-placeChild trace behind the "Diagnose stem // placement" debug button. -export function layoutCityWithTrace(manifest: ManifestLike | DirLike): { +export function layoutCityWithTrace( + manifest: ManifestLike | DirLike, + cfg: LayoutConfig +): { layout: CityLayout; trace: StemPlacementTrace; } { - return _layoutCityInternal(manifest, { placements: [] }); + return _layoutCityInternal(manifest, cfg, { placements: [] }); } function _layoutCityInternal( manifest: ManifestLike | DirLike, + cfg: LayoutConfig, trace: StemPlacementTrace | undefined, onPlaced?: () => void ): { layout: CityLayout; trace: StemPlacementTrace } { @@ -594,7 +622,7 @@ function _layoutCityInternal( // when its children seed their phantoms with the exact parent body extent. const _tReaches = _profNow(); const reachCache = new Map(); - estimateDirReaches(tree, stats.lines, stats.bytes, undefined, reachCache); + estimateDirReaches(tree, stats.lines, stats.bytes, undefined, reachCache, cfg); _profEnd('phase.estimateDirReaches', _tReaches); const _tPlace = _profNow(); _layoutDir( @@ -608,6 +636,7 @@ function _layoutCityInternal( stats.bytes, occupancy, reachCache, + cfg, trace, undefined, onPlaced diff --git a/app/src/city/layout/dimensions.ts b/app/src/city/scene/layout/dimensions.ts similarity index 92% rename from app/src/city/layout/dimensions.ts rename to app/src/city/scene/layout/dimensions.ts index 6819737d6..1fe6b9f6c 100644 --- a/app/src/city/layout/dimensions.ts +++ b/app/src/city/scene/layout/dimensions.ts @@ -1,9 +1,8 @@ // city/layout/dimensions.ts — file/building sizing and street width derivation. // Pure functions over manifest stats and settings stores; no DOM or Three.js. -import { STREET_TIERS } from '@/state/settings/fields/streets'; -import { BUILDING_DIMENSIONS } from '@/state/settings/fields/buildings'; -import type { StreetTier } from '@/state/settings/fields/streets'; +import type { BuildingDimensionsConfig } from '@/city/session/settings/buildings'; +import type { StreetTier } from '@/city/session/settings/streets'; import type { RangeStat, RepoStats } from '@/types'; import { isMediaFile, isEmptyFile, isDataBuilding } from '@/utils/fileKind'; @@ -30,8 +29,8 @@ export type TreeLike = FileLike | DirLike; // The widest tier whose min_descendants the count meets; the last tier is the // catch-all for big directories. -export function getStreetWidth(count: number, tiers?: StreetTier[]): number { - const arr = tiers && tiers.length ? tiers : STREET_TIERS.value.TIERS; +export function getStreetWidth(count: number, tiers: StreetTier[]): number { + const arr = tiers; let chosen = arr[0].width; for (let i = 0; i < arr.length; i++) { if (count >= arr[i].min_descendants) chosen = arr[i].width; @@ -62,7 +61,7 @@ export function computeFileStats(stats: RepoStats | null | undefined): { // Project-relative, but the ceiling is anchored to the biggest file's ABSOLUTE // size: a repo of small files reads as a short city, not a full one. -type Dims = typeof BUILDING_DIMENSIONS.value; +type Dims = BuildingDimensionsConfig; /** Floors from line count, sqrt-normalized. Full cap only when the largest file * is >= FULL_HEIGHT_LINES, so a small-file repo reads low-rise; order preserved. */ @@ -126,10 +125,10 @@ export function getBuildingDimensions( media_height?: number; binary?: boolean; }, + dims: Dims, lineStats?: RangeStat, byteStats?: RangeStat ): { w: number; d: number; h: number; floors: number } { - const dims = BUILDING_DIMENSIONS.value; const maxFloorsCap = dims.MAX_FLOORS != null ? dims.MAX_FLOORS : 30; // MIN_FLOORS is the floor for files that HAVE content, so an empty file skips @@ -195,13 +194,14 @@ export function makeHeightContext(stats: RepoStats | null | undefined): HeightCo // layout's skeleton-era placeholders into the final manifest's real sizes. export function recomputeBuildingDimensions( file: FileLike, - ctx: HeightContext + ctx: HeightContext, + dims: Dims ): { w: number; d: number; h: number; floors: number } { - return getBuildingDimensions(file, ctx.lineStats, ctx.byteStats); + return getBuildingDimensions(file, dims, ctx.lineStats, ctx.byteStats); } // A directory's descendants → its street width: bigger dirs, wider boulevards. -export function _streetWidthForDir(dir: DirLike | null | undefined): number { +export function _streetWidthForDir(dir: DirLike | null | undefined, tiers: StreetTier[]): number { const count = (dir && (dir.descendants_count || dir.children_count)) || 0; - return getStreetWidth(count, STREET_TIERS.value.TIERS); + return getStreetWidth(count, tiers); } diff --git a/app/src/city/layout/index.ts b/app/src/city/scene/layout/index.ts similarity index 82% rename from app/src/city/layout/index.ts rename to app/src/city/scene/layout/index.ts index eb2b623ab..d04be2293 100644 --- a/app/src/city/layout/index.ts +++ b/app/src/city/scene/layout/index.ts @@ -3,16 +3,12 @@ // newer call supersedes the pending ones rather than racing them. Falls back to // synchronous in-process layout when Worker is undefined (jsdom), identically. -import { STREET_LAYOUT, STREET_TIERS } from '@/state/settings/fields/streets'; -import { BUILDING_DIMENSIONS } from '@/state/settings/fields/buildings'; -import { GEM_SIZING } from '@/state/settings/fields/gem'; -import type { StreetLayoutConfig, StreetTier } from '@/state/settings/fields/streets'; -import type { BuildingDimensionsConfig } from '@/state/settings/fields/buildings'; -import type { GemSizingConfig } from '@/state/settings/fields/gem'; +import type { CityConfig } from '@/city/session/config'; import { layoutCity } from './algorithm'; import { makeHeightContext, recomputeBuildingDimensions } from './dimensions'; -import type { LayoutRequest, LayoutResponse } from './protocol'; -import type { Manifest, CityLayout, FileNode, TreeNode } from '@/types'; +import type { LayoutConfig, LayoutRequest, LayoutResponse } from './protocol'; +import type { Manifest, FileNode, TreeNode } from '@/types'; +import type { CityLayout } from '@/city/scene/types'; interface PendingRequest { resolve: (layout: CityLayout) => void; @@ -20,13 +16,6 @@ interface PendingRequest { onProgress?: (percent: number) => void; } -interface ConfigSnapshot { - streetLayout: StreetLayoutConfig; - buildingDimensions: BuildingDimensionsConfig; - gemSizing: GemSizingConfig; - streetTiers: StreetTier[]; -} - export interface LayoutClient { /** Off-thread, or in-thread without a Worker; rejects 'superseded'/'disposed'. * reuseLayoutFrom takes the cheap path, onProgress reports the worker's own. */ @@ -55,14 +44,15 @@ function buildPathToFile(tree: TreeNode): Map { // The cheap main-thread path: prior positions kept, per-file refs and dims // recomputed from the new manifest. No worker, no collision detection. -function reuseLayout(prior: CityLayout, newManifest: Manifest): CityLayout { +function reuseLayout(prior: CityLayout, newManifest: Manifest, cfg: LayoutConfig): CityLayout { const filesByPath = buildPathToFile(newManifest.tree as unknown as TreeNode); const heightCtx = makeHeightContext(newManifest.stats); const newBuildings = prior.buildings.map((b) => { const freshFile = (b.file?.path ? filesByPath.get(b.file.path) : null) ?? b.file; const dims = recomputeBuildingDimensions( freshFile as unknown as Parameters[0], - heightCtx + heightCtx, + cfg.buildingDimensions ); return { ...b, @@ -80,16 +70,15 @@ function reuseLayout(prior: CityLayout, newManifest: Manifest): CityLayout { }; } -function _snapshot(): ConfigSnapshot { - return { - streetLayout: STREET_LAYOUT.value, - buildingDimensions: BUILDING_DIMENSIONS.value, - gemSizing: GEM_SIZING.value, - streetTiers: STREET_TIERS.value.TIERS, - }; -} - -export function createLayoutClient(): LayoutClient { +export function createLayoutClient(config: CityConfig): LayoutClient { + // Read per request, not once: a Save changes what the next city is laid out + // from, and the worker cannot read a signal. + const snapshot = (): LayoutConfig => ({ + streetLayout: config.STREET_LAYOUT.value, + buildingDimensions: config.BUILDING_DIMENSIONS.value, + gemSizing: config.GEM_SIZING.value, + streetTiers: config.STREET_TIERS.value.TIERS, + }); const pending = new Map(); let nextId = 1; let disposed = false; @@ -152,7 +141,10 @@ export function createLayoutClient(): LayoutClient { reject: PendingRequest['reject'] ): void { try { - const layout = layoutCity(manifest as unknown as Parameters[0]); + const layout = layoutCity( + manifest as unknown as Parameters[0], + snapshot() + ); queueMicrotask(() => { if (!pending.has(id)) return; pending.delete(id); @@ -187,7 +179,7 @@ export function createLayoutClient(): LayoutClient { if (!pending.has(id)) return; // superseded pending.delete(id); try { - resolve(reuseLayout(reuseLayoutFrom, manifest)); + resolve(reuseLayout(reuseLayoutFrom, manifest, snapshot())); } catch (err) { reject(err instanceof Error ? err : new Error(String(err))); } @@ -210,7 +202,7 @@ export function createLayoutClient(): LayoutClient { // ONLY what layoutCity reads: the commits array would be structured- // cloned on the main thread every apply (~240ms at 200k), for nothing. manifest: { tree: manifest.tree, stats: manifest.stats }, - configSnapshot: _snapshot(), + configSnapshot: snapshot(), }; w.postMessage(request); }); diff --git a/app/src/city/layout/occupancyIndex.ts b/app/src/city/scene/layout/occupancyIndex.ts similarity index 87% rename from app/src/city/layout/occupancyIndex.ts rename to app/src/city/scene/layout/occupancyIndex.ts index 6f30a2f7a..01b1e2cf1 100644 --- a/app/src/city/layout/occupancyIndex.ts +++ b/app/src/city/scene/layout/occupancyIndex.ts @@ -4,7 +4,7 @@ // frame. import RBush from 'rbush'; -import type { Building, Street } from '@/types'; +import type { Building, Street } from '@/city/scene/types'; // Touches at edges (zero-area overlap) are NOT reported as overlap. const OVERLAP_EPS = 1e-9; @@ -25,10 +25,8 @@ export interface WorldRect { // Payload — what kind of thing is this, what does it belong to. kind: WorldRectKind; ref: Building | Street; - // True only for the phantom strip seeded to clear a child off its PARENT - // street body. It's a Street rect but NOT a sibling, so the sibling-gap - // logic (findSmallestValidStem) must not apply the street gap to it — the - // join clearance is PARENT_JOIN_PAD's job, not the sibling gap's. + // The phantom strip clearing a child off its PARENT's body: a Street rect + // but not a sibling, so PARENT_JOIN_PAD spaces it, not the sibling gap. parentBody?: boolean; } diff --git a/app/src/city/layout/overlaps.ts b/app/src/city/scene/layout/overlaps.ts similarity index 78% rename from app/src/city/layout/overlaps.ts rename to app/src/city/scene/layout/overlaps.ts index 946d4eb01..55395caa6 100644 --- a/app/src/city/layout/overlaps.ts +++ b/app/src/city/scene/layout/overlaps.ts @@ -2,15 +2,14 @@ // with T-junction joins classified apart from unexpected overlaps. Pure data; // no DOM or Three.js. -import { StreetAxis } from '@/types'; -import type { Building, Street } from '@/types'; +import { StreetAxis } from '@/city/scene/types'; +import type { Building, Street } from '@/city/scene/types'; import { rectOfBuilding, rectOfStreet, _rectsOverlap, rectEdges } from './rect'; import type { Rect } from './rect'; import { WorldRectKind } from './occupancyIndex'; -// Perpendicular streets whose endpoint sits on the other's centerline: the flat -// join the renderer fuses, and so the one overlap a layout may contain. The -// half-unit slop absorbs coordinate drift; a well-formed join has a zero gap. +// Perpendicular streets meeting on a centreline: the flat join the renderer +// fuses, and the one overlap a layout may contain. Half a unit absorbs drift. export function isStreetJoinPair(a: Street, b: Street): boolean { if (a.orientation === b.orientation) return false; const aLong = a.orientation === StreetAxis.X ? 'x' : 'y'; @@ -29,9 +28,7 @@ export function isStreetJoinPair(a: Street, b: Street): boolean { return perpClose && longClose; } -// How an overlap is classified. String enum (values match the prior wire -// strings) so call sites reference LayoutOverlapCategory.TJunction, mirroring -// the WorldRectKind convention. +// A string enum, like WorldRectKind, so a call site names the category. export enum LayoutOverlapCategory { TJunction = 't-junction', Unexpected = 'unexpected', @@ -91,12 +88,8 @@ export function findLayoutOverlaps(layout: { B = all[j]; if (!_rectsOverlap(A.rect, B.rect)) continue; const overlap = _intersectRect(A.rect, B.rect); - // Skip FP-noise overlaps. The internal _rectsOverlap uses - // OVERLAP_EPS=1e-9, which is below the IEEE-754 drift produced by - // chains of Float32 translations at large coordinate magnitudes - // (~1e-5 at coords of 10000+). Touching-edge cases produce overlaps - // with one dimension in that drift range; they're visually - // imperceptible and not actual layout bugs. + // OVERLAP_EPS sits below the drift a chain of translations produces out + // at 10000+ (~1e-5), so a touching edge lands here, invisible and real. if (overlap.w < 1e-3 || overlap.d < 1e-3) continue; let category: LayoutOverlapCategory = LayoutOverlapCategory.Unexpected; if ( diff --git a/app/src/city/layout/packProgress.ts b/app/src/city/scene/layout/packProgress.ts similarity index 100% rename from app/src/city/layout/packProgress.ts rename to app/src/city/scene/layout/packProgress.ts diff --git a/app/src/city/layout/profiling.ts b/app/src/city/scene/layout/profiling.ts similarity index 91% rename from app/src/city/layout/profiling.ts rename to app/src/city/scene/layout/profiling.ts index 6ec13842b..88d31f146 100644 --- a/app/src/city/layout/profiling.ts +++ b/app/src/city/scene/layout/profiling.ts @@ -1,11 +1,9 @@ // city/layout/profiling.ts — opt-in per-phase profiler for the layout packer. -import type { CityLayout } from '@/types'; +import type { CityLayout } from '@/city/scene/types'; // ─── Profiling ─────────────────────────────────────────────────────────────── -// Off by default; the _profEnabled guard keeps production cost to one branch per -// section. setLayoutProfiling(true) makes layoutCity log a per-phase breakdown -// and expose raw buckets (cumulative ms + count) via getLayoutProfile(). +// Off by default, one branch per section; on, layoutCity logs per phase. interface ProfBucket { ms: number; n: number; diff --git a/app/src/city/layout/protocol.ts b/app/src/city/scene/layout/protocol.ts similarity index 59% rename from app/src/city/layout/protocol.ts rename to app/src/city/scene/layout/protocol.ts index 616e9e3b7..11bcff674 100644 --- a/app/src/city/layout/protocol.ts +++ b/app/src/city/scene/layout/protocol.ts @@ -3,25 +3,30 @@ // posts/receives them; index.ts builds the request + narrows the response) so // the two ends can never drift. Mirrors components/trees/treePlacementProtocol.ts. -import type { StreetLayoutConfig, StreetTier } from '@/state/settings/fields/streets'; -import type { BuildingDimensionsConfig } from '@/state/settings/fields/buildings'; -import type { GemSizingConfig } from '@/state/settings/fields/gem'; -import type { Manifest, CityLayout } from '@/types'; +import type { StreetLayoutConfig, StreetTier } from '@/city/session/settings/streets'; +import type { BuildingDimensionsConfig } from '@/city/session/settings/buildings'; +import type { GemSizingConfig } from '@/city/session/settings/gem'; +import type { Manifest } from '@/types'; +import type { CityLayout } from '@/city/scene/types'; /** The only slice the worker needs. Sending the whole Manifest structured- * clones its commits array across postMessage every apply, for nothing. */ export type LayoutManifest = Pick; +/** What laying a city out reads: passed in rather than read from a store, + * since this runs in a worker as often as on the main thread. */ +export interface LayoutConfig { + streetLayout: StreetLayoutConfig; + buildingDimensions: BuildingDimensionsConfig; + gemSizing: GemSizingConfig; + streetTiers: StreetTier[]; +} + export interface LayoutRequest { type: 'layout'; id: number; manifest: LayoutManifest; - configSnapshot: { - streetLayout: StreetLayoutConfig; - buildingDimensions: BuildingDimensionsConfig; - gemSizing: GemSizingConfig; - streetTiers: StreetTier[]; - }; + configSnapshot: LayoutConfig; } export type LayoutResponse = diff --git a/app/src/city/scene/layout/rect.ts b/app/src/city/scene/layout/rect.ts new file mode 100644 index 000000000..1d3632d21 --- /dev/null +++ b/app/src/city/scene/layout/rect.ts @@ -0,0 +1,57 @@ +// city/scene/layout/rect.ts — the 2D footprint rectangle, and the one place a +// Street or Building becomes one. (x, y) is the CENTRE and w/d are full +// extents, on a plane whose y is the world's z. A Street stores length-along +// and width-across, so the orientation swap lives here and nowhere else. + +import { StreetAxis } from '@/city/scene/types'; +import type { Building, Street } from '@/city/scene/types'; + +/** Axis-aligned rectangle on the layout plane. (x, y) is the CENTER; + * w/d are the full width/depth (matches Building/Street conventions). */ +export interface Rect { + x: number; + y: number; + w: number; + d: number; +} + +/** A Building already stores center + full extents — direct mapping. */ +export function rectOfBuilding(b: Building): Rect { + return { x: b.x, y: b.y, w: b.w, d: b.d }; +} + +/** A Street with orientation X has its long side (length) on x and its + * short side (width) on y; orientation Y is the inverse. */ +export function rectOfStreet(s: Street): Rect { + if (s.orientation === StreetAxis.X) { + return { x: s.x, y: s.y, w: s.length, d: s.width }; + } + return { x: s.x, y: s.y, w: s.width, d: s.length }; +} + +// Edges that should touch differ by ~7e-15 (centre ± size/2 through non-integer +// offsets), and the packer must read that as no overlap, not a sliver. +export const OVERLAP_EPS = 1e-9; + +/** The four edges of a rect (center ± half-extent). Shared by the overlap test + * and the intersection helper so neither re-derives edges inline. */ +export interface RectEdges { + x1: number; + x2: number; + y1: number; + y2: number; +} +export function rectEdges(r: Rect): RectEdges { + return { x1: r.x - r.w / 2, x2: r.x + r.w / 2, y1: r.y - r.d / 2, y2: r.y + r.d / 2 }; +} + +export function _rectsOverlap(a: Rect, b: Rect): boolean { + const A = rectEdges(a); + const B = rectEdges(b); + return ( + A.x1 < B.x2 - OVERLAP_EPS && + A.x2 > B.x1 + OVERLAP_EPS && + A.y1 < B.y2 - OVERLAP_EPS && + A.y2 > B.y1 + OVERLAP_EPS + ); +} diff --git a/app/src/city/layout/stemSolver.ts b/app/src/city/scene/layout/stemSolver.ts similarity index 68% rename from app/src/city/layout/stemSolver.ts rename to app/src/city/scene/layout/stemSolver.ts index faba1d740..5eb963cb2 100644 --- a/app/src/city/layout/stemSolver.ts +++ b/app/src/city/scene/layout/stemSolver.ts @@ -3,22 +3,19 @@ // offset (and side/mirror variant) that avoids overlap with everything already // placed. Pure data; no DOM or Three.js. -import { StreetAxis } from '@/types'; +import { StreetAxis } from '@/city/scene/types'; import { OVERLAP_EPS } from './rect'; import type { Rect } from './rect'; import { WorldOccupancy, WorldRectKind } from './occupancyIndex'; import type { WorldRect } from './occupancyIndex'; import { _profNow, _profEnd, _profCount } from './profiling'; -// findSmallestValidStem's sort-free scan grows the stem one forbidden-interval -// link per round (O(F·chain)). Past this many rounds the chain is long enough -// that a one-off O(F log F) sorted scan is cheaper, so we fall back to it. +// The sort-free scan grows the stem one link per round, so past this many the +// chain is long enough that one sorted pass is cheaper. const STEM_SCAN_SORT_FALLBACK_ROUNDS = 32; // ─── Stem-placement diagnostic types ──────────────────────────────────────── -// Used by the "Diagnose stem placement" debug button. None of these types -// affect normal layout — they're only populated when an optional `trace` -// param is supplied to findSmallestValidStem / placeChild / _layoutDir. +// The "Diagnose stem placement" button. Filled only when a `trace` is passed. export interface ForbiddenIntervalRecord { lower: number; @@ -36,9 +33,8 @@ export interface VariantTrace { stem: number; /** Filled by findSmallestValidStem. */ forbidden: ForbiddenIntervalRecord[]; - /** Filled by findSmallestValidStem. Index into `forbidden` of the interval - * whose `.upper` set the final stem, or null if the chosen stem == - * baseline (no jump). */ + /** Index of the forbidden interval whose upper set the final stem, or null + * when the stem stayed at the baseline. */ bindingIndex: number | null; } @@ -62,10 +58,8 @@ export interface StemPlacementTrace { placements: ChildPlacementTrace[]; } -// computeFlips(parentOrient, side, mirror) → {flipX, flipY} -// -// For X-orient parent: side flips perp (Y), mirror flips along (X) of the child. -// For Y-orient parent: side flips perp (X), mirror flips along (Y) of the child. +// side flips the child across the parent's perpendicular axis, mirror across +// its long one — which axis is which depends on the parent's orientation. export function computeFlips( parentOrient: StreetAxis, side: 0 | 1, @@ -77,10 +71,7 @@ export function computeFlips( return { flipX: side === 0, flipY: mirror }; } -// applyFlips(rect, flipX, flipY) → flipped Rect -// -// Negates the rect's center coordinates per the flip flags. Width and depth -// are unchanged (rects are AABBs, not oriented). +// Negates the rect's centre per flag; w/d are unchanged, since these are AABBs. export function applyFlips(rect: Rect, flipX: boolean, flipY: boolean): Rect { return { x: flipX ? -rect.x : rect.x, @@ -90,15 +81,8 @@ export function applyFlips(rect: Rect, flipX: boolean, flipY: boolean): Rect { }; } -// isMirrorInvariant(rects, parentOrient) → boolean -// -// True iff mirroring across the perp axis (the axis flipped by `mirror=true`) -// produces the same rect list (within OVERLAP_EPS). Used to skip mirror=true -// variants when they'd be no-ops. -// -// For X-orient parent: mirror flips X. Invariant iff for every rect there's a -// matching rect with -x and same y, w, d. -// For Y-orient parent: mirror flips Y. Symmetric. +// Whether mirroring leaves the rect list unchanged, in which case the +// mirror=true variants are no-ops and get skipped. export function isMirrorInvariant(rects: Rect[], parentOrient: StreetAxis): boolean { // Empty list is trivially invariant. if (rects.length === 0) return true; @@ -113,9 +97,8 @@ export function isMirrorInvariant(rects: Rect[], parentOrient: StreetAxis): bool if (c - half < lo) lo = c - half; if (c + half > hi) hi = c + half; } - // Slack: the pair test tolerates eps on both center and width. Erring wide is - // free, since a mirror variant of an invariant list ties and loses the - // tiebreak to its non-mirrored twin. + // Erring wide is free: a mirror of an invariant list ties and loses the + // tiebreak to its twin. if (Math.abs(lo + hi) > 4 * OVERLAP_EPS) return false; // For each rect r in rects, the mirrored r must also exist in rects. @@ -150,31 +133,18 @@ interface FindSmallestValidStemParams { parentOriginY: number; priorStem: number; // alphabetical-monotonic: stem ≥ priorStem originPad: number; // stem ≥ originPad (parent's join clearance) - // Sibling clearance is split by kind: a building reserves buildingGap, a - // side street reserves streetGap. The clearance between the placing child and - // any obstacle is max(gap for childKind, gap for the obstacle's kind), so a - // pair touching a side street gets the (typically larger) street gap on both - // sides while building↔building stays at buildingGap. + // Clearance is max(child's gap, obstacle's gap), so anything next to a side + // street gets the street gap while building↔building keeps the smaller one. buildingGap: number; streetGap: number; childKind: WorldRectKind; // kind of the child being placed occupancy: WorldOccupancy; // global occupancy structure } -// findSmallestValidStem — for one (side, mirror) variant, compute the -// smallest stem ≥ max(priorStem, originPad) such that translating every -// child rect by (side, mirror, stem, parentOrigin) doesn't overlap any -// rect in occupancy. Uses the forbidden-interval union algorithm for -// gap-fit packing. -// -// Optional `trace` param: when provided, the function fills in -// trace.forbidden with the obstacle + child-rect provenance of every -// forbidden interval, trace.bindingIndex with the index of the interval that -// set the final stem (or null if the chosen stem equals the baseline), and -// trace.stem with the returned value. The trace param has no effect on the -// algorithm or return value; it is purely an out-parameter. -// Interval endpoints for one call; module-level and reused (called in a loop, -// never recursively, so one pair of buffers is safe). +// For one (side, mirror) variant: the smallest stem at or above the baseline +// where no child rect overlaps, from the union of what each obstacle forbids. + +// Interval endpoints, reused: called in a loop, never recursively. let _loScratch = new Float64Array(256); let _hiScratch = new Float64Array(256); @@ -193,9 +163,8 @@ export function findSmallestValidStem( // Gap the placing child reserves around itself (by its kind). const placingGap = p.childKind === WorldRectKind.Street ? p.streetGap : p.buildingGap; - // The stem only ever grows from this baseline, so obstacles behind the - // frontier can never bind — clip the query's low end past them (maxSep keeps - // the clip conservative, so output stays bit-identical). + // The stem only grows, so nothing behind the frontier can bind: clipping the + // query there is conservative enough to stay bit-identical. const baseline = Math.max(p.priorStem, p.originPad); const maxSep = Math.max(p.buildingGap, p.streetGap); @@ -240,11 +209,8 @@ export function findSmallestValidStem( const gMinAlong = alongIsX ? g.minX : g.minY; const gMaxAlong = alongIsX ? g.maxX : g.maxY; - // Separation to this obstacle. The parent-body phantom isn't a sibling — - // the gap to it is just the building baseline (its branch-join spacing - // comes from PARENT_JOIN_PAD via originPad, not the sibling gap). For real - // siblings it's max-of-both-kinds, so a side street keeps its clearance on - // both sides regardless of placement order. + // The parent-body phantom is not a sibling: PARENT_JOIN_PAD spaces it. + // Real siblings take max-of-both-kinds, whatever the placement order. const obsGap = g.kind === WorldRectKind.Street ? p.streetGap : p.buildingGap; const sep = g.parentBody ? p.buildingGap : Math.max(placingGap, obsGap); const lower = gMinAlong - alongMax0 - sep; @@ -265,10 +231,8 @@ export function findSmallestValidStem( } } - // Smallest stem ≥ baseline not covered by the forbidden intervals: grow s - // through their contiguous chain from the baseline. Avoiding the sort here - // (the chain is a few links) was the bulk of the speedup. trace keeps the - // sorted scan so its bindingIndex stays meaningful. + // Grow s through the chain rather than sorting: the chain is a few links, and + // that was the bulk of the speedup. trace still sorts, for bindingIndex. let s = baseline; if (tracing) { forbidden.sort((a, b) => a.lower - b.lower); @@ -319,16 +283,10 @@ interface PlaceChildParams { parentOrient: StreetAxis; parentOriginX: number; parentOriginY: number; - /** Single-value floor. If `priorStems` is also supplied, it takes - * precedence; this remains for tests and call sites that don't care about - * per-side tracking. */ + /** One floor for both sides. `priorStems` wins where it is given. */ priorStem: number; - /** Per-side floors. priorStems[0] applies when evaluating side-0 variants, - * priorStems[1] for side-1. When omitted, falls back to `priorStem` on - * both sides (the original cross-side alphabetical-monotonic behavior). - * When supplied, lets a child on side A fit at a lower stem than its - * alphabetical predecessor on side B — the alphabetical-monotonic - * invariant is then enforced PER SIDE rather than across both sides. */ + /** A floor per side, so alphabetical-monotonic holds per side rather than + * across both: a child can fit below its predecessor on the other one. */ priorStems?: readonly [number, number]; originPad: number; buildingGap: number; @@ -343,19 +301,8 @@ export interface PlaceChildResult { mirror: boolean; } -// placeChild — evaluates 4 (side × mirror) variants and picks the one with -// smallest stem. If mirror is invariant (the rect list is unchanged by -// perp-axis flip), only 2 variants (mirror=false) are evaluated. -// -// Tiebreak chain (when stems tie within OVERLAP_EPS): -// 1. Smaller stem wins (strict). -// 2. If stems tie: side 0 over side 1. -// 3. If sides tie: natural over mirrored. -// 4. If both tie: smaller stem within eps (mostly redundant). -// -// Optional `trace` param: when provided, every variant evaluated (including -// the loser ones) pushes a VariantTrace into trace.variants. No effect on -// algorithm or return value. +// The smallest stem across the 4 (side × mirror) variants, or 2 when mirroring +// is invariant. Ties go to side 0, then to natural over mirrored. export function placeChild(p: PlaceChildParams, trace?: PlaceChildTrace): PlaceChildResult { _profCount('placeChild.calls', 1); _profCount('placeChild.childRects', p.childRects.length); diff --git a/app/src/city/layout/streetPath.ts b/app/src/city/scene/layout/streetPath.ts similarity index 98% rename from app/src/city/layout/streetPath.ts rename to app/src/city/scene/layout/streetPath.ts index bd65a53e8..8d3a91493 100644 --- a/app/src/city/layout/streetPath.ts +++ b/app/src/city/scene/layout/streetPath.ts @@ -3,7 +3,8 @@ // on purpose: tests pass minimal mocks, real Streets/PickTargets satisfy // them without casts. -import { NodeKind, StreetAxis } from '@/types'; +import { NodeKind } from '@/types'; +import { StreetAxis } from '@/city/scene/types'; import { parentDirPath } from '../utils/path'; /** Minimal street shape these helpers read. Real Streets satisfy this. */ diff --git a/app/src/city/layout/worker.ts b/app/src/city/scene/layout/worker.ts similarity index 61% rename from app/src/city/layout/worker.ts rename to app/src/city/scene/layout/worker.ts index 3afe5200b..ee564fcc8 100644 --- a/app/src/city/layout/worker.ts +++ b/app/src/city/scene/layout/worker.ts @@ -1,27 +1,15 @@ -// city/layout/worker.ts — Web Worker entry point. Receives a manifest -// + a snapshot of the four config stores layoutCity reads, populates -// the worker's local store instances, runs the layout, posts the -// result back. Pure compute, no DOM or THREE.* references. +// city/layout/worker.ts — Web Worker entry point. Receives a manifest and the +// city's own layout config, runs the layout, posts the result back. Pure +// compute, no DOM or THREE.* references. import { layoutCity } from './algorithm'; import { createPackReporter } from './packProgress'; -import { STREET_LAYOUT, STREET_TIERS } from '@/state/settings/fields/streets'; -import { BUILDING_DIMENSIONS } from '@/state/settings/fields/buildings'; -import { GEM_SIZING } from '@/state/settings/fields/gem'; import type { LayoutRequest, LayoutResponse } from './protocol'; -function _applySnapshot(snap: LayoutRequest['configSnapshot']): void { - STREET_LAYOUT.value = { ...STREET_LAYOUT.value, ...snap.streetLayout }; - BUILDING_DIMENSIONS.value = { ...BUILDING_DIMENSIONS.value, ...snap.buildingDimensions }; - GEM_SIZING.value = { ...GEM_SIZING.value, ...snap.gemSizing }; - STREET_TIERS.value = { TIERS: snap.streetTiers }; -} - self.addEventListener('message', (event: MessageEvent) => { const data = event.data; if (!data || data.type !== 'layout') return; try { - _applySnapshot(data.configSnapshot); // The scanner already counted the tree, so the denominator is free. The // main thread is idle awaiting this reply, so each tick repaints at once. const onPlaced = createPackReporter(data.manifest.tree.descendants_count, (percent) => { @@ -30,6 +18,7 @@ self.addEventListener('message', (event: MessageEvent) => { }); const layout = layoutCity( data.manifest as unknown as Parameters[0], + data.configSnapshot, onPlaced ); const reply: LayoutResponse = { diff --git a/app/src/city/render/LOADING.md b/app/src/city/scene/render/LOADING.md similarity index 60% rename from app/src/city/render/LOADING.md rename to app/src/city/scene/render/LOADING.md index f442a0258..a76e64fc9 100644 --- a/app/src/city/render/LOADING.md +++ b/app/src/city/scene/render/LOADING.md @@ -28,6 +28,30 @@ is invisible; a frame early is the bug. A build that errored will never present anything, so the overlay cannot wait for a frame that is not coming. `Error` ends the wait and shows the failure. +## What the overlay does with it + +`Idle` is the _only_ thing that takes the loading overlay down. Not the stream +ending, not the heights landing: a scan streams three cities (structure, then +per-file metadata, then git history), and the last of them is the one with +trees in it, because commits are what trees are made of. Lifting at metadata +revealed a correct-looking city that then grew trees under you, so the overlay +now waits out the git walk too — the `Reading history` row is that wait. + +The cost is honest: a cold scan of a big repo holds the overlay through minutes +of `git log`. Cancel is right there, and every row says which stage is running. + +A build with no stream behind it raises the overlay by itself. Re-opening the +project you were just in never fetches anything — the manifest is still in hand +— but leaving `/city` unmounted the canvas, so the whole city gets packed again +from scratch. That rebuild is the entire wait, and it used to run behind a blank +stage. `CITY_ON_SCREEN` is what tells the two apart: a rebuild UNDER a city (a +settings Save, a live update) is the footer's to report, not the overlay's. + +The landing's wallpaper builds through this same pipeline and is _not_ the +world, so it reports through a silent `BuildReporter`. Otherwise its `markIdle` +left "a city is on screen" true for a canvas the landing had already thrown +away, and the next open skipped its overlay. + ## Everything downstream gets this for free `captureHarness` waits for `Idle` before taking a screenshot, and used to be diff --git a/app/src/city/render/backdropRadius.ts b/app/src/city/scene/render/backdropRadius.ts similarity index 95% rename from app/src/city/render/backdropRadius.ts rename to app/src/city/scene/render/backdropRadius.ts index 5a80288ec..d7204d210 100644 --- a/app/src/city/render/backdropRadius.ts +++ b/app/src/city/scene/render/backdropRadius.ts @@ -3,7 +3,7 @@ // project, so measuring in that put one setting on top of a small repo and // miles off a large one. -import type { WorldBounds } from '@/city/utils/floorBounds'; +import type { WorldBounds } from '@/city/scene/utils/floorBounds'; export interface BackdropGeometry { /** The gem's own radius, from the layout. */ diff --git a/app/src/city/render/cameraRig.ts b/app/src/city/scene/render/cameraRig.ts similarity index 92% rename from app/src/city/render/cameraRig.ts rename to app/src/city/scene/render/cameraRig.ts index 97396191c..de7ccb9ff 100644 --- a/app/src/city/render/cameraRig.ts +++ b/app/src/city/scene/render/cameraRig.ts @@ -18,24 +18,17 @@ import { CAMERA_INITIAL_DISTANCE_MULT, CAMERA_BASE_DURATION_MS, CAMERA_EASING_POWER, -} from '@/city/constants/camera'; -import { NodeKind, StreetAxis } from '@/types'; -import type { Building, PickTarget, Street } from '@/types'; -import type { CityState } from '@/city/state'; +} from '@/city/scene/constants/camera'; +import { NodeKind } from '@/types'; +import { StreetAxis } from '@/city/scene/types'; +import type { Building, Street } from '@/city/scene/types'; +import type { PickTarget } from '@/city/scene/types/picker'; +import type { CityBuild } from '@/city/scene/build'; import { computeFramingDir } from './framingDir'; -import { CAMERA } from '@/state/settings/fields/camera'; -import { HOME_BACKDROP } from '@/state/settings/fields/homeBackdrop'; -import { GEM_SIZING } from '@/state/settings/fields/gem'; -import { gemRadiusFor } from '@/city/components/gem/mesh'; +import type { CityConfig } from '@/city/session/config'; +import { gemRadiusFor } from '@/city/scene/components/gem/mesh'; import { backdropRadius } from './backdropRadius'; -// The backdrop fields that PLACE the camera. Rotation speed is out: dragging it -// mid-spin should change the speed, not yank the orbit back to its start azimuth. -const BACKDROP_POSE = computed(() => { - const s = HOME_BACKDROP.value; - return `${s.ELEVATION}|${s.AZIMUTH}|${s.DISTANCE}`; -}); - /** How a focus command frames the node it looks at. */ export enum FocusMode { /** Swing overhead and pull in until the node fills the frame: what asking to @@ -110,14 +103,23 @@ function _autoRotateFor(mode: CameraMode, wanted?: boolean): boolean { export function createCameraRig({ canvas, deps, - cityState, + build, + config, mode: initialMode = CameraMode.Project, }: { canvas: HTMLCanvasElement; deps: CameraRigDeps; - cityState: CityState; + build: CityBuild; + /** What this city looks like: its opening pose and its gem's size. */ + config: CityConfig; mode?: CameraMode; }) { + // The backdrop fields that PLACE the camera. Rotation speed is out: dragging + // it mid-spin should change the speed, not yank the orbit back to its start. + const backdropPose = computed(() => { + const s = config.HOME_BACKDROP.value; + return `${s.ELEVATION}|${s.AZIMUTH}|${s.DISTANCE}`; + }); const W = canvas.clientWidth; const H = canvas.clientHeight; const camera = new THREE.PerspectiveCamera( @@ -155,7 +157,7 @@ export function createCameraRig({ // Refresh the framed pose without moving the user's camera. Re-run per // manifest swap, or R would still target the previous city's framing. function _captureFraming(): boolean { - const bbox = cityState.bbox.value; + const bbox = build.bbox.value; if (!bbox || bbox.isEmpty()) return false; // Drive maxDistance + camera.far, so zooming all the way out shows the @@ -191,8 +193,8 @@ export function createCameraRig({ // Sized to the root street, not the world bbox: R should land on a // readable neighbourhood, not a metropolis with the gem as a dot. - const gemPos = cityState.gemWorldPos.value; - const rootStreet = cityState.rootStreet.value; + const gemPos = build.gemWorldPos.value; + const rootStreet = build.rootStreet.value; let framingCenter: THREE.Vector3; let framingRadius: number; if (gemPos && rootStreet) { @@ -211,15 +213,15 @@ export function createCameraRig({ // Behind the gem along the root street's axis (the gem sits at its low // end), lifted and swung by the user's angle. A CAMERA effect re-frames. const dir = computeFramingDir( - CAMERA.value.ELEVATION, - CAMERA.value.AZIMUTH, + config.CAMERA.value.ELEVATION, + config.CAMERA.value.AZIMUTH, rootStreet ? rootStreet.orientation : null ); // Smallest D fitting the tallest roof's 4 corners in the vertical FOV: // D ≥ |p · cam_up| / tan(halfFov) + p · dir, maxed over the corners. let heightDist = 0; - const tallest = gemPos && rootStreet ? cityState.tallestBuilding.value : null; + const tallest = gemPos && rootStreet ? build.tallestBuilding.value : null; const labelBounds = gemPos && rootStreet ? deps.getRepoLabelBounds() : null; if (tallest || labelBounds) { const sinElev = dir.y; @@ -280,7 +282,7 @@ export function createCameraRig({ // bbox is reassigned only on a non-reuse apply, which is exactly when the // framing should update. Whether to SNAP is the composer's call, not this. const _disposeReframeEffect = effect(() => { - void cityState.bbox.value; + void build.bbox.value; // Untracked: _captureFraming reads gem/rootStreet too, and the dependency // set should be exactly what this effect claims. untracked(_captureFraming); @@ -289,7 +291,7 @@ export function createCameraRig({ // A saved angle re-frames the project it steers: reset() reads it and snaps. // On construction the bbox is empty, so this no-ops. const _disposeCameraAngleEffect = effect(() => { - void CAMERA.value; + void config.CAMERA.value; untracked(() => { if (mode === CameraMode.Project) reset(); }); @@ -405,7 +407,7 @@ export function createCameraRig({ // Sub-centimetre horizontal offset is nadir: the root-street axis stands // in, or the azimuth NaNs out. if (horizLenSq < 1e-4) { - const root = cityState.rootStreet.value; + const root = build.rootStreet.value; if (root && root.orientation === StreetAxis.X) { dirX = -1; dirZ = 0; @@ -492,7 +494,7 @@ export function createCameraRig({ const dir = computeFramingDir( opts.elevation, opts.azimuth, - cityState.rootStreet.value?.orientation ?? null + build.rootStreet.value?.orientation ?? null ); camera.up.set(0, 1, 0); camera.position.copy(opts.target).addScaledVector(dir, distance); @@ -511,9 +513,9 @@ export function createCameraRig({ cityRadius: number; rootStreetWidth: number; } { - const gem = cityState.gemWorldPos.value; - const tb = cityState.tallestBuilding.value; - const bbox = cityState.bbox.value; + const gem = build.gemWorldPos.value; + const tb = build.tallestBuilding.value; + const bbox = build.bbox.value; let center: THREE.Vector3 | null = null; let cityRadius = 0; if (bbox && !bbox.isEmpty()) { @@ -527,7 +529,7 @@ export function createCameraRig({ center, tallestHeight: tb ? tb.h : 0, cityRadius, - rootStreetWidth: cityState.rootStreet.value?.width ?? 0, + rootStreetWidth: build.rootStreet.value?.width ?? 0, }; } @@ -544,7 +546,7 @@ export function createCameraRig({ function streetAnchor( path: string ): { pos: THREE.Vector3; width: number; length: number } | null { - const s = cityState.streetsByDirMap.value[path]; + const s = build.streetsByDirMap.value[path]; if (!s) return null; return { pos: new THREE.Vector3(s.x, 0, s.y), width: s.width, length: s.length }; } @@ -563,7 +565,7 @@ export function createCameraRig({ /** The distance the default camera rests at, which the backdrop counts in. * Same inputs as _captureFraming's widthDist, so 1 lands where opening does. */ function _gemFitDistance(): number | null { - const rootStreet = cityState.rootStreet.value; + const rootStreet = build.rootStreet.value; if (!rootStreet) return null; const halfFov = (camera.fov * Math.PI) / 180 / 2; const framingRadius = rootStreet.width * CAMERA_GEM_FRAMING_WIDTH_MULT; @@ -572,24 +574,24 @@ export function createCameraRig({ /** Where the backdrop orbit sits, in units of that framing distance. */ function _backdropRadius(distance: number): number { - const rootStreet = cityState.rootStreet.value; + const rootStreet = build.rootStreet.value; return backdropRadius(distance, controls, { - gemRadius: rootStreet ? gemRadiusFor(rootStreet.width, GEM_SIZING.value) : null, + gemRadius: rootStreet ? gemRadiusFor(rootStreet.width, config.GEM_SIZING.value) : null, gemFitDistance: _gemFitDistance(), - worldBounds: cityState.latestWorldBounds.value, + worldBounds: build.latestWorldBounds.value, }); } /** A ground-level orbit circling the root gem, sized from the city's own * geometry so every project is framed in proportion. */ function _backdropPose(): CameraPlacement | null { - const gem = cityState.gemWorldPos.value; + const gem = build.gemWorldPos.value; if (!gem) return null; - const backdrop = HOME_BACKDROP.value; + const backdrop = config.HOME_BACKDROP.value; const dir = computeFramingDir( backdrop.ELEVATION, backdrop.AZIMUTH, - cityState.rootStreet.value?.orientation ?? null + build.rootStreet.value?.orientation ?? null ); const target = gem.clone(); return { @@ -609,7 +611,7 @@ export function createCameraRig({ // Saving a Home backdrop draft re-frames a running turntable in place, so the // wallpaper reflects the new pose without a reload. const _disposeBackdropPoseEffect = effect(() => { - void BACKDROP_POSE.value; + void backdropPose.value; untracked(() => { if (mode === CameraMode.Backdrop) reset(); }); @@ -618,7 +620,7 @@ export function createCameraRig({ // Sole writer of autoRotateSpeed: runs on construction and on every change, so // no mode swap has to set it. const _disposeBackdropSpeedEffect = effect(() => { - controls.autoRotateSpeed = HOME_BACKDROP.value.ROTATE_SPEED; + controls.autoRotateSpeed = config.HOME_BACKDROP.value.ROTATE_SPEED; }); function dispose() { diff --git a/app/src/city/render/frameLoop.ts b/app/src/city/scene/render/frameLoop.ts similarity index 70% rename from app/src/city/render/frameLoop.ts rename to app/src/city/scene/render/frameLoop.ts index 323bf77e7..ac131f031 100644 --- a/app/src/city/render/frameLoop.ts +++ b/app/src/city/scene/render/frameLoop.ts @@ -1,13 +1,7 @@ -// city/render/frameLoop.ts — generic requestAnimationFrame driver. Wired by -// createCity (city/index.ts) as the scene's live loop: -// perFrame.before → rig.update → world-matrix refresh → each -// component.tick(dt, frame) in array order → perFrame.after → postFx.render. -// -// dt/time contract: one performance.now() -// sample per frame; time = seconds since start; dt = 0 on the first frame, -// else clamped non-negative delta. Tick order is the caller's components-array -// order (sky must be passed LAST — its camera-follow must run immediately -// before postFx.render). +// city/render/frameLoop.ts — the requestAnimationFrame driver: before → rig → +// world matrices → each component.tick in the caller's order → after → render. +// One now() per frame; dt is 0 on the first. Sky must be passed LAST, since its +// camera-follow has to run immediately before postFx.render. import type * as THREE from 'three'; @@ -48,9 +42,8 @@ export function startFrameLoop( perFrame.before?.(f); perFrame.rig.update(0); - // controls.update() moves the camera but matrixWorldInverse is stale until - // a render runs; ticks below project mesh positions and need fresh world - // matrices. + // The camera moved, but its inverse is stale until a render: the ticks + // below project mesh positions and need the matrices fresh. perFrame.rig.camera.updateMatrixWorld(); ctx.scene.updateMatrixWorld(); for (const c of components) c.tick?.(dt, f); diff --git a/app/src/city/render/framingDir.ts b/app/src/city/scene/render/framingDir.ts similarity index 73% rename from app/src/city/render/framingDir.ts rename to app/src/city/scene/render/framingDir.ts index ec3e0a67c..27bb83869 100644 --- a/app/src/city/render/framingDir.ts +++ b/app/src/city/scene/render/framingDir.ts @@ -4,17 +4,12 @@ // unit-testable without the rig/canvas. import * as THREE from 'three'; -import { StreetAxis } from '@/types'; +import { StreetAxis } from '@/city/scene/types'; const DEG2RAD = Math.PI / 180; -/** - * Unit camera-offset direction for the start framing. `elevationDeg` lifts the - * camera above the horizon (90° = straight overhead); `azimuthDeg` swings it - * around the gem, measured off the root street's long axis (0° = looking - * straight down the street from behind the gem). `axis` is the root street's - * orientation, or null when there's no gem (a high-oblique fallback). - */ +/** Unit camera offset for the start framing: elevation lifts off the horizon, + * azimuth swings around the gem from the root street's axis. No axis: oblique. */ export function computeFramingDir( elevationDeg: number, azimuthDeg: number, diff --git a/app/src/city/render/postFx.ts b/app/src/city/scene/render/postFx.ts similarity index 90% rename from app/src/city/render/postFx.ts rename to app/src/city/scene/render/postFx.ts index 46bf4b7de..ca071e938 100644 --- a/app/src/city/render/postFx.ts +++ b/app/src/city/scene/render/postFx.ts @@ -9,7 +9,8 @@ import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js'; -import { BLOOM } from '@/state/settings/fields/effects'; +import type { BloomConfig } from '@/city/session/settings/effects'; +import type { ReadonlySignal } from '@preact/signals'; // Fraction of the DRAWING BUFFER, not the CSS box: composer.setSize already // applies the pixel ratio, so CSS sizing cost DPR-1 displays 4x per scene pixel. @@ -24,9 +25,11 @@ export interface PostFx { export function createPostFx( renderer: THREE.WebGLRenderer, scene: THREE.Scene, - camera: THREE.PerspectiveCamera + camera: THREE.PerspectiveCamera, + /** This city's bloom settings: the pass follows what it is set to. */ + bloomConfig: ReadonlySignal ): PostFx { - const bloomCfg = BLOOM.value; + const bloomCfg = bloomConfig.value; // Reused by setSize so the per-resize drawing-buffer read costs no alloc. const _drawingBuffer = new THREE.Vector2(); // ACES squashes >1.0 back into display range: walls (already [0,1]) are @@ -55,7 +58,7 @@ export function createPostFx( // BLOOM Save → live knobs, no renderer rebuild. ENABLED off bypasses the // pass entirely, pairing with the shader's clamped emission for a flat look. const stopBloom = effect(() => { - const cfg = BLOOM.value; + const cfg = bloomConfig.value; bloom.enabled = cfg.ENABLED; bloom.strength = cfg.STRENGTH; bloom.radius = cfg.RADIUS; diff --git a/app/src/city/timeline/replay.ts b/app/src/city/scene/scrub/replay.ts similarity index 100% rename from app/src/city/timeline/replay.ts rename to app/src/city/scene/scrub/replay.ts diff --git a/app/src/city/timeline/scrubController.ts b/app/src/city/scene/scrub/scrubController.ts similarity index 75% rename from app/src/city/timeline/scrubController.ts rename to app/src/city/scene/scrub/scrubController.ts index 1801b8340..653b814a2 100644 --- a/app/src/city/timeline/scrubController.ts +++ b/app/src/city/scene/scrub/scrubController.ts @@ -1,13 +1,15 @@ // Timeline mode's per-frame driver: read the frame, run the pass, hand each // component its slice. It writes nobody else's buffers. -import { TIMELINE_BUNDLE, SCRUB_TODAY_MS } from '@/state/stores/timeline'; -import type { RangeStat, Street } from '@/types'; -import type { BuildingIndex } from '@/city/components/buildings/buildingIndex'; -import type { BuildingScrubState } from '@/city/components/buildings/scrubState'; -import type { StreetScrubState } from '@/city/components/streets/scrubState'; -import type { HeightContext } from '@/city/layout/dimensions'; -import type { createPicker } from '@/city/interaction/picker'; +import type { TimelineStore } from '@/city/session/stores/timeline'; +import type { CityConfig } from '@/city/session/config'; +import type { RangeStat } from '@/types'; +import type { Street } from '@/city/scene/types'; +import type { BuildingIndex } from '@/city/scene/components/buildings/buildingIndex'; +import type { BuildingScrubState } from '@/city/scene/components/buildings/scrubState'; +import type { StreetScrubState } from '@/city/scene/components/streets/scrubState'; +import type { HeightContext } from '@/city/scene/layout/dimensions'; +import type { createPicker } from '@/city/scene/interaction/picker'; import type { PathTimeline } from './replay'; import { readScrubFrame } from './scrubFrame'; import { createScrubPass, type ScrubStates } from './scrubPass'; @@ -23,6 +25,10 @@ export interface ScrubGate { } export interface ScrubControllerDeps { + /** The history being scrubbed: this city's, never the app's. */ + timeline: TimelineStore; + /** And what that city looks like, read fresh each frame. */ + config: CityConfig; buildings: { /** The union set the pass decides over. */ getBuildingIndex(): BuildingIndex | null; @@ -46,12 +52,11 @@ export interface ScrubControllerDeps { export function createScrubController(deps: ScrubControllerDeps) { // Fixed for the life of the controller; readScrubFrame owns everything that // varies per frame. - const bundle = TIMELINE_BUNDLE.peek(); + const bundle = deps.timeline.bundle.peek(); const commitMs = (bundle?.commits ?? []).map((c) => parseDateMs(c.date) || 0); - const commitDateRanges = bundle?.commitDateRanges ?? []; const scannedAtMs = parseDateMs(deps.scannedAt ?? '') || (commitMs.at(-1) ?? 0); // The same last stop the bar ends on, so the two agree about the far end. - const trackEndMs = SCRUB_TODAY_MS.peek() ?? scannedAtMs; + const trackEndMs = deps.timeline.todayMs.peek() ?? scannedAtMs; const pass = createScrubPass({ buildingIndex: deps.buildings.getBuildingIndex(), @@ -62,8 +67,9 @@ export function createScrubController(deps: ScrubControllerDeps) { function update(): void { const frame = readScrubFrame({ + config: deps.config, + scrubPos: deps.timeline.scrubPos.peek(), commitLineRanges: deps.commitLineRanges, - commitDateRanges, commitMs, trackEndMs, byteStats: deps.heightCtx.byteStats, diff --git a/app/src/city/timeline/scrubFrame.ts b/app/src/city/scene/scrub/scrubFrame.ts similarity index 67% rename from app/src/city/timeline/scrubFrame.ts rename to app/src/city/scene/scrub/scrubFrame.ts index f7e36838e..b035ec5b0 100644 --- a/app/src/city/timeline/scrubFrame.ts +++ b/app/src/city/scene/scrub/scrubFrame.ts @@ -1,15 +1,13 @@ -// The only per-frame reader of SCRUB_POS, the ruin/building stores +// The only per-frame reader of the scrub position, the ruin/building stores // and the picker. Everything downstream takes a ScrubFrame value. -import { SCRUB_POS } from '@/state/stores/timeline'; -import { BUILDING_DIMENSIONS, BUILDINGS } from '@/state/settings/fields/buildings'; -import { RUINS } from '@/state/settings/fields/ruins'; +import type { BuildingsConfig, BuildingDimensionsConfig } from '@/city/session/settings/buildings'; +import type { CityConfig } from '@/city/session/config'; import { NodeKind } from '@/types'; -import type { FileNode, RangeStat, Street, TimelineBundle } from '@/types'; -import { resolveDirTarget } from '@/city/components/buildings/fadeTiers'; -import type { createPicker } from '@/city/interaction/picker'; - -export type CommitDateRange = NonNullable[number]; +import type { FileNode, RangeStat } from '@/types'; +import type { Street } from '@/city/scene/types'; +import { resolveDirTarget } from '@/city/scene/components/buildings/fadeTiers'; +import type { createPicker } from '@/city/scene/interaction/picker'; /** Already in THREE's working space: components rather than a CSS string keep * the scrub decision free of THREE. */ @@ -31,18 +29,18 @@ export interface ScrubFrame { /** What "now" means at this position: the commit under the scrubber, or the * end of the track once past the last one. */ nowMs: number; - minCreated: number; - createdSpread: number; bldgTargetFile: FileNode | null; dirTarget: ReturnType; hoverFile: FileNode | null; - fadeCfg: ReturnType; + fadeCfg: BuildingsConfig; + /** The dimensions a scrubbed building is re-measured with. */ + buildingDims: BuildingDimensionsConfig; } export interface ScrubFrameDeps { + /** Where this city's scrubber is, read by its caller once per frame. */ + scrubPos: number; commitLineRanges: readonly RangeStat[]; - /** Replayed over the present set, so at HEAD weathering matches Live. */ - commitDateRanges: readonly CommitDateRange[]; /** Commit dates as ms, for resolving what "now" is mid-scrub. */ commitMs: readonly number[]; /** What the far end of the track means. The bar's last stop is the same @@ -51,6 +49,8 @@ export interface ScrubFrameDeps { byteStats: RangeStat; streetsByDir: Record; picker: Pick, 'selection' | 'hover'>; + /** What this city looks like, as the scrub reads it each frame. */ + config: CityConfig; } /** The date the handle sits on, interpolated toward the next commit so a quiet @@ -63,17 +63,13 @@ function scrubNow(pos: number, deps: ScrubFrameDeps): number { } export function readScrubFrame(deps: ScrubFrameDeps): ScrubFrame { - const pos = SCRUB_POS.peek(); + const pos = deps.scrubPos; - // SCRUB_POS is clamped to the bundle; only the range arrays can fall short. + // The position is clamped to the bundle; only the ranges can fall short. const r = deps.commitLineRanges[Math.min(Math.floor(pos), deps.commitLineRanges.length - 1)]; - const floorHeight = BUILDING_DIMENSIONS.peek().FLOOR_HEIGHT; - const ruins = RUINS.peek(); - - const dateRange = - deps.commitDateRanges[Math.min(Math.floor(pos), deps.commitDateRanges.length - 1)]; - const minCreated = dateRange?.minCreated ?? 0; + const floorHeight = deps.config.BUILDING_DIMENSIONS.peek().FLOOR_HEIGHT; + const ruins = deps.config.RUINS.peek(); // The fader is dormant in Timeline, so resolve its targets here instead. const sel = deps.picker.selection.peek(); @@ -89,11 +85,10 @@ export function readScrubFrame(deps: ScrubFrameDeps): ScrubFrame { ruinHeight: ruins.STUB_HEIGHT * floorHeight, ruinGrayMix: ruins.DESATURATION, nowMs: scrubNow(pos, deps), - minCreated, - createdSpread: (dateRange?.maxCreated ?? 0) - minCreated, bldgTargetFile: sel?.kind === NodeKind.File ? sel.file : null, dirTarget: resolveDirTarget(sel, hov, deps.streetsByDir), hoverFile: hov?.kind === NodeKind.File ? hov.file : null, - fadeCfg: BUILDINGS.peek(), + fadeCfg: deps.config.BUILDINGS.peek(), + buildingDims: deps.config.BUILDING_DIMENSIONS.peek(), }; } diff --git a/app/src/city/timeline/scrubPass.ts b/app/src/city/scene/scrub/scrubPass.ts similarity index 90% rename from app/src/city/timeline/scrubPass.ts rename to app/src/city/scene/scrub/scrubPass.ts index 5becc49fd..757e47e8b 100644 --- a/app/src/city/timeline/scrubPass.ts +++ b/app/src/city/scene/scrub/scrubPass.ts @@ -2,22 +2,22 @@ // because street opacity is a rollup over descendant buildings, so the // buildings must be walked before the streets resolve. -import type { Building, Street } from '@/types'; -import type { BuildingIndex } from '@/city/components/buildings/buildingIndex'; +import type { Building, Street } from '@/city/scene/types'; +import type { BuildingIndex } from '@/city/scene/components/buildings/buildingIndex'; import { BuildingLane, blankBuildingScrubState, resolveBuildingScrubState, type BuildingScrubInput, type BuildingScrubState, -} from '@/city/components/buildings/scrubState'; +} from '@/city/scene/components/buildings/scrubState'; import { resolveStreetScrubState, type StreetRollup, type StreetScrubState, -} from '@/city/components/streets/scrubState'; -import { streetChainForDirPath } from '@/city/layout/streetPath'; -import { parentDirPath } from '@/city/utils/path'; +} from '@/city/scene/components/streets/scrubState'; +import { streetChainForDirPath } from '@/city/scene/layout/streetPath'; +import { parentDirPath } from '@/city/scene/utils/path'; import type { PathTimeline } from './replay'; import type { ScrubFrame } from './scrubFrame'; diff --git a/app/src/types/animation.ts b/app/src/city/scene/types/animation.ts similarity index 100% rename from app/src/types/animation.ts rename to app/src/city/scene/types/animation.ts diff --git a/app/src/city/scene/types/building.ts b/app/src/city/scene/types/building.ts new file mode 100644 index 000000000..f25f68dbd --- /dev/null +++ b/app/src/city/scene/types/building.ts @@ -0,0 +1,42 @@ +// types/building.ts — pairs with state/stores/settings/buildings.ts (the tunables). +// Defines the Building shape the layout step produces; the city renderer +// (city/index.ts createCityScene) reads it to instantiate three.js meshes. + +import type { FileNode } from '@/types'; + +/** Direction the door faces. Layout sets this; engine reads it. */ +export enum BuildingOrient { + North = 'n', + South = 's', + East = 'e', + West = 'w', +} + +/** One building in the laid-out city. */ +export interface Building { + /** Center on the ground plane, in world units. */ + x: number; + y: number; + /** Footprint width (x) and depth (z), and height (y). */ + w: number; + d: number; + h: number; + /** HSL, from colors.ts. Keys on last-modified, unlike createdAge below. */ + color: string; + /** 0..1 weathering, oldest-created at 1, normalised against the repo so it + * rescales as the codebase ages. Filled in after layout, so optional. */ + createdAge?: number; + + /** createdAge's mirror on the modified axis: 1 is longest untouched. */ + modifiedAge?: number; + file: FileNode; + orient: BuildingOrient; + floors?: number; + + /** Spatial-grid bucket, set on CellTile insertion; BuildingIndex reverses + * a raycaster hit back to a building through it. */ + cellId?: number; + + /** Slot within that cell's InstancedMesh; "cellId:slotId" keys the index. */ + slotId?: number; +} diff --git a/app/src/city/types/index.ts b/app/src/city/scene/types/index.ts similarity index 53% rename from app/src/city/types/index.ts rename to app/src/city/scene/types/index.ts index 921c2665b..cbe68dfca 100644 --- a/app/src/city/types/index.ts +++ b/app/src/city/scene/types/index.ts @@ -1,14 +1,37 @@ // city/types/index.ts — the shape every scene component, the composer and the // render loop agree on. Types only. +// The city's own model — what it is made of, where that sits, and what a pick +// returns. The manifest it is built FROM is the server's contract (@/types). +export * from './building'; +export * from './street'; +export * from './scene'; +export * from './animation'; +export * from './picker'; + import type * as THREE from 'three'; import type { Picker } from '../interaction/picker'; +import type { CityConfig } from '@/city/session/config'; +import type { BuildingMaterial } from '../components/buildings/material'; import type { CameraRig } from '../render/cameraRig'; -import type { CityState } from '../state'; +import type { CityBuild } from '../build'; import type { Trees } from '../components/trees/treeRenderer'; -import type { PathTimeline } from '../timeline/replay'; +import type { PathTimeline } from '../scrub/replay'; +import type { ReadonlySignal } from '@preact/signals'; import type { Manifest, RangeStat } from '@/types'; import type { BuildStage } from '@/constants/progress'; +import type { TimelineStore } from '@/city/session/stores/timeline'; + +/** What a city asks of whatever it is mounted in: it says what the user asked + * for and the app decides what moves. No chrome, and these are no-ops. */ +export interface CityChrome { + /** True while something else owns the keyboard, so scene shortcuts hold. */ + keyboardBusy(): boolean; + /** The user asked what this node IS: the details are the answer. */ + showDetails(): void; + /** The user asked to LOOK at the city: move whatever is in the way. */ + revealCity(): void; +} /** What a component needs to wire itself in. picker is null until after the * components exist, so anything needing it arms on the first tick. */ @@ -16,7 +39,19 @@ export interface SceneContext { scene: THREE.Scene; canvas: HTMLCanvasElement; picker: Picker; - cityState: CityState; + /** What the last apply made of this city, and how to apply another. */ + build: CityBuild; + /** What this city looks like. Every visual setting is read through here, so + * nothing under city/ reaches for the panel's own signals. */ + config: CityConfig; + /** This city's building material and icon atlas: every cell of it shares + * one, so a uniform written there reaches this city and no other. */ + buildingMaterial: BuildingMaterial; + /** What this city asks of whatever it is mounted in. */ + chrome: CityChrome; + /** This city's history state, for the components drawing what a scrub + * implies. Its own, so a second city scrubs its own. */ + timeline: TimelineStore; } /** Per-frame state passed to each component's tick() method. */ @@ -61,8 +96,11 @@ export interface CityTimeline { setFootprintsTransparent(on: boolean): void; } -/** The top-level city object returned by the city composer (createCity). */ -export interface City { +/** The machinery drawing one city: its canvas, renderer, camera and picker, + * and the verbs that swap what it is showing. The city itself is the session. */ +export interface CityScene { + /** What this city is showing: the manifest its last apply landed. */ + manifest: ReadonlySignal; scene: THREE.Scene; picker: Picker; rig: CameraRig; @@ -70,8 +108,14 @@ export interface City { /** The stages that apply would run, for a caller whose own work comes first. */ buildStagesFor(m: Manifest): BuildStage[]; invalidateLayoutCache(): void; + /** Re-pack what this city is already showing: the settings path, where the + * manifest has not moved and only the packer's inputs have. */ + repack(): Promise; + /** Resolves once a frame carrying the latest build has been presented, which + * WebGL will not tell you. `report.markIdle`'s moment, asked instead of told. */ + whenOnScreen(): Promise; world: CityWorld; - /** Timeline-mode install surface (see hooks/useTimelineMode). The controller + /** Timeline-mode install surface (see state/city/timelineMode). The controller * is built here because it needs the components' mesh/attr resolvers. */ timeline: CityTimeline; /** Tear all of it down. Required on unmount, or a remount stacks a second diff --git a/app/src/types/picker.ts b/app/src/city/scene/types/picker.ts similarity index 87% rename from app/src/types/picker.ts rename to app/src/city/scene/types/picker.ts index 88be64670..4c6fffd61 100644 --- a/app/src/types/picker.ts +++ b/app/src/city/scene/types/picker.ts @@ -1,12 +1,13 @@ -// types/picker.ts — selection / hover state shapes used by the -// raycaster and consumed by every selection-driven renderer. +// city/scene/types/picker.ts — what the raycaster hands back, and what every +// selection-driven renderer reads: one shape per kind of thing you can hit. import type * as THREE from 'three'; import type { Building } from './building'; import type { Street } from './street'; -import type { CommitEntry, DirNode, FileNode, NodeKind } from './manifest'; -import type { BuildingIndex } from '@/city/components/buildings/buildingIndex'; -import type { CellTile } from '@/city/components/buildings/cellTile'; +import type { CommitEntry, DirNode, FileNode } from '@/types'; +import { NodeKind } from '@/types'; +import type { BuildingIndex } from '@/city/scene/components/buildings/buildingIndex'; +import type { CellTile } from '@/city/scene/components/buildings/cellTile'; /** Hovered/selected file (a building mesh). */ export interface FileTarget { diff --git a/app/src/types/scene.ts b/app/src/city/scene/types/scene.ts similarity index 97% rename from app/src/types/scene.ts rename to app/src/city/scene/types/scene.ts index 8e4014a10..d69d4c104 100644 --- a/app/src/types/scene.ts +++ b/app/src/city/scene/types/scene.ts @@ -3,7 +3,7 @@ import type { Building } from './building'; import type { Street } from './street'; -import type { FileStats, RangeStat } from './manifest'; +import type { FileStats, RangeStat } from '@/types'; /** Axis-aligned bounding box around the entire laid-out city. */ export interface CityBbox { diff --git a/app/src/types/street.ts b/app/src/city/scene/types/street.ts similarity index 96% rename from app/src/types/street.ts rename to app/src/city/scene/types/street.ts index f8d5ee904..1fd6540d1 100644 --- a/app/src/types/street.ts +++ b/app/src/city/scene/types/street.ts @@ -1,7 +1,7 @@ // types/street.ts — pairs with state/stores/settings/streets.ts (the tunables). // Defines the Street shape the layout step produces. -import type { DirNode } from './manifest'; +import type { DirNode } from '@/types'; /** Street long-axis. 'x' = runs along world-X, 'y' = along world-Z. */ export enum StreetAxis { diff --git a/app/src/city/utils/armOnFirstTick.ts b/app/src/city/scene/utils/armOnFirstTick.ts similarity index 100% rename from app/src/city/utils/armOnFirstTick.ts rename to app/src/city/scene/utils/armOnFirstTick.ts diff --git a/app/src/city/utils/color/colors.ts b/app/src/city/scene/utils/color/colors.ts similarity index 100% rename from app/src/city/utils/color/colors.ts rename to app/src/city/scene/utils/color/colors.ts diff --git a/app/src/city/utils/color/setColorFromHex.ts b/app/src/city/scene/utils/color/setColorFromHex.ts similarity index 100% rename from app/src/city/utils/color/setColorFromHex.ts rename to app/src/city/scene/utils/color/setColorFromHex.ts diff --git a/app/src/city/utils/disposeObject3D.ts b/app/src/city/scene/utils/disposeObject3D.ts similarity index 100% rename from app/src/city/utils/disposeObject3D.ts rename to app/src/city/scene/utils/disposeObject3D.ts diff --git a/app/src/city/utils/floorBounds.ts b/app/src/city/scene/utils/floorBounds.ts similarity index 88% rename from app/src/city/utils/floorBounds.ts rename to app/src/city/scene/utils/floorBounds.ts index f7032eab8..813b76a37 100644 --- a/app/src/city/utils/floorBounds.ts +++ b/app/src/city/scene/utils/floorBounds.ts @@ -2,8 +2,8 @@ // floor mesh and the tree scatter so they cannot drift apart. Centred on the // bbox rather than the gem, so a gem at one edge still gets even tree coverage. // A missing bbox falls back to a small rectangle at the origin. -import type { CityBbox } from '@/types'; -import { WORLD } from '@/state/settings/fields/island'; +import type { CityBbox } from '@/city/scene/types'; +import type { WorldConfig } from '@/city/session/settings/island'; /** Fallback half-extent when no bbox is available (pre-layout, * non-git smoke tests). Keeps the floor visible at the origin. */ @@ -24,6 +24,7 @@ export interface WorldBounds { * so a small-footprint repo with tall buildings is not cramped on screen. */ export function getWorldBounds( bbox: CityBbox | null | undefined, + world: WorldConfig, cityHeight: number = 0 ): WorldBounds { if (!bbox) { @@ -34,7 +35,7 @@ export function getWorldBounds( halfDepth: FALLBACK_HALF_DIM, }; } - const bufferFrac = WORLD.value.GROUND_BUFFER_PERCENT / 100; + const bufferFrac = world.GROUND_BUFFER_PERCENT / 100; const characteristicSize = Math.max(bbox.width, bbox.depth, cityHeight); const buffer = characteristicSize * bufferFrac; return { diff --git a/app/src/city/utils/nextPaint.ts b/app/src/city/scene/utils/nextPaint.ts similarity index 100% rename from app/src/city/utils/nextPaint.ts rename to app/src/city/scene/utils/nextPaint.ts diff --git a/app/src/city/scene/utils/onConfig.ts b/app/src/city/scene/utils/onConfig.ts new file mode 100644 index 000000000..2ead6534b --- /dev/null +++ b/app/src/city/scene/utils/onConfig.ts @@ -0,0 +1,12 @@ +// city/scene/utils/onConfig.ts — run `apply` now and on every change to ONE +// section of this city's config, and only that one: it runs untracked, so +// nothing else it reads becomes a dependency. Which is why a component that +// wants two sections cannot use this — the second would be dropped silently. +import { effect, untracked, type ReadonlySignal } from '@preact/signals'; + +export function onConfig(section: ReadonlySignal, apply: () => void): () => void { + return effect(() => { + void section.value; // the ONLY tracked dependency + untracked(apply); + }); +} diff --git a/app/src/city/utils/path.ts b/app/src/city/scene/utils/path.ts similarity index 100% rename from app/src/city/utils/path.ts rename to app/src/city/scene/utils/path.ts diff --git a/app/src/city/utils/rainbowChase.ts b/app/src/city/scene/utils/rainbowChase.ts similarity index 89% rename from app/src/city/utils/rainbowChase.ts rename to app/src/city/scene/utils/rainbowChase.ts index a26c151cd..179c79739 100644 --- a/app/src/city/utils/rainbowChase.ts +++ b/app/src/city/scene/utils/rainbowChase.ts @@ -4,7 +4,7 @@ // hue = wrap(timeMs * SPEED + fraction) -> setHSL(hue, SATURATION, LIGHTNESS) import * as THREE from 'three'; -import { RAINBOW } from '@/state/settings/fields/effects'; +import type { RainbowConfig } from '@/city/session/settings/effects'; // Scratch reused across calls so steady-state frames allocate nothing. const _scratchColor = new THREE.Color(); @@ -15,9 +15,10 @@ const _scratchOut: [number, number, number] = [0, 0, 0]; export function rainbowRgbAt( timeMs: number, fraction: number, + rainbow: RainbowConfig, out: [number, number, number] = _scratchOut ): [number, number, number] { - const rb = RAINBOW.value; + const rb = rainbow; const t = timeMs * rb.SPEED; const hue = (((t + fraction) % 1) + 1) % 1; _scratchColor.setHSL(hue, rb.SATURATION, rb.LIGHTNESS); diff --git a/app/src/city/utils/reactiveRebuild.ts b/app/src/city/scene/utils/reactiveRebuild.ts similarity index 100% rename from app/src/city/utils/reactiveRebuild.ts rename to app/src/city/scene/utils/reactiveRebuild.ts diff --git a/app/src/city/utils/recency.ts b/app/src/city/scene/utils/recency.ts similarity index 100% rename from app/src/city/utils/recency.ts rename to app/src/city/scene/utils/recency.ts diff --git a/app/src/city/utils/safeLineMaterial.ts b/app/src/city/scene/utils/safeLineMaterial.ts similarity index 93% rename from app/src/city/utils/safeLineMaterial.ts rename to app/src/city/scene/utils/safeLineMaterial.ts index 2aaf20867..6f511c71f 100644 --- a/app/src/city/utils/safeLineMaterial.ts +++ b/app/src/city/scene/utils/safeLineMaterial.ts @@ -4,7 +4,7 @@ // seen top-down), which Android GLES rasterizes as giant flickering triangles. import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; -import { NEUTRAL_POLYGON_OFFSET } from '@/city/utils/neutralPolygonOffset'; +import { NEUTRAL_POLYGON_OFFSET } from '@/city/scene/constants/polygonOffset'; // String-based swap: the test pins the stock shader text so a three upgrade // that rewrites it goes red instead of silently dropping the guard. diff --git a/app/src/city/utils/safeLineSegmentsGeometry.ts b/app/src/city/scene/utils/safeLineSegmentsGeometry.ts similarity index 96% rename from app/src/city/utils/safeLineSegmentsGeometry.ts rename to app/src/city/scene/utils/safeLineSegmentsGeometry.ts index 6c61595a1..68c1df941 100644 --- a/app/src/city/utils/safeLineSegmentsGeometry.ts +++ b/app/src/city/scene/utils/safeLineSegmentsGeometry.ts @@ -5,7 +5,7 @@ import * as THREE from 'three'; import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; -import { FLOATS_PER_SEGMENT, VEC3_COMPONENTS } from '@/city/utils/bufferLayout'; +import { FLOATS_PER_SEGMENT, VEC3_COMPONENTS } from '@/city/scene/constants/bufferLayout'; export class SafeLineSegmentsGeometry extends LineSegmentsGeometry { override setPositions(array: Float32Array | number[]): this { diff --git a/app/src/city/utils/shaders/fog.glsl b/app/src/city/scene/utils/shaders/fog.glsl similarity index 100% rename from app/src/city/utils/shaders/fog.glsl rename to app/src/city/scene/utils/shaders/fog.glsl diff --git a/app/src/city/utils/shaders/hash.glsl b/app/src/city/scene/utils/shaders/hash.glsl similarity index 100% rename from app/src/city/utils/shaders/hash.glsl rename to app/src/city/scene/utils/shaders/hash.glsl diff --git a/app/src/city/utils/shaders/hsl.glsl b/app/src/city/scene/utils/shaders/hsl.glsl similarity index 100% rename from app/src/city/utils/shaders/hsl.glsl rename to app/src/city/scene/utils/shaders/hsl.glsl diff --git a/app/src/city/utils/shaders/registerShaderChunks.ts b/app/src/city/scene/utils/shaders/registerShaderChunks.ts similarity index 100% rename from app/src/city/utils/shaders/registerShaderChunks.ts rename to app/src/city/scene/utils/shaders/registerShaderChunks.ts diff --git a/app/src/city/utils/shaders/sunDir.ts b/app/src/city/scene/utils/shaders/sunDir.ts similarity index 100% rename from app/src/city/utils/shaders/sunDir.ts rename to app/src/city/scene/utils/shaders/sunDir.ts diff --git a/app/src/city/scene/utils/until.ts b/app/src/city/scene/utils/until.ts new file mode 100644 index 000000000..f8d762542 --- /dev/null +++ b/app/src/city/scene/utils/until.ts @@ -0,0 +1,25 @@ +// utils/until.ts — await a signal condition. Two places had grown their own +// effect-and-resolve dance (a scene appearing, a frame landing), and one of +// them kept a queue of callbacks to fan out to. + +import { effect } from '@preact/signals'; + +/** Resolves as soon as `holds()` is true, reading it through the signals it + * touches. Already true: resolves without waiting a tick. */ +export function until(holds: () => boolean): Promise { + if (holds()) return Promise.resolve(); + return new Promise((resolve) => { + let stop: (() => void) | null = null; + let done = false; + stop = effect(() => { + if (!holds() || done) return; + done = true; + // Out of the effect's own run: disposing it from inside its body is a + // write during the flush, and `stop` is still being assigned besides. + queueMicrotask(() => { + stop?.(); + resolve(); + }); + }); + }); +} diff --git a/app/src/city/sceneHandle.ts b/app/src/city/sceneHandle.ts deleted file mode 100644 index f48c2f48e..000000000 --- a/app/src/city/sceneHandle.ts +++ /dev/null @@ -1,148 +0,0 @@ -// city/sceneHandle.ts — the live scene handle, and the commands the UI sends -// through it. Not app state: it holds a Three.js object and calls methods on -// it, so it lives with the renderer rather than in state/stores. - -import { signal, effect } from '@preact/signals'; -import type { createCity } from './index'; -import type { FocusMode } from './render/cameraRig'; -import { SIDEBAR_COLLAPSED, dismissSelectionPane, openSelectionPane } from '@/state/stores/chrome'; -import { IS_PHONE } from '@/state/stores/viewport'; - -export type SceneHandle = Awaited>; - -/** The city on the /city route. Only the Scene variant publishes here. */ -export const SCENE_HANDLE = signal(null); - -/** The landing's wallpaper city: a different city on a different canvas, so - * sharing a slot made whichever mounted last the other's applyManifest target. */ -export const BACKDROP_HANDLE = signal(null); - -/** Resolves once the city exists. A boot load can outrun createCity, and a load - * that finds no handle has nowhere to put its city. */ -export function whenSceneHandle(): Promise { - const ready = SCENE_HANDLE.peek(); - if (ready) return Promise.resolve(ready); - return new Promise((resolve) => { - const stop = effect(() => { - const handle = SCENE_HANDLE.value; - if (!handle) return; - resolve(handle); - queueMicrotask(() => stop()); - }); - }); -} - -/** Phone: the left drawer covers the city, so a camera move behind it is one you - * can't see. It's the whole screen there and a column everywhere else. */ -function collapseDrawerOnPhone(): void { - if (IS_PHONE.peek()) SIDEBAR_COLLAPSED.value = true; -} - -/** Asking to focus something is asking to look at it, so every focus command - * clears what's in the way and leaves the chip standing in for the details. */ -function revealCity(): void { - dismissSelectionPane(); - collapseDrawerOnPhone(); -} - -/** The other half of that choice: you asked for the node by name, so its details - * are the answer, and only the phone drawer has to move. */ -function revealDetails(): void { - openSelectionPane(); - collapseDrawerOnPhone(); -} - -/** What a command points the camera at: a node by path, a commit by sha, or - * whatever is already selected. */ -type NodeRef = { path: string } | { sha: string } | null; - -/** Select what `ref` names and aim the camera at it: the one place a ref becomes - * a focus. False when there is nothing to look at, so the chrome stays put. */ -function _pointAt(ref: NodeRef, mode?: FocusMode): boolean { - const handle = SCENE_HANDLE.peek(); - if (!handle) return false; - const sel = - ref === null - ? handle.picker.selection.peek() - : 'sha' in ref - ? handle.picker.selectByCommit(ref.sha) - : handle.picker.selectByPath(ref.path); - if (!sel) return false; - handle.rig.focusSelection(sel, mode); - return true; -} - -// Thin wrappers the UI calls instead of reaching into the handle itself. All -// no-op before the scene boots. - -/** Hover-highlight the node at `path` (tree-row hover → city highlight). */ -export function hoverPath(path: string): void { - SCENE_HANDLE.peek()?.picker.hoverByPath(path); -} - -/** Clear the hover highlight. */ -export function clearHover(): void { - SCENE_HANDLE.peek()?.picker.setHover(null); -} - -/** Clear the current selection (closes the contextual right sidebar). */ -export function clearSelection(): void { - SCENE_HANDLE.peek()?.picker.clearSelection(); -} - -/** Focus a node, selecting it first if it isn't: an almanac row is a Focus - * button for something you haven't picked yet. Re-selecting is identity. */ -export function focusPath(path: string, mode?: FocusMode): void { - if (_pointAt({ path }, mode)) revealCity(); -} - -/** focusPath for a commit's tree, by sha. */ -export function focusCommit(sha: string, mode?: FocusMode): void { - if (_pointAt({ sha }, mode)) revealCity(); -} - -/** Focus whatever is selected, whichever kind. Here rather than in the key - * handler: a keystroke and a Focus button are the same request. */ -export function focusSelection(mode?: FocusMode): void { - if (_pointAt(null, mode)) revealCity(); -} - -/** Go to a node named in a list. The details open, unlike the Focus commands: - * there you act on what's in front of you, here you asked for the name. */ -export function goToPath(path: string, mode?: FocusMode): void { - if (_pointAt({ path }, mode)) revealDetails(); -} - -/** goToPath for a commit's tree, by sha (almanac landmarks). */ -export function goToCommit(sha: string, mode?: FocusMode): void { - if (_pointAt({ sha }, mode)) revealDetails(); -} - -/** A commit's details, with the camera left alone: the timeline's own row, - * where you are already looking at what you asked about. */ -export function showCommit(sha: string): void { - const handle = SCENE_HANDLE.peek(); - if (!handle) return; - handle.picker.selectByCommit(sha); - openSelectionPane(); -} - -/** Reset the camera framing to the current mode's default pose. */ -export function resetView(): void { - SCENE_HANDLE.peek()?.rig.reset(); -} - -/** Debug: run the building/street collision check. */ -export function runCollisionCheck(): void { - SCENE_HANDLE.peek()?.world.runCollisionCheck(); -} - -/** Debug: run the stem-placement diagnostic. */ -export function runStemDiagnostic(): void { - SCENE_HANDLE.peek()?.world.runStemPlacementDiagnostic(); -} - -/** Debug: audit every tree's contact with the ground. */ -export function runTreeGroundingCheck(): void { - SCENE_HANDLE.peek()?.world.runTreeGroundingDiagnostic(); -} diff --git a/app/src/api/file.ts b/app/src/city/session/api/file.ts similarity index 100% rename from app/src/api/file.ts rename to app/src/city/session/api/file.ts diff --git a/app/src/api/fingerprint.ts b/app/src/city/session/api/fingerprint.ts similarity index 94% rename from app/src/api/fingerprint.ts rename to app/src/city/session/api/fingerprint.ts index ff02597ee..5f4987c8c 100644 --- a/app/src/api/fingerprint.ts +++ b/app/src/city/session/api/fingerprint.ts @@ -3,7 +3,7 @@ // never reaches the client, only the picture of its byte distribution. import { apiUrl } from '@/api/apiUrl'; -import { fetchContent } from '@/api/file'; +import { fetchContent } from '@/city/session/api/file'; import type { SourceRef } from '@/types'; /** URL for a binary file's fingerprint PNG, addressed like /api/file. `mtime` diff --git a/app/src/api/manifest.ts b/app/src/city/session/api/manifest.ts similarity index 100% rename from app/src/api/manifest.ts rename to app/src/city/session/api/manifest.ts diff --git a/app/src/api/timeline.ts b/app/src/city/session/api/timeline.ts similarity index 100% rename from app/src/api/timeline.ts rename to app/src/city/session/api/timeline.ts diff --git a/app/src/city/session/commands.ts b/app/src/city/session/commands.ts new file mode 100644 index 000000000..21027cb12 --- /dev/null +++ b/app/src/city/session/commands.ts @@ -0,0 +1,115 @@ +// state/city/commands.ts — the verbs the chrome sends one city's scene, and the +// wait for that scene to exist. App-side glue, not renderer: it moves panes and +// drawers as well as the camera. Every command works on the session it was made +// for, so the chrome can only ever move the city it is looking at. + +import { type Signal } from '@preact/signals'; +import { until } from '@/city/scene/utils/until'; +import type { CityScene } from '@/city/scene/types'; +import type { FocusMode } from '@/city/scene/render/cameraRig'; +import type { CitySession } from '@/city/session/session'; + +/** Resolves once this city's scene exists. A boot load can outrun the scene, + * and a load that finds none has nowhere to put its manifest. */ +export async function whenScene(scene: Signal): Promise { + await until(() => scene.value !== null); + return scene.peek()!; +} + +/** What a command points the camera at: a node by path, a commit by sha, or + * whatever is already selected. */ +type NodeRef = { path: string } | { sha: string } | null; + +/** The verbs the chrome sends one city. All no-op before its scene boots, and + * none of them can reach another session's: the scene comes from this one. */ +export class CityCommands { + constructor(private readonly session: CitySession) {} + + /** Select what `ref` names and aim the camera at it: the one place a ref + * becomes a focus. False when there is nothing to look at. */ + private pointAt(ref: NodeRef, mode?: FocusMode): boolean { + const scene = this.session.scene.peek(); + if (!scene) return false; + const sel = + ref === null + ? scene.picker.selection.peek() + : 'sha' in ref + ? scene.picker.selectByCommit(ref.sha) + : scene.picker.selectByPath(ref.path); + if (!sel) return false; + scene.rig.focusSelection(sel, mode); + return true; + } + + // Arrow properties throughout: these are handed to onClick and to prop + // callbacks, where a plain method would arrive unbound. + + /** Hover-highlight the node at `path` (tree-row hover → city highlight). */ + hoverPath = (path: string): void => { + this.session.scene.peek()?.picker.hoverByPath(path); + }; + + clearHover = (): void => { + this.session.scene.peek()?.picker.setHover(null); + }; + + /** Clear the selection, which closes the contextual right sidebar. */ + clearSelection = (): void => { + this.session.scene.peek()?.picker.clearSelection(); + }; + + /** Focus a node, selecting it first if it isn't: an almanac row is a Focus + * button for something you haven't picked yet. Re-selecting is identity. */ + focusPath = (path: string, mode?: FocusMode): void => { + if (this.pointAt({ path }, mode)) this.session.chrome.revealCity(); + }; + + focusCommit = (sha: string, mode?: FocusMode): void => { + if (this.pointAt({ sha }, mode)) this.session.chrome.revealCity(); + }; + + /** Focus whatever is selected, whichever kind: a keystroke and a Focus + * button are the same request. */ + focusSelection = (mode?: FocusMode): void => { + if (this.pointAt(null, mode)) this.session.chrome.revealCity(); + }; + + /** Go to a node named in a list. The details open, unlike the Focus commands: + * there you act on what's in front of you, here you asked for the name. */ + goToPath = (path: string, mode?: FocusMode): void => { + if (this.pointAt({ path }, mode)) this.session.chrome.showDetails(); + }; + + goToCommit = (sha: string, mode?: FocusMode): void => { + if (this.pointAt({ sha }, mode)) this.session.chrome.showDetails(); + }; + + /** A commit's details with the camera left alone: the timeline's own row, + * where you are already looking at what you asked about. */ + showCommit = (sha: string): void => { + const scene = this.session.scene.peek(); + if (!scene) return; + scene.picker.selectByCommit(sha); + this.session.chrome.showDetails(); + }; + + /** Reset the camera framing to the current mode's default pose. */ + resetView = (): void => { + this.session.scene.peek()?.rig.reset(); + }; + + /** Debug: the building/street collision check. */ + runCollisionCheck = (): void => { + this.session.scene.peek()?.world.runCollisionCheck(); + }; + + /** Debug: the stem-placement diagnostic. */ + runStemDiagnostic = (): void => { + this.session.scene.peek()?.world.runStemPlacementDiagnostic(); + }; + + /** Debug: audit every tree's contact with the ground. */ + runTreeGroundingCheck = (): void => { + this.session.scene.peek()?.world.runTreeGroundingDiagnostic(); + }; +} diff --git a/app/src/city/session/config.ts b/app/src/city/session/config.ts new file mode 100644 index 000000000..5d1092ba4 --- /dev/null +++ b/app/src/city/session/config.ts @@ -0,0 +1,75 @@ +// state/city/config.ts — what ONE city looks like. Every visual setting the +// renderer reads comes from here rather than from a module singleton, which is +// what lets two cities on screen differ: same fields, its own values. + +import { signal, computed, type Signal, type ReadonlySignal } from '@preact/signals'; +import { fieldsOf, type ChangeRoute, type SettingStore } from '@/state/settings/schema'; +import { BUILDINGS, BUILDING_DIMENSIONS } from './settings/buildings'; +import { TREES } from './settings/trees'; +import { GEM, GEM_SIZING, REPO_LABEL } from './settings/gem'; +import { ISLAND, WORLD } from './settings/island'; +import { STREETS, STREET_TIERS, STREET_LAYOUT } from './settings/streets'; +import { FOOTPRINT } from './settings/footprint'; +import { FIREFLIES } from './settings/fireflies'; +import { RUINS } from './settings/ruins'; +import { RAINBOW, BLOOM } from './settings/effects'; +import { SCENE } from './settings/scene'; +import { CAMERA } from './settings/camera'; +import { HOME_BACKDROP } from './settings/homeBackdrop'; + +/** One city's settings. Each section follows the panel until this city is given + * its own value for it, so the opened city tracks what you save. */ +export class CityConfig { + // app store -> this city's value for it. Iterated by signature(), so the + // rebuild trigger is this city's fields, not every city's. + private readonly sections = new Map>(); + private readonly own = new Map>(); + + readonly BUILDINGS = this.follow(BUILDINGS); + readonly BUILDING_DIMENSIONS = this.follow(BUILDING_DIMENSIONS); + readonly TREES = this.follow(TREES); + readonly GEM = this.follow(GEM); + readonly GEM_SIZING = this.follow(GEM_SIZING); + readonly REPO_LABEL = this.follow(REPO_LABEL); + readonly ISLAND = this.follow(ISLAND); + readonly WORLD = this.follow(WORLD); + readonly STREETS = this.follow(STREETS); + readonly STREET_TIERS = this.follow(STREET_TIERS); + readonly STREET_LAYOUT = this.follow(STREET_LAYOUT); + readonly FOOTPRINT = this.follow(FOOTPRINT); + readonly FIREFLIES = this.follow(FIREFLIES); + readonly RUINS = this.follow(RUINS); + readonly RAINBOW = this.follow(RAINBOW); + readonly BLOOM = this.follow(BLOOM); + readonly SCENE = this.follow(SCENE); + readonly CAMERA = this.follow(CAMERA); + readonly HOME_BACKDROP = this.follow(HOME_BACKDROP); + + /** Give this city its own value for one section. Nothing calls it yet: the + * seam is the point, and the second caller is a city that looks different. */ + override(app: Signal, value: T): void { + this.own.get(app as SettingStore)!.value = value; + } + + /** Every field with this route, as one string: what a rebuild keys off. */ + signature(route: ChangeRoute): string { + let sig = ''; + for (const [app, mine] of this.sections) { + const values = mine.value as Record; + const fields = fieldsOf(app); + for (const key in fields) { + if (fields[key].route === route) sig += `${key}=${JSON.stringify(values[key])};`; + } + } + return sig; + } + + // Its own value when it has one, the panel's until then. + private follow(app: Signal): ReadonlySignal { + const own = signal(null); + const mine = computed(() => own.value ?? app.value); + this.own.set(app as SettingStore, own as Signal); + this.sections.set(app as SettingStore, mine as ReadonlySignal); + return mine; + } +} diff --git a/app/src/city/session/loader.ts b/app/src/city/session/loader.ts new file mode 100644 index 000000000..deb7cacbe --- /dev/null +++ b/app/src/city/session/loader.ts @@ -0,0 +1,358 @@ +// state/city/loader.ts — the FETCH layer for ONE city: it streams manifests and +// writes that session's stores, and nothing else. Scene-free by contract; the +// render layer consumes those signals and owns the apply's rebuild status. + +import { useEffect } from 'preact/hooks'; +import { effect } from '@preact/signals'; + +import { + manifestUrlFor, + signatureUrlFor, + streamManifest, + ScanPhase, + ScanError, +} from '@/city/session/api/manifest'; +import { LIVE_UPDATES, liveUpdatesActive } from '@/city/session/settings/updates'; +import { activeExcludePathsFor } from '@/city/session/stores/excludes'; +import type { ProgressStore } from '@/city/session/stores/progress'; +import type { CitySession } from '@/city/session/session'; +import { srcKind, SourceKind, identityBranch, sourceKey } from '@/utils/sources'; +import type { UrlView } from '@/router/viewParams'; +import type { Manifest } from '@/types'; +import type { SourcePayload } from '@/types/ui'; + +/** Consume a manifest stream, publishing SCAN_PROGRESS per event and handing + * each skeleton/final to onManifest. Returns the final; throws without one. */ +async function pumpManifestStream( + url: string, + meta: { kind: SourceKind; branch?: string }, + /** Where the stream's own progress goes: this project's readouts. */ + progress: ProgressStore, + onManifest: ( + manifest: Manifest, + phase: ScanPhase.PartialManifest | ScanPhase.CompleteManifest + ) => Promise | void, + signal?: AbortSignal +): Promise { + let lastManifest: Manifest | null = null; + // Only partials are applied in here, so the complete event carries the last + // applied `pending` forward rather than claiming its own as applied. + let appliedPending: Manifest['pending'] | undefined; + + for await (const event of streamManifest(url, { signal })) { + if (event.phase === ScanPhase.Error) throw new ScanError(event.error, event.code); + + if ('label' in event && event.label) { + // Server-side, so the document title and the overlay header name the + // project the same way instead of each deriving it from the src. + progress.pendingLabel.value = event.label; + } + + if (event.phase === ScanPhase.CloneProgress || event.phase === ScanPhase.ScanProgress) { + progress.scan.value = { + ...meta, + phase: event.phase, + percent: event.phase === ScanPhase.CloneProgress ? event.percent : undefined, + stage: event.phase === ScanPhase.CloneProgress ? event.stage : undefined, + mbOnDisk: event.phase === ScanPhase.CloneProgress ? event.mb_on_disk : undefined, + objects: event.phase === ScanPhase.CloneProgress ? event.objects : undefined, + objectsTotal: event.phase === ScanPhase.CloneProgress ? event.objects_total : undefined, + mib: event.phase === ScanPhase.CloneProgress ? event.mib : undefined, + filesScanned: event.phase === ScanPhase.ScanProgress ? event.files_scanned : undefined, + }; + continue; + } + + // tree.name beats the src basename, which for a working tree is whatever + // the folder is called (e.g. a git-worktree dir). + if (event.manifest.tree?.name) progress.pendingLabel.value = event.manifest.tree.name; + await onManifest(event.manifest, event.phase); + lastManifest = event.manifest; + if (event.phase === ScanPhase.PartialManifest) appliedPending = event.manifest.pending; + // After onManifest, so the overlay reaction can never see "heights final" + // ahead of the paint that shows them. + progress.scan.value = { ...meta, phase: event.phase, appliedPending }; + } + + if (!lastManifest) throw new Error('No manifest received'); + return lastManifest; +} + +/** Fetching one city. Every write lands in that session's stores, so two of + * these can stream different repos at once without a shared generation. */ +// Floor: the server walks the filesystem per poll, so tighter burns CPU. +// Ceiling: past a minute "live" stops feeling live. +const POLL_SECONDS_MIN = 1; +const POLL_SECONDS_MAX = 60; + +interface SignatureResponse { + root: string; + scanned_at: string; + content_signature: string; +} + +export class CityLoader { + private readonly disposers: (() => void)[] = []; + + constructor(private readonly session: CitySession) {} + + // Every write is gated on "am I still the current generation?", so a newer + // load silently drops an older one and any in-flight poll write with it. + private generation = 0; + // The controller for the current foreground load, so the UI can cancel a slow + // clone/scan. A new load or a cancel aborts the previous one. + private controller: AbortController | null = null; + + /** The one way to load a source into this city. The poll below is a separate + * op that shares only the manifest sink and yields to this by generation. */ + loadSource = async (payload: SourcePayload): Promise => { + // This attempt supersedes the last failure, so nothing outlives it to explain + // a load that is no longer the current one. + this.session.source.error.value = null; + // A source switch always exits Timeline; the city layer reacts to the flip. + if (this.session.timeline.mode.peek()) this.session.timeline.reset(); + const myGen = ++this.generation; // claim authority; supersedes any in-flight load/poll + this.controller?.abort(); // supersede any in-flight load + const controller = new AbortController(); + this.controller = controller; + // A local source has no branch axis, so a stale deep-link's branch is dropped + // rather than carried into the URL, the overlay and the committed this.session.source. + const branch = identityBranch(payload.src, payload.branch); + // The server's label arrives with the first stream event; until then the + // overlay names the source you asked for. + this.session.progress.pendingLabel.value = payload.label ?? null; + const meta = { + kind: srcKind(payload.src), + branch, + }; + this.session.progress.scan.value = { ...meta, phase: null }; // show overlay immediately + // What a cancel rolls back to, captured before the clear below: otherwise the + // canceled repo's geometry lingers under the unchanged header. + const prevManifest = this.session.manifest.current.peek(); + // What is on screen stays only while re-scanning the very project it shows: + // otherwise the manifest in hand builds a city this load is about to replace. + const keepCityUp = + this.session.source.isOpen(payload.src, branch) && this.session.progress.cityOnScreen.peek(); + if (!keepCityUp) this.session.manifest.set(null); + + try { + const url = manifestUrlFor({ + src: payload.src, + branch, + noCache: !!payload.skipCache, + exclude: activeExcludePathsFor(payload.src), + }); + // Skeleton streams out here; the final is published below, after the + // source is committed. + const loaded = await pumpManifestStream( + url, + meta, + this.session.progress, + (m, phase) => { + if (phase === ScanPhase.PartialManifest && myGen === this.generation) + this.session.manifest.set(m); + }, + controller.signal + ); + // A newer load superseded this one: it owns MANIFEST now, don't touch. + if (myGen !== this.generation) return; + // An aborted stream ends as done, not a throw, so a cancel arrives here + // holding the partial. Roll back rather than commit it. + if (controller.signal.aborted) { + this.session.manifest.set(prevManifest); + return; + } + // One commit point, whichever view loaded it: source, recents, this.session.manifest. + this.session.source.set(payload.src, branch, loaded); + } catch (err) { + if (myGen !== this.generation) return; // superseded — its error isn't current + if (controller.signal.aborted) { + this.session.manifest.set(prevManifest); // user canceled: not an error; roll back any skeleton + return; + } + this.session.source.error.value = { + error: err instanceof Error ? err.message : String(err), + code: err instanceof ScanError ? err.code : undefined, + prefill: { src: payload.src, branch }, + }; + } finally { + // Only the authoritative load tears the overlay down, or a superseded one + // clears it out from under the load still streaming. + if (myGen === this.generation) { + this.session.progress.scan.value = null; + if (this.controller === controller) this.controller = null; + } + } + }; + + /** Abort the in-flight foreground load. Treated as a clean user cancel, not a + * failure: see the `catch` branch above. */ + /** Abort the in-flight load. A clean user cancel, not a failure. */ + cancel = (): void => { + this.controller?.abort(); + }; + + /** Re-read the source already open, in whichever mode it is being viewed: + * Timeline refetches its bundle in place rather than dropping to live HEAD. */ + /** Re-read the source already open, in whichever mode it is being viewed. */ + refresh = (skipCache = false): void => { + const cur = this.session.source.current.peek(); + if (!cur) return; + if (this.session.timeline.mode.peek()) { + // Asked for by hand, so it gets the same stepped overlay a Live refresh + // does: the history walk behind it runs for minutes on a big repo. + void this.session.timelineMode.loadScene({ + inPlace: true, + noCache: skipCache, + overlay: true, + }); + return; + } + void this.loadSource({ src: cur.src, branch: cur.branch, skipCache: skipCache || undefined }); + }; + + // ── Live-update poll loop ──────────────────────────────────────────── + + private clampPollSeconds(s: number | unknown): number { + if (typeof s !== 'number' || !isFinite(s)) return POLL_SECONDS_MIN; + return Math.min(POLL_SECONDS_MAX, Math.max(POLL_SECONDS_MIN, s)); + } + + /** Start the live-update poll loop and the exclude-refresh reaction, returning + * a dispose for both. Exported so the reaction is directly testable. */ + /** Start the live-update poll + the exclude-refresh reaction. */ + setupLiveUpdates = (): (() => void) => { + let timer: number | null = null; + let inFlight = false; + + const fetchAndApply = async (src: string, branch: string | undefined): Promise => { + const myGen = this.generation; // capture; a foreground load bumping this drops our write + try { + for await (const event of streamManifest( + manifestUrlFor({ src, branch, exclude: activeExcludePathsFor(src) }) + )) { + if (event.phase === ScanPhase.Error) throw new ScanError(event.error, event.code); + // Skip the skeleton: the city is already drawn, and applying one would + // animate every building to placeholder heights and back on each save. + if (event.phase !== ScanPhase.CompleteManifest) continue; + if (myGen !== this.generation) return; // a foreground load started — this refresh is stale + const m = event.manifest; + if (m?.content_signature) this.session.manifest.set(m); + } + } catch (err) { + if (myGen !== this.generation) return; // superseded by a load — not our error to surface + this.session.progress.markError(err); + } + }; + + // Cheap signature first, full manifest only when it differs. Targets the + // committed CURRENT_SOURCE, not the page URL, which lags a switch. + const tick = async (): Promise => { + if (inFlight) return; + if (this.session.timeline.mode.peek()) return; // Timeline mode owns the scene (union city + scrub) — no live poll + if (this.session.progress.scan.peek() !== null) return; // a foreground load is in flight — yield + const cur = this.session.source.current.peek(); + if (!cur) return; // nothing loaded yet + const current = this.session.manifest.current.peek(); + if (!current) return; + const applied = (current as Manifest).content_signature; + inFlight = true; + try { + const sigResp = await fetch( + signatureUrlFor(cur.src, cur.branch, activeExcludePathsFor(cur.src)) + ); + if (!sigResp.ok) return; + const sig: SignatureResponse | null = await sigResp.json(); + if (!sig?.content_signature || sig.content_signature === applied) return; + await fetchAndApply(cur.src, cur.branch); + } catch (_) { + // Cheap-probe network blip: no rebuild attempted, so not surfaced. Next + // tick retries. + } finally { + inFlight = false; + } + }; + + const start = (): void => { + stop(); + const seconds = this.clampPollSeconds(LIVE_UPDATES.value.POLL_SECONDS); + timer = window.setInterval(tick, seconds * 1000); + }; + const stop = (): void => { + if (timer != null) { + window.clearInterval(timer); + timer = null; + } + }; + + const disposeEnabledEffect = effect(() => { + // Tracks the source too, so switching between a local tree and a clone + // starts or stops the timer without a reload. + if (liveUpdatesActive(this.session.source)) start(); + else stop(); + }); + + // Excludes need their own trigger, since the poll is gated on + // the poll being active. Key-guarded so a source switch isn't read as an edit. + let lastExcludeKey: string | null = null; + const disposeExcludeRefresh = effect(() => { + const serialized = this.session.source.excludes.value.join('\n'); + const cur = this.session.source.current.peek(); + const repoKey = cur ? sourceKey(cur.src) : null; + const nextKey = repoKey === null ? null : `${repoKey}|${serialized}`; + const prev = lastExcludeKey; + lastExcludeKey = nextKey; + if (prev === null || nextKey === null) return; // first run / no source + const [prevRepo] = prev.split('|', 1); + if (prevRepo !== repoKey) return; // source switched — the load owns it + if (prev === nextKey) return; // no actual change + if (this.session.progress.scan.peek() !== null) return; // yield to a foreground load + if (!cur) return; + if (inFlight) return; // the poll's tick is already covering this refresh + inFlight = true; + // Timeline owns the scene: excludes change the union data, so refetch its + // bundle + re-pack (it reports itself through the readout). Live: re-scan. + let refresh: Promise; + if (this.session.timeline.mode.peek()) { + refresh = this.session.timelineMode.loadScene({ inPlace: true }); + } else { + this.session.progress.markRebuilding(); // say so now, not after the re-scan streams back + refresh = fetchAndApply(cur.src, cur.branch); + } + void refresh.finally(() => { + inFlight = false; + }); + }); + + return () => { + stop(); + disposeEnabledEffect(); + disposeExcludeRefresh(); + }; + }; + + /** The load the URL asks for, in the mode it asks for. A Timeline boot that + * fails to engage falls through to Live, so the page lands on a working city. */ + /** Load whatever the URL names, in the mode it asks for. */ + boot = async (view: UrlView): Promise => { + const src = view.src; + if (!src) return; + if (view.timeline) { + await this.session.timelineMode.loadSource({ + src, + branch: view.branch, + commit: view.commit ?? undefined, + }); + if (this.session.timeline.mode.peek()) return; + } + await this.loadSource({ src, branch: view.branch }); + }; + + dispose = (): void => this.disposers.splice(0).forEach((stop) => stop()); +} + +/** Watch this city for changes on disk while it is mounted. One loop per + * session: it re-reads the signals per tick, so a switch needs no restart. */ +export function useLiveUpdates(session: CitySession): void { + useEffect(() => session.load.setupLiveUpdates(), [session]); +} diff --git a/app/src/state/settings/reactions.ts b/app/src/city/session/rebuildOnSave.ts similarity index 51% rename from app/src/state/settings/reactions.ts rename to app/src/city/session/rebuildOnSave.ts index aa7da7140..788a7627c 100644 --- a/app/src/state/settings/reactions.ts +++ b/app/src/city/session/rebuildOnSave.ts @@ -1,39 +1,34 @@ -// state/settings/reactions.ts — a committed settings change → the scene -// rebuilding or refreshing. Two route-generated signatures, one effect each. -// See README.md. +// city/session/rebuildOnSave.ts — a committed change to THIS city's config → +// this city re-packing or refreshing. Two route-generated signatures, one +// effect each. See state/settings/README.md for how a route is declared. import { computed, effect, untracked } from '@preact/signals'; -import { MANIFEST } from '@/state/stores/manifest'; -import { - REBUILD_STATUS, - RebuildStatus, - LAST_UPDATED_AT, - markRebuilding, - markIdle, - markError, -} from '@/state/stores/progress'; -import { routeSignature, ChangeRoute } from '@/state/settings/schema'; +import { RebuildStatus, type BuildReporter } from '@/city/session/stores/progress'; +import { ChangeRoute } from '@/state/settings/schema'; +import type { CityConfig } from '@/city/session/config'; +import type { CityScene } from '@/city/scene/types'; // Min-dwell for the 'rebuilding' indicator on the material-only path. const HOT_REBUILD_MIN_DWELL_MS = 220; interface SettingsReactionsOpts { - /** Re-pack the current mode's scene (Live: HEAD; Timeline: union + scrub), so a - * Save keeps the user in the mode they're in. */ - rebuildScene(): Promise; - invalidateLayoutCache(): void; + /** The scene to re-pack. It knows what it is showing and how to rebuild it, + * which is why nothing here has to be told either. */ + scene: Pick; + /** That city's status channel, so a Save to one nobody is waiting for cannot + * drive the readouts describing the one they are. */ + report: BuildReporter; + /** What that city looks like: the values these reactions watch. */ + config: CityConfig; } -// ── Change signatures (auto-generated by route) ──────────────────────────── -// Each changes iff a field with that route changes, so neither cross-fires. -const REBUILD_SIGNATURE = computed(() => routeSignature(ChangeRoute.Rebuild)); -const REFRESH_SIGNATURE = computed(() => routeSignature(ChangeRoute.Refresh)); +export function rebuildOnSave({ scene, report, config }: SettingsReactionsOpts): () => void { + // Auto-generated by route, over THIS city's values: each changes iff a field + // with that route changes, so neither cross-fires and neither city does. + const rebuildSignature = computed(() => config.signature(ChangeRoute.Rebuild)); + const refreshSignature = computed(() => config.signature(ChangeRoute.Refresh)); -export function attachSettingsReactions({ - rebuildScene, - invalidateLayoutCache, -}: SettingsReactionsOpts): () => void { // Effects fire synchronously on first call. Suppress reactions until all // subscriptions are wired so the initial fire doesn't trigger a rebuild. let armed = false; @@ -42,22 +37,19 @@ export function attachSettingsReactions({ async function scheduleRebuild() { if (!armed) return; - markRebuilding(); try { // Always invalidate: the manifest is unchanged, so reuseLayoutFrom would // skip the recompute and the setting would do nothing visible. - invalidateLayoutCache(); - // peek, not .value: a tracked read here double-applies with City's render - // effect. MANIFEST is only the "project loaded?" proxy. - if (!MANIFEST.peek()) { - markIdle(); // no world to rebuild — settle immediately + scene.invalidateLayoutCache(); + if (!scene.manifest.peek()) { + report.markIdle(); // nothing on it to rebuild — settle immediately return; } - await rebuildScene(); + await scene.repack(); // Idle is owned by the trees decoration pass (markIdle), the last stage of // applyManifest — setting it here would stomp its Decorating state. } catch (err) { - markError(err); + report.markError(err); } } @@ -66,22 +58,19 @@ export function attachSettingsReactions({ function flagRefresh() { if (!armed) return; if (hotIdleTimer) clearTimeout(hotIdleTimer); - markRebuilding(); + report.markRebuilding(); // Min-dwell so the flash is visible, and only if no real rebuild is in // flight: applyManifest owns the final status then. hotIdleTimer = setTimeout(() => { hotIdleTimer = 0; - if (REBUILD_STATUS.peek() === RebuildStatus.Rebuilding) { - markIdle(); - LAST_UPDATED_AT.value = Date.now(); - } + if (report.status() === RebuildStatus.Rebuilding) report.markIdle(); }, HOT_REBUILD_MIN_DWELL_MS); } // untracked, or the effect subscribes to whatever the imperative work reads // and re-fires on its own writes. const unsubRebuild = effect(() => { - void REBUILD_SIGNATURE.value; // establish tracking + void rebuildSignature.value; // establish tracking if (!armed) return; untracked(() => { void scheduleRebuild(); @@ -89,7 +78,7 @@ export function attachSettingsReactions({ }); const unsubRefresh = effect(() => { - void REFRESH_SIGNATURE.value; // establish tracking + void refreshSignature.value; // establish tracking if (!armed) return; untracked(() => flagRefresh()); }); diff --git a/app/src/city/session/session.ts b/app/src/city/session/session.ts new file mode 100644 index 000000000..f2fa86711 --- /dev/null +++ b/app/src/city/session/session.ts @@ -0,0 +1,70 @@ +// state/city/session.ts — one open city, whole: which repo, its manifest, how +// far its load and build have got, the history it is scrubbed through, and the +// scene drawing it. Nothing here is app-wide, so a second session shares +// nothing — which is what lets two cities sit side by side. + +import { signal, type Signal } from '@preact/signals'; +import { ManifestStore } from '@/city/session/stores/manifest'; +import { SourceStore } from '@/city/session/stores/source'; +import { ProgressStore } from '@/city/session/stores/progress'; +import { TimelineStore } from '@/city/session/stores/timeline'; +import { CityCommands } from '@/city/session/commands'; +import { CityLoader } from '@/city/session/loader'; +import { TimelineMode } from '@/city/session/timelineMode'; +import type { CameraMode } from '@/city/scene/render/cameraRig'; +import type { CityChrome } from '@/city/scene/types'; +import { CityConfig } from '@/city/session/config'; +import type { CityScene } from '@/city/scene/types'; + +/** The city open in one place. Loading a new source swaps its contents rather + * than making a second one; the scene rendering it stays put. */ +/** A city with nothing around it: no panes to open, no drawer to close, and + * nothing else holding the keyboard. What a backdrop gets. */ +const NO_CHROME: CityChrome = { + keyboardBusy: () => false, + showDetails: () => {}, + revealCity: () => {}, +}; + +export interface CitySessionOptions { + /** What a scene of this city is for: the opened city is one you fly around, + * the landing's is a turntable. Left out, it is one you fly around. */ + cameraMode?: CameraMode; + /** What it may ask of the app it is mounted in. Left out, it asks nothing. */ + chrome?: CityChrome; +} + +export class CitySession { + readonly cameraMode?: CameraMode; + readonly chrome: CityChrome; + + constructor(opts: CitySessionOptions = {}) { + this.cameraMode = opts.cameraMode; + this.chrome = opts.chrome ?? NO_CHROME; + } + + /** Order is the dependency graph: the manifest is what a source describes, + * and both are what progress and history are measured against. */ + readonly manifest = new ManifestStore(); + readonly source = new SourceStore(this.manifest); + readonly progress = new ProgressStore(this.manifest, this.source); + readonly timeline = new TimelineStore(this.manifest); + /** What this city looks like. Its own, so a second city can differ. */ + readonly config = new CityConfig(); + + /** The scene drawing this city, while one is mounted. The chrome reaches + * through here to command it; a city with no scene is still a city. */ + readonly scene: Signal = signal(null); + + /** The verbs the chrome sends that scene. */ + readonly commands = new CityCommands(this); + /** Fetching this city: the scan, its cancel, and the live-update poll. */ + readonly load = new CityLoader(this); + /** Timeline for this city: entering, scrubbing, and leaving. */ + readonly timelineMode = new TimelineMode(this); + + dispose(): void { + this.load.dispose(); + this.timeline.dispose(); + } +} diff --git a/app/src/state/settings/fields/buildings.ts b/app/src/city/session/settings/buildings.ts similarity index 95% rename from app/src/state/settings/fields/buildings.ts rename to app/src/city/session/settings/buildings.ts index 58cd80a1b..066e9ce90 100644 --- a/app/src/state/settings/fields/buildings.ts +++ b/app/src/city/session/settings/buildings.ts @@ -9,8 +9,8 @@ import { type ConfigOf, type FieldMap, } from '@/state/settings/schema'; -import { DEFAULT_HUE_EXT_MAP } from '@/constants/buildings'; -import { FadeDetail } from '@/types'; +import { DEFAULT_HUE_EXT_MAP } from '@/city/scene/constants/buildings'; +import { FadeDetail } from '@/city/scene/types'; const FADE_DETAIL_OPTIONS = [ { value: FadeDetail.Full, label: 'Full' }, @@ -129,17 +129,30 @@ export type BuildingDimensionsConfig = ConfigOf; /** The toggle AND a local source. Remote is excluded on cost: ensure_clone * fetches on every open, so polling one means a git fetch every few seconds. */ -export const LIVE_UPDATES_ACTIVE = computed( - () => LIVE_UPDATES.value.ENABLED && CURRENT_SOURCE_IS_LOCAL.value -); +/** Whether this project is being watched for edits: the setting is app-wide, + * but only a working tree on disk can change under the app. */ +export function liveUpdatesActive(source: SourceStore): boolean { + return LIVE_UPDATES.value.ENABLED && source.isLocal.value; +} diff --git a/app/src/city/session/stores/excludes.ts b/app/src/city/session/stores/excludes.ts new file mode 100644 index 000000000..dbdbd99e1 --- /dev/null +++ b/app/src/city/session/stores/excludes.ts @@ -0,0 +1,36 @@ +// state/stores/excludes.ts — the folders you have hidden, keyed by repo rather +// than by session: hiding vendor/ in a repo hides it every time you open it, +// on any branch. + +import { persistedSignal } from '@/state/persist'; +import { PERSISTED_KEYS } from '@/constants/storage'; +import { sourceKey } from '@/utils/sources'; + +/** repo key -> sorted, de-duped rel-paths. Whole-object persistence: the keys + * are runtime repo hashes, so diff-vs-default would drop every write. */ +export const EXCLUDES = persistedSignal>( + PERSISTED_KEYS.EXCLUDES, + {}, + { whole: true } +); + +/** Repo-scoped key: src only (branch ignored) so excludes hold across branches. */ +export function repoKeyFor(src: string): string { + return sourceKey(src); +} + +/** Peek the excludes for an explicit src — for the imperative fetch layer. */ +export function activeExcludePathsFor(src: string): string[] { + return EXCLUDES.peek()[repoKeyFor(src)] ?? []; +} + +/** Replace one repo's exclude list. Sorted and de-duped, and an empty list + * drops the slot so the store holds only repos that hide something. */ +export function setExcludesFor(src: string, next: readonly string[]): void { + const repo = repoKeyFor(src); + const sorted = [...new Set(next)].sort(); + const map = { ...EXCLUDES.peek() }; + if (sorted.length === 0) delete map[repo]; + else map[repo] = sorted; + EXCLUDES.value = map; +} diff --git a/app/src/city/session/stores/manifest.ts b/app/src/city/session/stores/manifest.ts new file mode 100644 index 000000000..fb0f4f661 --- /dev/null +++ b/app/src/city/session/stores/manifest.ts @@ -0,0 +1,22 @@ +// state/stores/manifest.ts — the manifest of one open city, at HEAD. Its +// session's fetch layer writes it and everything in that session reads it. Two +// neighbours answer the same question differently: `timeline.ts` for what a +// scrub position implies, `progress.ts` for how far its build has got. + +import { signal } from '@preact/signals'; +import type { Manifest, DirNode } from '@/types'; + +// The union spans a final Manifest, a bare DirNode, and the loose skeleton the +// stream emits before it is fully typed. +export type ManifestValue = Manifest | DirNode | { tree?: unknown; [k: string]: unknown } | null; + +export class ManifestStore { + /** What this city is, as last fetched. */ + readonly current = signal(null); + + /** Skeleton, final, live-update or a rollback. The fetch layer is the single + * writer; everything else reads. */ + set = (m: ManifestValue): void => { + this.current.value = m ?? null; + }; +} diff --git a/app/src/city/session/stores/progress.ts b/app/src/city/session/stores/progress.ts new file mode 100644 index 000000000..986f60b91 --- /dev/null +++ b/app/src/city/session/stores/progress.ts @@ -0,0 +1,318 @@ +// state/stores/progress.ts — how far along the thing you are waiting for is, +// for ONE city: what the server reports, what the build is doing, and what the +// overlay shows over both. The overlay is a reduction over the other two, +// which is why it needs the driver at the bottom. + +import { signal, computed, effect } from '@preact/signals'; +import { SourceKind } from '@/utils/sources'; +import { ScanPhase, CloneStage } from '@/city/session/api/manifest'; +import type { Manifest } from '@/types'; +import { + BuildStage, + buildStageTail, + type BuildProgress, + LoadingStep, + LOADING_STEPS, + BUILD_ONLY_STEPS, + firstStepFor, + stepForPhase, + transferTail, +} from '@/constants/progress'; +import type { ManifestStore, ManifestValue } from './manifest'; +import type { SourceStore } from './source'; +import type { LoadingOverlayShowOpts, LoadingOverlayState } from '@/types/ui'; + +// ── What the server is doing ───────────────────────────────────────── + +export interface ScanProgress { + /** Kind of source being loaded (drives the overlay's initial step). */ + kind: SourceKind; + branch?: string; + /** Latest stream phase, or null when the load just started and no stream + * event has arrived yet (overlay shows the kind-based initial step). */ + phase: ScanPhase | null; + /** Cloning percent (0-100) when phase === Cloning. */ + percent?: number; + /** Cloning stage (e.g. Receiving, Updating) when present. */ + stage?: CloneStage; + /** Working-tree size on disk (MB) during the silent promisor blob fetch — + * a clone-progress heartbeat with no stage/percent. */ + mbOnDisk?: number; + /** Git's own counts for the clone, where its line carried them. */ + objects?: number; + objectsTotal?: number; + mib?: number; + /** Files scanned so far when phase === Scanning. */ + filesScanned?: number; + /** `pending` of the manifest most recently APPLIED this load. Absent until + * its first manifest event, so a previous repo's can't leak in. */ + appliedPending?: Manifest['pending']; +} + +// ── What the build is doing ────────────────────────────────────────── + +/** State of the most recent world rebuild. Decorating is the city already on + * screen with its deferred pass (trees and friends) still in flight. */ +export enum RebuildStatus { + /** Nothing has been built yet. Distinct from Idle so "a build finished" is + * answerable: consumers that wait for Idle used to pass at boot. */ + Pending = 'pending', + Idle = 'idle', + Rebuilding = 'rebuilding', + Decorating = 'decorating', + Error = 'error', +} + +/** The status writes a city makes as it assembles: how its build reaches the + * readouts describing it, or does not, when nobody is waiting for that one. */ +export interface BuildReporter { + beginBuild(stages: readonly BuildStage[]): void; + enterBuildStage(stage: BuildStage): void; + setBuildStagePercent(percent: number): void; + markRebuilding(): void; + markDecorating(): void; + markIdle(): void; + markError(err: unknown): void; + /** The canvas went away, so whatever it had on it is not on screen. */ + markGone(): void; + /** Read back, for a caller checking whether its own flash still stands. */ + status(): RebuildStatus; +} + +/** How far along the thing you are waiting for is, for ONE city. It IS that + * city's BuildReporter: the scene writes its status straight in here. */ +export class ProgressStore implements BuildReporter { + /** Non-null while this city is actively loading; null when idle/done. */ + readonly scan = signal(null); + readonly rebuildStatus = signal(RebuildStatus.Pending); + /** The manifest the FINISHED city was built from, trees included: a consumer + * that aims the camera at a node needs that node to exist. */ + readonly builtManifest = signal(null); + /** Whether a finished city is on screen RIGHT NOW: set when one is presented, + * cleared when the canvas goes away. Not "has anything ever been built". */ + readonly cityOnScreen = computed(() => this.builtManifest.value !== null); + /** Error message from the most recent failed rebuild; null when idle. */ + readonly lastError = signal(null); + /** Progress beside "rebuilding…", for the one build nothing else reports: + * Timeline's no-overlay refetch. Every transition clears it. */ + readonly detail = signal(null); + /** Epoch millis of the most recent finished apply, in whichever mode. */ + readonly lastUpdatedAt = signal(0); + /** Which stage the running build is on, null between builds. */ + readonly buildProgress = signal(null); + + /** The overlay over this city, and the repo name in its header. */ + readonly overlay = signal({ + visible: false, + showOpts: null, + activeStep: null, + stepTails: {}, + }); + readonly pendingLabel = signal(null); + /** A load that can be backed out of registers its own abort here. */ + readonly cancel = signal<(() => void) | null>(null); + + constructor( + private readonly manifest: ManifestStore, + private readonly source: SourceStore + ) {} + + // ── Status transitions ─────────────────────────────────────────── + // Every rebuild path goes through these, so the set cannot drift. + + markRebuilding = (): void => { + this.rebuildStatus.value = RebuildStatus.Rebuilding; + this.detail.value = null; + this.buildProgress.value = null; + }; + + /** Decoration is the build's last stage, not the end of it: the overlay stays + * up through the tree pass, and the trees land at the END of that stage. */ + markDecorating = (): void => { + this.rebuildStatus.value = RebuildStatus.Decorating; + this.enterBuildStage(BuildStage.Decorate); + }; + + /** The city is on screen. Set by the composer once the meshes exist and a + * frame carrying them has been presented — NOT when applyStructure returns. */ + markIdle = (): void => { + this.rebuildStatus.value = RebuildStatus.Idle; + this.builtManifest.value = this.manifest.current.peek(); + this.lastError.value = null; + this.detail.value = null; + this.buildProgress.value = null; + this.lastUpdatedAt.value = Date.now(); + }; + + /** The canvas went away (leaving `/city` unmounts it), so nothing is on + * screen: the remount's rebuild is a load with a world to wait for. */ + markGone = (): void => { + this.rebuildStatus.value = RebuildStatus.Pending; + this.builtManifest.value = null; + this.detail.value = null; + this.buildProgress.value = null; + }; + + markError = (err: unknown): void => { + this.rebuildStatus.value = RebuildStatus.Error; + // Logged with the stack, where a developer can use it. The UI shows a + // generic line: the message names our internals, and a user cannot act on it. + console.error('[codecity] city build failed', err); + this.lastError.value = err instanceof Error ? err.message : String(err); + this.detail.value = null; + this.buildProgress.value = null; + }; + + status = (): RebuildStatus => this.rebuildStatus.peek(); + + /** How far along the rebuild already announced by markRebuilding is. */ + setDetail = (detail: string | null): void => { + this.detail.value = detail; + }; + + // ── Build stages ───────────────────────────────────────────────── + // The plan is per build: only what runs is an honest denominator. + + beginBuild = (stages: readonly BuildStage[]): void => { + this.buildProgress.value = { stages, index: 0, percent: null }; + }; + + /** Advance to a stage of the declared plan. A stage the plan didn't list is + * ignored rather than appended: the denominator was already shown. */ + enterBuildStage = (stage: BuildStage): void => { + const prev = this.buildProgress.peek(); + if (!prev) return; + const index = prev.stages.indexOf(stage); + if (index < 0 || index === prev.index) return; + this.buildProgress.value = { ...prev, index, percent: null }; + }; + + setBuildStagePercent = (percent: number): void => { + const prev = this.buildProgress.peek(); + if (!prev || prev.percent === percent) return; + this.buildProgress.value = { ...prev, percent }; + }; + + // ── The overlay ────────────────────────────────────────────────── + // peek, not value: called from effects that go on to write what they read. + + /** Omitting onCancel leaves any registered handler in place, so a caller can + * pre-register one before the reaction shows the overlay. */ + showOverlay = (opts: LoadingOverlayShowOpts, onCancel?: (() => void) | null): void => { + this.overlay.value = { + visible: true, + showOpts: opts, + activeStep: firstStepFor(opts.steps ?? LOADING_STEPS, opts.kind), + stepTails: {}, + }; + if (onCancel !== undefined) this.cancel.value = onCancel; + }; + + hideOverlay = (): void => { + this.overlay.value = { ...this.overlay.peek(), visible: false }; + this.cancel.value = null; + // The header belongs to the overlay, so it clears here rather than at each + // call site: one that forgets leaves a stale label over the next load. + this.pendingLabel.value = null; + }; + + setStep = (step: LoadingStep): void => { + const prev = this.overlay.peek(); + if (prev.activeStep === step) return; + this.overlay.value = { ...prev, activeStep: step }; + }; + + setStepTail = (step: LoadingStep, tail: string | null): void => { + const prev = this.overlay.peek(); + this.overlay.value = { ...prev, stepTails: { ...prev.stepTails, [step]: tail } }; + }; + + setCancel = (onCancel: (() => void) | null): void => { + this.cancel.value = onCancel; + }; + + /** Drive the overlay off this city's scan + build. Returns a dispose. */ + attachOverlayDriver = (): (() => void) => { + const stops = [this.attachLoadReaction(), this.attachBuildReaction()]; + return () => stops.forEach((stop) => stop()); + }; + + /** The overlay's row only: these stages pass in a few frames, and the + * freshness readout flickering through them cost more than they are worth. */ + private attachBuildReaction(): () => void { + return effect(() => { + this.setStepTail(LoadingStep.Building, buildStageTail(this.buildProgress.value)); + }); + } + + /** The overlay's whole lifetime: up while a world is coming, down once that + * world is ON SCREEN. The stream ending is only the middle of that wait. */ + private attachLoadReaction(): () => void { + let overlayUp = false; + // How far down the list this load has got. A row that lights up again after + // a later one reads as the whole load starting over. + let reached = -1; + const advance = (step: LoadingStep): void => { + if (!overlayUp) return; + const index = LOADING_STEPS.indexOf(step); + if (index <= reached) return; + reached = index; + this.setStep(step); + }; + // Nothing was fetched, so the build row is the only honest one. An overlay + // already up belongs to whoever raised it (Timeline brings its own list). + const showForBuild = (): void => { + if (this.overlay.peek().visible) return; + this.pendingLabel.value = this.source.info.peek().label || null; + this.showOverlay({ kind: null, steps: BUILD_ONLY_STEPS }); + overlayUp = true; + reached = -1; + }; + return effect(() => { + const p = this.scan.value; + // The stream finishing is not the city appearing: Idle means a frame of + // it has been presented, so every earlier status keeps the overlay up. + const status = this.rebuildStatus.value; + const building = status === RebuildStatus.Rebuilding || status === RebuildStatus.Decorating; + const onScreen = this.cityOnScreen.value; + const hide = () => { + if (overlayUp) this.hideOverlay(); + overlayUp = false; + }; + if (!p) { + // A build is all that's left to wait for: this overlay's own, or one for + // a city not up yet. A rebuild UNDER one is the footer's to report. + if (building && (overlayUp || !onScreen)) { + if (!overlayUp) showForBuild(); + advance(LoadingStep.Building); + return; + } + hide(); + return; + } + // A load's first event: a new list, so it starts from the top again. + if (p.phase === null) reached = -1; + if (!overlayUp) { + // null→non-null: show at the kind-based initial step. + this.showOverlay({ kind: p.kind, branch: p.branch }); + overlayUp = true; + reached = LOADING_STEPS.indexOf(firstStepFor(LOADING_STEPS, p.kind)); + } + advance(stepForPhase(p.phase, p.kind, p.appliedPending)); + if (p.phase === ScanPhase.CloneProgress) { + // A heartbeat during the silent promisor blob fetch has no percent at + // all, and shows the working tree growing on disk instead. + const tail = transferTail(p) ?? (p.mbOnDisk !== undefined ? `${p.mbOnDisk} MB` : null); + this.setStepTail(LoadingStep.Cloning, tail); + } else if (p.phase === ScanPhase.ScanProgress) { + this.setStepTail( + LoadingStep.Scanning, + p.filesScanned !== undefined ? `${p.filesScanned.toLocaleString()} files` : null + ); + } else if (p.phase === ScanPhase.PartialManifest || p.phase === ScanPhase.CompleteManifest) { + this.setStepTail(LoadingStep.Cloning, null); + this.setStepTail(LoadingStep.Scanning, null); + } + }); + } +} diff --git a/app/src/city/session/stores/source.ts b/app/src/city/session/stores/source.ts new file mode 100644 index 000000000..fea253e51 --- /dev/null +++ b/app/src/city/session/stores/source.ts @@ -0,0 +1,113 @@ +// state/stores/source.ts — which repo ONE city is showing: what it opened, how +// it failed, what the chrome prints about it, and the folders hidden inside it. +// The lists that outlive it are recents.ts and excludes.ts. + +import { signal, computed, type ReadonlySignal } from '@preact/signals'; +import { + srcKind, + SourceKind, + resolveBranch, + identityBranch, + sourceKey, + sameSourceIdentity, +} from '@/utils/sources'; +import { EXCLUDES, repoKeyFor, setExcludesFor } from './excludes'; +import type { ManifestStore } from './manifest'; +import type { Manifest, SourceError } from '@/types'; + +// ── One project's source ───────────────────────────────────────────── + +export interface SourceRef { + src: string; + branch?: string; +} + +export interface SourceInfo { + /** Human-readable project label (owner/repo or directory name). */ + label: string; + /** Branch name when the loaded source is a git URL with a known branch. */ + branch: string | undefined; + /** Original git URL when the source is a hosted git repo. */ + sourceUrl: string | undefined; + /** Raw source as entered: the git URL for a remote, the path for a local. */ + src: string | undefined; +} + +export class SourceStore { + /** The applied source, or null when none is. Written only by set(), so it + * means "a load succeeded". */ + readonly current = signal(null); + /** The last load failure, or null. A fetch outcome, not a UI command. */ + readonly error = signal(null); + /** Its stable hash, or null. Namespaces per-source storage. */ + readonly key: ReadonlySignal; + /** A working tree on disk rather than a clone: only a working tree changes + * under the app, so anything watching for change keys off this. */ + readonly isLocal: ReadonlySignal; + /** Everything the chrome prints about it, off the manifest it loaded. */ + readonly info: ReadonlySignal; + /** The folders hidden in this repo (empty when none / no source). */ + readonly excludes: ReadonlySignal; + + constructor(private readonly manifest: ManifestStore) { + this.key = computed(() => + this.current.value ? sourceKey(this.current.value.src, this.current.value.branch) : null + ); + + this.isLocal = computed(() => { + const cur = this.current.value; + return cur ? srcKind(cur.src) === SourceKind.Local : false; + }); + + this.info = computed(() => { + const cur = this.current.value; + const m = this.manifest.current.value; + if (!cur || !m) { + return { label: '', branch: undefined, sourceUrl: undefined, src: undefined }; + } + const loaded = m as Manifest; + return { + label: loaded.tree?.name ?? '', + branch: resolveBranch(loaded, cur.branch), + sourceUrl: srcKind(cur.src) === SourceKind.Remote ? cur.src : undefined, + src: cur.src, + }; + }); + + this.excludes = computed(() => { + const cur = this.current.value; + return cur ? (EXCLUDES.value[repoKeyFor(cur.src)] ?? []) : []; + }); + } + + /** Show this source: what this city holds, and the manifest the chrome reads. + * Remembering you opened it is the app's, which is why a backdrop can too. */ + set = (src: string, branch: string | undefined, loaded: Manifest): SourceRef => { + // A local source carries no branch: its checkout is dynamic, so identity + // omits it. The header still shows it, read off the manifest. + const ref = { src, branch: identityBranch(src, resolveBranch(loaded, branch)) }; + // Source before manifest: the camera-reframe reaction reads the source at + // apply-start, and the apply is kicked off by the manifest write. + this.current.value = ref; + this.manifest.set(loaded); + return ref; + }; + + /** Whether these name the city this session already has open. */ + isOpen = (src?: string | null, branch?: string | null): boolean => { + const cur = this.current.peek(); + return !!cur && !!src && sameSourceIdentity(cur, { src, branch: branch ?? undefined }); + }; + + /** Hide `path` from this repo's city, restore one, restore all. */ + addExclude = (path: string): void => this.setExcludes([...this.excludes.peek(), path]); + removeExclude = (path: string): void => + this.setExcludes(this.excludes.peek().filter((p) => p !== path)); + clearExcludes = (): void => this.setExcludes([]); + + private setExcludes(next: string[]): void { + const cur = this.current.peek(); + if (!cur) return; // no source loaded: nothing to key against + setExcludesFor(cur.src, next); + } +} diff --git a/app/src/city/session/stores/timeline.ts b/app/src/city/session/stores/timeline.ts new file mode 100644 index 000000000..514a6e62b --- /dev/null +++ b/app/src/city/session/stores/timeline.ts @@ -0,0 +1,392 @@ +// state/stores/timeline.ts — the history ONE project is being looked at +// through: the mode, the loaded bundle, the scrub position, and everything that +// position implies. Derived reads live beside the position they read because +// resetting the mode moves it, so splitting them would make the two files cyclic. + +import { signal, batch, computed, effect, type ReadonlySignal } from '@preact/signals'; +import type { DirNode, Manifest, TimelineBundle, TreeNode } from '@/types'; +import { NodeKind } from '@/types'; +import { parseDateMs, epochDayAt } from '@/utils/dates'; +import { findNodeByPath } from '@/utils/manifest'; +import type { ManifestStore, ManifestValue } from './manifest'; +import { + buildPathTimelines, + ruinStateAt, + PathState, + blobShaAt, + entryAt, + statsAtDeletion, + lastModifiedIndexAt, +} from '@/city/scene/scrub/replay'; +import type { PathTimeline } from '@/city/scene/scrub/replay'; + +const EMPTY: ReadonlySet = new Set(); + +// Record present paths into `out`; returns whether this node is present. A file +// is present when live at pos; a directory is present iff any descendant is. +function _collect( + node: TreeNode, + timelines: Map, + pos: number, + out: Set +): boolean { + if (node.type === NodeKind.File) { + const pt = node.path != null ? timelines.get(node.path) : undefined; + const present = pt ? ruinStateAt(pt, pos) === PathState.Present : false; + if (present && node.path != null) out.add(node.path); + return present; + } + let anyPresent = false; + for (const child of node.children ?? []) { + if (_collect(child, timelines, pos, out)) anyPresent = true; + } + if (anyPresent && node.path != null) out.add(node.path); + return anyPresent; +} + +/** A file's measures at the scrub position, or at its deletion if already gone. */ +export interface ScrubbedFileStats { + // Null when the blob was never fetched: unknown at this commit, not zero. + lines: number | null; + bytes: number | null; + /** True when these are the values the file had when it was deleted. */ + atDeletion: boolean; +} + +// Keep a node iff it's present at the scrubbed commit. A present directory always +// has ≥1 present descendant, so filtering its children never leaves it empty. +function _filterPresent(node: TreeNode, present: ReadonlySet): TreeNode | null { + if (!present.has(node.path ?? '')) return null; + if (node.type === NodeKind.File) return node; + const children: TreeNode[] = []; + for (const child of node.children ?? []) { + const kept = _filterPresent(child, present); + if (kept) children.push(kept); + } + return { ...node, children }; +} + +// ── A folder's rollups at the scrub position ───────────────────────── + +// Mirrors api/scan/treebuild.py's accumulator: files add themselves, a finished +// subdirectory is absorbed whole, and the extensionless bucket sorts last. +interface Rollup { + count: number; + fileCount: number; + dirCount: number; + size: number; + createdMin: string | null; + modifiedMax: string | null; + ext: Map; +} + +function _emptyRollup(): Rollup { + return { + count: 0, + fileCount: 0, + dirCount: 0, + size: 0, + createdMin: null, + modifiedMax: null, + ext: new Map(), + }; +} + +// Empty stands for "no date", so it loses to any real one rather than sorting +// before every ISO string. +function _minIso(a: string | null, b: string | null | undefined): string | null { + if (!b) return a; + if (!a) return b; + return b < a ? b : a; +} + +function _maxIso(a: string | null, b: string | null | undefined): string | null { + if (!b) return a; + if (!a) return b; + return b > a ? b : a; +} + +function _addExt(roll: Rollup, ext: string | null, count: number, size: number): void { + const bucket = roll.ext.get(ext); + if (bucket) { + bucket.count += count; + bucket.size += size; + } else { + roll.ext.set(ext, { count, size }); + } +} + +function _extBreakdown(roll: Rollup): DirNode['descendants_ext_breakdown'] { + return [...roll.ext.entries()] + .map(([ext, { count, size }]) => ({ ext, count, size })) + .sort( + (a, b) => + b.count - a.count || + Number(a.ext === null) - Number(b.ext === null) || + (a.ext ?? '').localeCompare(b.ext ?? '') + ); +} + +/** The commit date of the last change to `path` at or before `pos`. The union's + * own `modified` is the last change EVER, which may be in the scrub's future. */ +function _modifiedAt(pt: PathTimeline, pos: number, commitDates: (string | undefined)[]): string { + const idx = lastModifiedIndexAt(pt, pos); + return commitDates[idx] ?? ''; +} + +// Post-order, so a directory absorbs children that already know their own +// totals. Returns null for anything absent at `pos`, which prunes emptied dirs. +function _rebuildAt( + node: TreeNode, + pos: number, + timelines: Map, + commitDates: (string | undefined)[], + into: Rollup +): TreeNode | null { + if (node.type === NodeKind.File) { + const pt = node.path != null ? timelines.get(node.path) : undefined; + if (!pt || ruinStateAt(pt, pos) !== PathState.Present) return null; + const entry = entryAt(pt, pos); + const size = entry?.bytes ?? 0; + const created = (node as { created?: string }).created ?? ''; + const modified = _modifiedAt(pt, pos, commitDates); + const file = { ...node, size, lines: entry?.lines ?? 0, modified } as TreeNode; + + into.count += 1; + into.fileCount += 1; + into.size += size; + into.createdMin = _minIso(into.createdMin, created); + into.modifiedMax = _maxIso(into.modifiedMax, modified); + const ext = (node as { extension?: string }).extension; + _addExt(into, ext ? ext.toLowerCase() : null, 1, size); + return file; + } + + const own = _emptyRollup(); + const files: TreeNode[] = []; + const subdirs: TreeNode[] = []; + for (const child of node.children ?? []) { + const kept = _rebuildAt(child, pos, timelines, commitDates, own); + if (!kept) continue; + (kept.type === NodeKind.File ? files : subdirs).push(kept); + } + if (files.length === 0 && subdirs.length === 0) return null; + + into.count += 1 + own.count; + into.fileCount += own.fileCount; + into.dirCount += 1 + own.dirCount; + into.size += own.size; + into.createdMin = _minIso(into.createdMin, own.createdMin); + into.modifiedMax = _maxIso(into.modifiedMax, own.modifiedMax); + for (const [ext, { count, size }] of own.ext) _addExt(into, ext, count, size); + + const children = [...files, ...subdirs]; + return { + ...node, + children, + children_count: children.length, + children_file_count: files.length, + children_dir_count: subdirs.length, + descendants_count: own.count, + descendants_file_count: own.fileCount, + descendants_dir_count: own.dirCount, + descendants_size: own.size, + descendants_created_min: own.createdMin, + descendants_modified_max: own.modifiedMax, + descendants_ext_breakdown: _extBreakdown(own), + } as TreeNode; +} + +/** The history ONE city is being looked at through. Its own, so a second city + * scrubs its own past. */ +export class TimelineStore { + /** Distinct render mode (union city + scrub). */ + readonly mode = signal(false); + readonly bundle = signal(null); + /** Anything that would reflow the layout waits for this to clear, or the + * track resizes under the pointer mid-drag and the position jumps. */ + readonly dragging = signal(false); + /** What the content fetches key on: follows the scrub but holds still + * mid-drag, so dragging a long history doesn't refetch per commit crossed. */ + readonly settledCommit = signal(0); + /** The same rest point as a position: settledCommit caps at the newest + * commit, so it cannot tell that stop from the today stop past it. */ + readonly settledPos = signal(0); + + /** Read once per Timeline entry rather than per frame: the stop today sits + * at has to hold still while it is being scrubbed to. */ + private readonly today = signal(Date.now()); + private readonly rawPos = signal(0); + + /** How many commits the loaded bundle holds. */ + private readonly commitCount = computed(() => this.bundle.value?.commits.length ?? 0); + + /** Today, when it is later than the last commit: the city goes on aging + * after the last thing anyone committed. Null when the newest IS today. */ + readonly todayMs = computed(() => { + const loaded = this.bundle.value; + if (!loaded || loaded.commits.length === 0) return null; + // Parsed the way the bar prints dates, so the stop lands on the day it names. + const newest = parseDateMs(loaded.commits[loaded.commits.length - 1].date); + const today = this.today.value; + if (!Number.isFinite(newest)) return null; + // Whole days: a commit from this morning is not a stop away from now, and a + // track ending hours past its last tick reads as a rounding error. + return Math.floor(epochDayAt(today)) > Math.floor(epochDayAt(newest)) ? today : null; + }); + + /** One past the last commit when today is a stop of its own. */ + readonly scrubMax = computed( + () => Math.max(0, this.commitCount.value - 1) + (this.todayMs.value === null ? 0 : 1) + ); + + /** Clamped against the current bundle, not at each write, so a bundle swap + * alone can't leave a stale position out of range. Readers never clamp. */ + readonly scrubPos: ReadonlySignal = computed(() => + Math.min(Math.max(this.rawPos.value, 0), this.scrubMax.value) + ); + + /** The whole commit scrubPos lands on, so anything keyed on presence + * recomputes once a crossing. Capped: past the last, the city is still its. */ + readonly scrubCommit = computed(() => + Math.min(Math.floor(this.scrubPos.value), Math.max(0, this.commitCount.value - 1)) + ); + + /** Timelines depend only on the bundle — rebuilt when it changes, NOT per + * scrub. (A second copy from the scrub controller's; both are pure reads.) */ + private readonly timelines = computed | null>(() => { + const loaded = this.bundle.value; + return loaded ? buildPathTimelines(loaded) : null; + }); + + readonly presentPaths: ReadonlySignal> = computed(() => { + if (!this.mode.value) return EMPTY; + const loaded = this.bundle.value; + const timelines = this.timelines.value; + if (!loaded || !timelines) return EMPTY; + const pos = this.scrubCommit.value; + const tree = (loaded.unionManifest as unknown as { tree?: TreeNode }).tree; + if (!tree) return EMPTY; + const out = new Set(); + _collect(tree, timelines, pos, out); + return out; + }); + + /** The tree the panes render: the union filtered to what exists at the + * scrub, or this city's live manifest outside the mode. */ + readonly paneManifest: ReadonlySignal = computed(() => { + const loaded = this.bundle.value; + if (!this.mode.value || !loaded) return this.manifest.current.value; + + const union = loaded.unionManifest as unknown as ManifestValue; + const tree = (union as { tree?: DirNode } | null)?.tree; + if (!tree) return union; + + // Root always stays (an empty tree still needs a container); its children + // are filtered to the present-at-scrub subtrees. + const present = this.presentPaths.value; + const children: TreeNode[] = []; + for (const child of tree.children ?? []) { + const kept = _filterPresent(child, present); + if (kept) children.push(kept); + } + return { ...(union as object), tree: { ...tree, children } } as unknown as ManifestValue; + }); + + private readonly stopSettle: () => void; + + constructor(private readonly manifest: ManifestStore) { + this.stopSettle = effect(() => { + const commit = this.scrubCommit.value; + const pos = this.scrubPos.value; + if (this.dragging.value) return; + batch(() => { + this.settledCommit.value = commit; + this.settledPos.value = pos; + }); + }); + } + + /** The only way to move the scrubber; a readonly scrubPos makes bypassing it + * a type error. */ + setScrubPos = (pos: number): void => { + this.rawPos.value = pos; + }; + + /** The moment Timeline treats as now. Set on entry; tests pin it. */ + setTodayMs = (ms: number): void => { + this.today.value = ms; + }; + + /** Called BEFORE the union city is packed: the mode tells the scene layer + * whose city to pack. The position follows, once its bundle is loaded. */ + begin = (): void => { + batch(() => { + this.mode.value = true; + this.today.value = Date.now(); + }); + }; + + /** Shared by every exit path (toggle-off, source switch); scene-free. */ + reset = (): void => { + batch(() => { + this.mode.value = false; + this.setScrubPos(0); + this.bundle.value = null; + this.dragging.value = false; + }); + }; + + /** A file's measures at the scrub position, or at its deletion if gone. + * .value, not .peek(): the footer reads this in render. */ + scrubbedStatsFor = (path: string): ScrubbedFileStats | null => { + if (!this.mode.value) return null; + const pt = this.timelines.value?.get(path); + if (!pt) return null; + const pos = this.settledCommit.value; + const gone = statsAtDeletion(pt, pos); + if (gone) return { lines: gone.lines, bytes: gone.bytes, atDeletion: true }; + const entry = entryAt(pt, pos); + if (!entry) return null; + return { lines: entry.lines, bytes: entry.bytes, atDeletion: false }; + }; + + /** Blob sha for a path at the scrub position; null in Live or when absent. + * Settled, not live: content fetches wait for the drag to end. */ + scrubbedBlobShaFor = (path: string | null | undefined): string | null => { + if (!path || !this.mode.value) return null; + const pt = this.timelines.value?.get(path); + return pt ? blobShaAt(pt, this.settledCommit.value) : null; + }; + + /** No content to fetch here, so callers must NOT fall back to a by-path read: + * that hits HEAD, where a union file may not exist (#122). */ + hasNoContentAtScrub = (path: string | null | undefined): boolean => + this.mode.value && this.scrubbedBlobShaFor(path) === null; + + /** A directory at the settled commit: the union's structure, every measure + * re-added from the per-blob numbers the buildings use. Null in Live. */ + scrubbedDirFor = (path: string): DirNode | null => { + if (!this.mode.value) return null; + const loaded = this.bundle.value; + const timelines = this.timelines.value; + if (!loaded || !timelines) return null; + const union = (loaded.unionManifest as unknown as { tree?: TreeNode }).tree; + if (!union) return null; + + const node = findNodeByPath(loaded.unionManifest as unknown as Manifest, path); + if (!node || node.type !== NodeKind.Directory) return null; + + const commitDates = loaded.commits.map((c) => (c as { date?: string }).date); + const rebuilt = _rebuildAt( + node, + this.settledCommit.value, + timelines, + commitDates, + _emptyRollup() + ); + return (rebuilt as DirNode | null) ?? null; + }; + + dispose = (): void => this.stopSettle(); +} diff --git a/app/src/city/session/timelineMode.ts b/app/src/city/session/timelineMode.ts new file mode 100644 index 000000000..7cbfff584 --- /dev/null +++ b/app/src/city/session/timelineMode.ts @@ -0,0 +1,251 @@ +// state/city/timelineMode.ts — enter/exit the explicit Timeline mode for ONE +// city. Enter packs the union city once and installs the scrub controller; +// exit reloads live HEAD. Every exit path only flips the mode: the city layer +// (city/index.ts) reacts to that and does the scene teardown itself. + +import { fetchTimelineBundle } from '@/city/session/api/timeline'; +import { buildPathTimelines } from '@/city/scene/scrub/replay'; +import { activeExcludePathsFor } from '@/city/session/stores/excludes'; +import { whenScene } from '@/city/session/commands'; +import { + BuildStage, + PACK_STAGES, + LoadingStep, + TIMELINE_LOADING_STEPS, + stepForTimelineStage, + transferTail, +} from '@/constants/progress'; +import { nextPaint } from '@/city/scene/utils/nextPaint'; +import { srcKind } from '@/utils/sources'; +import { TimelineStage } from '@/types'; +import type { CitySession } from '@/city/session/session'; +import type { Manifest, TimelineBundle, TimelineProgress } from '@/types'; + +/** How far the current stage has got. Written beside its own step row, and + * standalone beside the freshness dot, so it names its own units. */ +function timelineStageTail(p: TimelineProgress): string | null { + if (p.stage === TimelineStage.Fetch) return transferTail(p); + if (p.stage === TimelineStage.History) { + return p.commits !== undefined ? `${p.commits.toLocaleString()} commits` : null; + } + if (p.blobsDone !== undefined && p.blobsTotal !== undefined) { + return `${p.blobsDone}/${p.blobsTotal} files`; + } + return null; +} + +/** Load a source in Timeline: its own call and manifest, committed the way Live + * commits its scan. `inPlace` is the refetch that holds the scrub. */ +/** Everything Timeline does to one city. Held by its session, so a second city + * scrubs its own history with its own overlay and its own this.session.scene. */ +export class TimelineMode { + constructor(private readonly session: CitySession) {} + + /** Enter Timeline for an explicit source (the `?mode=timeline` boot). */ + loadSource = async ({ + src, + branch, + commit, + inPlace = false, + noCache = false, + overlay = !inPlace, + }: { + src: string; + branch?: string; + commit?: string; + inPlace?: boolean; + noCache?: boolean; + overlay?: boolean; + }): Promise => { + const abort = new AbortController(); + let cancelled = false; + let committed = false; + + // Unoverlaid, the readout is the only progress surface: say so now, and the + // stage tails land beside it. Overlaid, a cancel has nothing to unwind. + if (inPlace && !overlay) this.session.progress.markRebuilding(); + if (overlay) { + // Cancelling keeps whatever is on screen: nothing is touched until the pack + // below sets `committed`. + this.session.progress.pendingLabel.value = this.session.source.info.peek().label || null; + this.session.progress.showOverlay( + { kind: srcKind(src), branch, steps: TIMELINE_LOADING_STEPS }, + () => { + if (committed) return; + cancelled = true; + abort.abort(); + this.session.progress.hideOverlay(); + } + ); + } + + // A cold boot can outrun the scene it packs into; a refetch can't, so no + // scene there means nothing to refetch and waiting would hang the overlay. + if (inPlace && !this.session.scene.peek()) return; + const handle = await whenScene(this.session.scene); + if (cancelled) return; + + // The plan the whole Building row counts over, opened when the server starts + // assembling and reopened identically below so the bundle can't reset it. + const buildPlan = (clientStages: readonly BuildStage[]): BuildStage[] => [ + BuildStage.Assembling, + BuildStage.Replay, + ...clientStages, + ]; + + // One row per server stage, so a stall is attributable to the stage it is in + // and the rows below say what is still to come. + let assemblyOpen = false; + const onProgress = (p: TimelineProgress): void => { + if (p.stage === TimelineStage.Assemble) { + // The server's wait, but the same wait as the build after it: one readout. + + if (!assemblyOpen) { + assemblyOpen = true; + this.session.progress.beginBuild(buildPlan(PACK_STAGES)); + } + if (p.percent != null) this.session.progress.setBuildStagePercent(p.percent); + if (overlay) this.session.progress.setStep(LoadingStep.Building); + return; + } + const tail = timelineStageTail(p); + if (!overlay) { + this.session.progress.setDetail(tail); + return; + } + const step = stepForTimelineStage(p.stage); + this.session.progress.setStep(step); + this.session.progress.setStepTail(step, tail); + }; + + try { + const bundle = await fetchTimelineBundle(src, branch, onProgress, { + signal: abort.signal, + exclude: activeExcludePathsFor(src), + noCache, + }); + if (cancelled) return; // user backed out during the fetch — live view stands + committed = true; // past here the scene is repacked; no longer cancellable + this.session.timeline.bundle.value = bundle; + if (overlay) this.session.progress.setStep(LoadingStep.Building); + this.session.progress.markRebuilding(); + // The bundle's union manifest is a Manifest like any other — repo info, + // commits, signals — so the panes, header and tree read Timeline's own. + const manifest = bundle.unionManifest as unknown as Manifest; + // The replay and the fan-out below run before the apply that would name + // them: open the same plan the assembly did, and paint before the freeze. + this.session.progress.beginBuild(buildPlan(handle.buildStagesFor(manifest))); + this.session.progress.enterBuildStage(BuildStage.Replay); + await nextPaint(); + const timelines = buildPathTimelines(bundle); + // Before the manifest: the mode is what tells the scene layer whose city to + // pack, and the commit below would otherwise land as a live one. + this.session.timeline.begin(); + this.session.source.set(src, branch, manifest); + await handle.applyManifest(manifest, [BuildStage.Assembling, BuildStage.Replay]); + // Flip after the pack: applyManifest rebuilds the street + footprint meshes opaque. + handle.timeline.setStreetsTransparent(true); + handle.timeline.setFootprintsTransparent(true); + handle.timeline.installScrubController(timelines, bundle.commitLineRanges); + // A refetch holds position (self-clamping if the bundle shrank), a named + // commit rests on it, and anything else opens at the present. + this.session.timeline.setScrubPos(this.scrubTarget(bundle, commit, inPlace)); + if (overlay) { + // The pack returns before its meshes are drawn: wait for the frame that + // carries them, or the reveal lands on a half-built city. + await handle.whenOnScreen(); + this.session.progress.hideOverlay(); + } + } catch (err) { + if (cancelled) return; // user cancel already aborted the fetch + restored live + // A failure can predate the controller install, so the effect wouldn't + // fire: tear down here, best-effort so its own throw can't bury `err`. + if (!inPlace) { + try { + this.session.timeline.reset(); + handle.timeline.uninstallScrubController(); + handle.timeline.setStreetsTransparent(false); + handle.timeline.setFootprintsTransparent(false); + } catch { + /* teardown failed; surfacing err + hiding the overlay below is what matters */ + } + } + if (overlay) this.session.progress.hideOverlay(); + this.session.progress.markError(err); + } + }; + + /** Where the scrubber lands. An unknown sha falls through to the present + * rather than erroring: the union cap can drop one, and a link can go stale. */ + private scrubTarget( + bundle: TimelineBundle, + commit: string | undefined, + inPlace: boolean + ): number { + if (commit) { + const idx = bundle.commits.findIndex((c) => c.sha === commit); + if (idx >= 0) return idx; + } + return inPlace ? this.session.timeline.scrubPos.peek() : this.session.timeline.scrubMax.peek(); + } + + /** Enter Timeline for the source already open. */ + loadScene = ( + opts: { inPlace?: boolean; noCache?: boolean; overlay?: boolean; commit?: string } = {} + ): Promise => { + const cur = this.session.source.current.peek(); + if (!cur) return Promise.resolve(); + return this.loadSource({ src: cur.src, branch: cur.branch, ...opts }); + }; + + // Enter Timeline if it isn't on, then scrub to the commit. No-op if the sha + // isn't in the bundle (the union cap can drop one) or the mode failed to engage. + /** Enter if it is not on, then scrub to that commit. */ + viewCommit = async (sha: string): Promise => { + if (!this.session.timeline.mode.peek()) { + await this.loadScene({ commit: sha }); + return; // the load rests on the commit itself; a failure is surfaced already + } + const bundle = this.session.timeline.bundle.peek(); + if (!bundle) return; + const idx = bundle.commits.findIndex((c) => c.sha === sha); + if (idx >= 0) this.session.timeline.setScrubPos(idx); + }; + + // Re-pack from the warm bundle, holding SCRUB_POS, so a settings Save in + // Timeline stays in Timeline instead of dropping to live HEAD. + /** Rebuild the union city from the warm bundle, holding the position. */ + reassemble = async (): Promise => { + const handle = this.session.scene.peek(); + const bundle = this.session.timeline.bundle.peek(); + if (!handle || !bundle) return; + const timelines = buildPathTimelines(bundle); + await handle.applyManifest(bundle.unionManifest as unknown as Manifest); + handle.timeline.setStreetsTransparent(true); + handle.timeline.setFootprintsTransparent(true); + handle.timeline.installScrubController(timelines, bundle.commitLineRanges); + }; + + // Scene-free: the city-layer effect (city/index.ts) reacts to TIMELINE_MODE and does the scene teardown. + /** Scene-free: the city layer reacts to the mode and tears its scene down. */ + teardown = (): void => { + this.session.timeline.reset(); + }; + + /** Leave, and reload live HEAD where the scrubber was. */ + exit = (): void => { + const cur = this.session.source.current.peek(); + const scrubPos = this.session.timeline.scrubPos.peek(); // remember where the scrubber was + this.teardown(); + if (!cur) return; + // Cancelling the reload re-enters Timeline where you were rather than dumping + // you on the switcher. Registered first, so loadSource's overlay keeps it. + this.session.progress.setCancel(() => { + this.session.load.cancel(); + void this.loadScene().then(() => { + if (this.session.timeline.mode.peek()) this.session.timeline.setScrubPos(scrubPos); + }); + }); + void this.session.load.loadSource({ src: cur.src, branch: cur.branch }); + }; +} diff --git a/app/src/city/utils/onSettings.ts b/app/src/city/utils/onSettings.ts deleted file mode 100644 index b9733ded4..000000000 --- a/app/src/city/utils/onSettings.ts +++ /dev/null @@ -1,19 +0,0 @@ -// city/utils/onSettings.ts — subscribe a component's reactivity to a single -// settings store. Runs `apply` once immediately and again on every Save of -// `store`, and ONLY that store: `apply` is invoked untracked, so any other -// signals it reads (picker hover/selection, sibling stores, computeds derived -// from `store`) do NOT become dependencies (which would over-fire the effect). -// Returns the effect's stop fn. -// -// Use this ONLY for components whose theme reactivity tracks exactly one store. -// A component that genuinely wants to re-fire on TWO stores (e.g. repoLabel, -// which reads both REPO_LABEL and BUILDING_DIMENSIONS) does NOT fit here — -// wrapping its apply untracked would silently drop the second subscription. -import { effect, untracked, type ReadonlySignal } from '@preact/signals'; - -export function onSettings(store: ReadonlySignal, apply: () => void): () => void { - return effect(() => { - void store.value; // the ONLY tracked dependency - untracked(apply); - }); -} diff --git a/app/src/components/fields/TierWidthsField/TierWidthsField.tsx b/app/src/components/fields/TierWidthsField/TierWidthsField.tsx index 7ce66be83..9e97bd51c 100644 --- a/app/src/components/fields/TierWidthsField/TierWidthsField.tsx +++ b/app/src/components/fields/TierWidthsField/TierWidthsField.tsx @@ -6,7 +6,7 @@ import { useId } from 'preact/hooks'; import { useEffective, useDefault } from '@/hooks/useSettings'; import { setDraft } from '@/state/settings/drafts'; -import type { StreetTier } from '@/state/settings/fields/streets'; +import type { StreetTier } from '@/city/session/settings/streets'; import { RotateCcw } from 'lucide-preact'; import { SliderField } from '@/components/fields/SliderField/SliderField'; import { FieldRow } from '@/components/fields/FieldRow/FieldRow'; diff --git a/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx b/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx index 9b52ac9f9..158926dca 100644 --- a/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx +++ b/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx @@ -1,10 +1,10 @@ -// components/loading/LoadingOverlay/LoadingOverlay.tsx — Full-viewport centered progress, mounted once -// by App.tsx and driven by the LOADING_OVERLAY signal. Narrow role by design: a -// load driven from renders its OWN inline progress and this -// suppresses itself (below), so two full-viewport surfaces never stack. +// components/loading/LoadingOverlay/LoadingOverlay.tsx — full-viewport centered +// progress, mounted by CityView and driven entirely by its project's overlay +// state: what raises it and what takes it down is the store's call (see +// attachLoadReaction), so this renders and nothing more. import './LoadingOverlay.css'; -import { LOADING_OVERLAY } from '@/state/stores/progress'; +import { useCity } from '@/city/CityProvider'; import { LoadingProgress } from '@/components/loading/LoadingProgress/LoadingProgress'; export interface LoadingOverlayProps { @@ -13,7 +13,8 @@ export interface LoadingOverlayProps { } export function LoadingOverlay({ onCancel }: LoadingOverlayProps) { - const lo = LOADING_OVERLAY.value; + const { progress } = useCity(); + const lo = progress.overlay.value; if (!lo.visible || !lo.activeStep) return null; return ( diff --git a/app/src/components/loading/LoadingProgress/LoadingProgress.css b/app/src/components/loading/LoadingProgress/LoadingProgress.css index 91ef26489..37a711c2b 100644 --- a/app/src/components/loading/LoadingProgress/LoadingProgress.css +++ b/app/src/components/loading/LoadingProgress/LoadingProgress.css @@ -1,6 +1,6 @@ /* ── Loading progress ────────────────────────────────────────────────────── - Shared column: repo label + spinner + current-step status + stepped list. - Used by LoadingOverlay (cold boot) and ProjectsView (in-app switches). */ + The loading column: repo label + spinner + current-step status + stepped + list. Its shell (backdrop, card) is LoadingOverlay's. */ /* Repo name (+ branch pill) — the header of the loading column. Holds its row whether or not the label has arrived, so nothing below it moves when it does. */ diff --git a/app/src/components/loading/LoadingProgress/LoadingProgress.tsx b/app/src/components/loading/LoadingProgress/LoadingProgress.tsx index 5c1f4db79..e0d31050c 100644 --- a/app/src/components/loading/LoadingProgress/LoadingProgress.tsx +++ b/app/src/components/loading/LoadingProgress/LoadingProgress.tsx @@ -1,9 +1,9 @@ -// components/loading/LoadingProgress/LoadingProgress.tsx — the shared loading column: -// repo label + spinner + current-step status + the stepped progress list. -// Rendered by both the app-level LoadingOverlay (deep-link cold boot) and -// ProjectsView's inline progress, so the phase→step mapping has one definition. +// components/loading/LoadingProgress/LoadingProgress.tsx — the loading column: +// repo label + spinner + current-step status + the stepped progress list. Told +// which rows to draw and which one is live; what those mean is the store's. import './LoadingProgress.css'; +import { useCity } from '@/city/CityProvider'; import { BranchPill } from '@/components/sources/BranchPill/BranchPill'; import { SourceKind } from '@/utils/sources'; @@ -14,19 +14,16 @@ import { LOADING_STEP_LABELS, stepRuns, } from '@/constants/progress'; -import { PENDING_SOURCE_LABEL } from '@/state/stores/progress'; export interface LoadingProgressProps { activeStep: LoadingStep; kind: SourceKind | null; branch?: string | null; - // Trailing per-step progress text (clone %, scanned file count). Only the - // overlay flow supplies these; ProjectsView omits them. + // Trailing per-step progress text (clone %, scanned file count, build %). stepTails?: Partial>; // Custom step list (e.g. Timeline-mode entry). Defaults to LOADING_STEPS. steps?: readonly LoadingStep[]; - // Aborts the load and returns to the project list. Each surface wires the - // routing (ProjectsView is already the list; the overlay opens it). + // Aborts the wait and returns to the project list. onCancel: () => void; } @@ -38,7 +35,8 @@ export function LoadingProgress({ steps = LOADING_STEPS, onCancel, }: LoadingProgressProps) { - const pendingLabel = PENDING_SOURCE_LABEL.value; + const { progress } = useCity(); + const pendingLabel = progress.pendingLabel.value; const activeIdx = steps.indexOf(activeStep); return ( diff --git a/app/src/components/menus/ImportExportMenu/ImportExportMenu.tsx b/app/src/components/menus/ImportExportMenu/ImportExportMenu.tsx index 68732b186..341db1838 100644 --- a/app/src/components/menus/ImportExportMenu/ImportExportMenu.tsx +++ b/app/src/components/menus/ImportExportMenu/ImportExportMenu.tsx @@ -4,17 +4,18 @@ // under what name, is the app's call rather than this panel's. import './ImportExportMenu.css'; -import { useEffect, useRef, useState } from 'preact/hooks'; +import { useEffect, useRef, useState, useMemo } from 'preact/hooks'; import { useSignal } from '@preact/signals'; import { ArrowDownUp, FolderInput, Download } from 'lucide-preact'; import { Popover, PopoverPlacement } from '@/components/menus/Popover/Popover'; +import { useCity } from '@/city/CityProvider'; import { buildSettingsFile, parseSettingsFile, applySettingsFile, excludesOrigin, storePart, - EXCLUDES_PART, + excludesPart, SettingsFileError, TransferFamily, type ImportReport, @@ -69,12 +70,12 @@ function groupRow(group: TransferGroup, parts: TransferPart[]): TransferRow { return { id: `group:${group.key}`, label: group.label, family: group.family, parts }; } -function excludesRow(note: string): TransferRow { +function excludesRow(part: TransferPart, note: string): TransferRow { return { id: EXCLUDES_ROW, label: 'Excluded from City', - family: EXCLUDES_PART.family, - parts: [EXCLUDES_PART], + family: part.family, + parts: [part], note, }; } @@ -85,26 +86,31 @@ function partsOf(group: TransferGroup): TransferPart[] { /** Every part this build will accept from a file. Nothing outside it can be * applied, however a hand-edited file names it. */ -function catalogueOf(groups: readonly TransferGroup[]): TransferPart[] { - return [...groups.flatMap(partsOf), EXCLUDES_PART]; +function catalogueOf(groups: readonly TransferGroup[], excludes: TransferPart): TransferPart[] { + return [...groups.flatMap(partsOf), excludes]; } /** Everything this browser could send: every group, plus what the open project * hides. Always offered, since "I hide nothing" is a thing worth sending. */ -function exportRows(groups: readonly TransferGroup[], note: string): TransferRow[] { - return [...groups.map((g) => groupRow(g, partsOf(g))), excludesRow(note)]; +function exportRows( + groups: readonly TransferGroup[], + excludes: TransferPart, + note: string +): TransferRow[] { + return [...groups.map((g) => groupRow(g, partsOf(g))), excludesRow(excludes, note)]; } /** Only what the file actually carries, so an import never offers to reset a * section its author chose not to send. */ function importRows( groups: readonly TransferGroup[], + excludes: TransferPart, parsed: ParsedSettingsFile, note: string ): TransferRow[] { const carried = new Set(parsed.parts.map((p) => `${p.family}/${p.key}`)); const held = (part: TransferPart) => carried.has(`${part.family}/${part.key}`); - return exportRows(groups, note) + return exportRows(groups, excludes, note) .map((row) => ({ ...row, parts: row.parts.filter(held) })) .filter((row) => row.parts.length > 0); } @@ -145,6 +151,10 @@ export interface ImportExportMenuProps { } export function ImportExportMenu({ groups }: ImportExportMenuProps) { + const { source } = useCity(); + // This project's hidden paths: whose list travels is a question about which + // repo is open, so the part is made here rather than imported ready-made. + const excludes = useMemo(() => excludesPart(source), [source]); const open = useSignal(false); const [mode, setMode] = useState(Mode.Export); const [parsed, setParsed] = useState(null); @@ -174,8 +184,8 @@ export function ImportExportMenu({ groups }: ImportExportMenuProps) { const dotTitle = mode === Mode.Import ? DOT_TITLE.import : DOT_TITLE.export; const rows = mode === Mode.Import && parsed - ? importRows(groups, parsed, scanNote) - : exportRows(groups, scanNote); + ? importRows(groups, excludes, parsed, scanNote) + : exportRows(groups, excludes, scanNote); const chosen = rows.filter((r) => !off.has(r.id)); const toggleRow = (id: string, on: boolean) => { @@ -202,7 +212,7 @@ export function ImportExportMenu({ groups }: ImportExportMenuProps) { try { // The catalogue is the authority on what may travel: a hand-edited file // naming something outside it (auto-refresh, say) resolves to nothing. - setParsed(parseSettingsFile(await file.text(), catalogueOf(groups))); + setParsed(parseSettingsFile(await file.text(), catalogueOf(groups, excludes))); setOff(new Set()); setMode(Mode.Import); } catch (e) { diff --git a/app/src/components/menus/ScanMenu/FreshnessStatus/FreshnessStatus.tsx b/app/src/components/menus/ScanMenu/FreshnessStatus/FreshnessStatus.tsx index ff368bfb3..f00860607 100644 --- a/app/src/components/menus/ScanMenu/FreshnessStatus/FreshnessStatus.tsx +++ b/app/src/components/menus/ScanMenu/FreshnessStatus/FreshnessStatus.tsx @@ -7,14 +7,9 @@ import './FreshnessStatus.css'; import { useSignal } from '@preact/signals'; import { useEffect } from 'preact/hooks'; import { formatRelativeAgeShort } from '@/utils/dates'; -import { LIVE_UPDATES_ACTIVE } from '@/state/settings/fields/updates'; -import { - REBUILD_STATUS, - RebuildStatus, - LAST_REBUILD_ERROR, - LAST_UPDATED_AT, - REBUILD_DETAIL, -} from '@/state/stores/progress'; +import { liveUpdatesActive } from '@/city/session/settings/updates'; +import { RebuildStatus } from '@/city/session/stores/progress'; +import { useCity } from '@/city/CityProvider'; // CSS modifier classes for the combined dot/detail (see FreshnessStatus.css). // Named so the className composition reads without inline magic strings. @@ -63,11 +58,12 @@ export function useFreshness(): Freshness { }, []); void tick.value; - const liveEnabled = LIVE_UPDATES_ACTIVE.value; - const rebuildStatus = REBUILD_STATUS.value; - const lastUpdatedAt = LAST_UPDATED_AT.value; - const errorMessage = LAST_REBUILD_ERROR.value; - const rebuildDetail = REBUILD_DETAIL.value; + const { progress, source } = useCity(); + const liveEnabled = liveUpdatesActive(source); + const rebuildStatus = progress.rebuildStatus.value; + const lastUpdatedAt = progress.lastUpdatedAt.value; + const errorMessage = progress.lastError.value; + const rebuildDetail = progress.detail.value; let buildClass: BuildClass; let detailText: string; diff --git a/app/src/components/menus/ScanMenu/ScanMenu.tsx b/app/src/components/menus/ScanMenu/ScanMenu.tsx index b4243d79a..cbdfb111c 100644 --- a/app/src/components/menus/ScanMenu/ScanMenu.tsx +++ b/app/src/components/menus/ScanMenu/ScanMenu.tsx @@ -13,15 +13,10 @@ import { import { Field } from '@/components/fields/Field/Field'; import { useMiddleEllipsis } from '@/hooks/useMiddleEllipsis'; import { useReplayAnimation } from '@/hooks/useReplayAnimation'; -import { LIVE_UPDATES } from '@/state/settings/fields/updates'; -import { - CURRENT_SOURCE_IS_LOCAL, - ACTIVE_EXCLUDES, - removeExclude, - clearExcludes, -} from '@/state/stores/source'; +import { LIVE_UPDATES } from '@/city/session/settings/updates'; import { EXCLUDES_DOCS_URL } from '@/constants/ui'; import { SetupGuideLink } from '@/components/app/SetupGuideLink/SetupGuideLink'; +import { useCity } from '@/city/CityProvider'; const PANEL_LABEL = 'Scan Settings'; @@ -54,7 +49,8 @@ function ExcludePath({ path }: { path: string }) { } function ExcludesGroup() { - const paths = ACTIVE_EXCLUDES.value; + const { source } = useCity(); + const paths = source.excludes.value; return (
@@ -68,7 +64,7 @@ function ExcludesGroup() { class="setting-row-reset" title="Restore all excluded paths" aria-label="Restore all excluded paths" - onClick={clearExcludes} + onClick={source.clearExcludes} > @@ -86,7 +82,7 @@ function ExcludesGroup() { class="setting-row-reset" title={`Restore ${p}`} aria-label={`Restore ${p}`} - onClick={() => removeExclude(p)} + onClick={() => source.removeExclude(p)} > @@ -129,10 +125,11 @@ export interface ScanMenuProps { } export function ScanMenu({ onRefresh }: ScanMenuProps) { + const { source } = useCity(); const freshness = useFreshness(); // Excluding is the one act whose result is an absence: the pane closes, the // building goes, and nothing else in the app says where it went. - const hiddenCount = ACTIVE_EXCLUDES.value.length; + const hiddenCount = source.excludes.value.length; const countRef = useReplayAnimation(hiddenCount); const status = hiddenCount ? `${freshness.titleText} · ${hiddenCount} ${hiddenCount === 1 ? 'path' : 'paths'} hidden` @@ -186,7 +183,7 @@ export function ScanMenu({ onRefresh }: ScanMenuProps) { {/* Only where it explains something: on a clone the controls above look live but nothing can change under them. */} - {!CURRENT_SOURCE_IS_LOCAL.value && ( + {!source.isLocal.value && (

Local projects only.

diff --git a/app/src/components/nodes/KindBadge/KindBadge.tsx b/app/src/components/nodes/KindBadge/KindBadge.tsx index fcfade81d..119ec116b 100644 --- a/app/src/components/nodes/KindBadge/KindBadge.tsx +++ b/app/src/components/nodes/KindBadge/KindBadge.tsx @@ -3,7 +3,7 @@ // it too. Its text colour is chosen by luminance against that fill. import './KindBadge.css'; -import { getHue } from '@/city/components/buildings/color'; +import { getHue } from '@/city/scene/components/buildings/color'; import { parseHex, hslToRgb, @@ -12,8 +12,8 @@ import { FILE_TAG_LIGHTNESS, } from '@/utils/colors'; import { NodeKind } from '@/types'; -import { BUILDINGS } from '@/state/settings/fields/buildings'; -import { STREETS } from '@/state/settings/fields/streets'; +import { BUILDINGS } from '@/city/session/settings/buildings'; +import { STREETS } from '@/city/session/settings/streets'; // The luminance check reads the same saturation and lightness the CSS paints, // so it judges the colour actually on screen. diff --git a/app/src/components/panes/PaneStats/PaneStats.tsx b/app/src/components/panes/PaneStats/PaneStats.tsx index 3323538d2..a9b6bb0da 100644 --- a/app/src/components/panes/PaneStats/PaneStats.tsx +++ b/app/src/components/panes/PaneStats/PaneStats.tsx @@ -3,18 +3,10 @@ // node; one row that wraps rather than a grid, so a file's five and a road's // four occupy the same shell. import './PaneStats.css'; - -export interface PaneStatItem { - text: string; - /** Hover tooltip, for a value the short form abbreviates (an exact date). - * Defaults to the text, so a truncated item can still be read in full. */ - title?: string; - /** How readily this item gives up width. Higher yields first; default 1. */ - shrink?: number; -} +import type { StatItem } from '@/types/ui'; export interface PaneStatsProps { - items: PaneStatItem[]; + items: StatItem[]; } export function PaneStats({ items }: PaneStatsProps) { diff --git a/app/src/components/sources/BranchSelect/BranchSelect.tsx b/app/src/components/sources/BranchSelect/BranchSelect.tsx index 78c54bdd4..430c4d4ba 100644 --- a/app/src/components/sources/BranchSelect/BranchSelect.tsx +++ b/app/src/components/sources/BranchSelect/BranchSelect.tsx @@ -6,7 +6,7 @@ import './BranchSelect.css'; import { useEffect, useState } from 'preact/hooks'; import { LoaderCircle } from 'lucide-preact'; import { fetchBranches } from '@/api/branches'; -import { ScanError, type ScanErrorCode } from '@/api/manifest'; +import { ScanError, type ScanErrorCode } from '@/city/session/api/manifest'; export interface BranchSelectProps { url: string; // a resolvable git URL, or '' to stay idle diff --git a/app/src/components/sources/DiscoverList/DiscoverList.tsx b/app/src/components/sources/DiscoverList/DiscoverList.tsx index feda4979f..720f92210 100644 --- a/app/src/components/sources/DiscoverList/DiscoverList.tsx +++ b/app/src/components/sources/DiscoverList/DiscoverList.tsx @@ -3,14 +3,13 @@ // stars or timings, which would rot or reopen a third-party dependency, and no // "Featured" note, since what a row can usefully say is whether you can see it. import { DISCOVER } from '@/state/stores/serverData'; -import { ACTIVE_SOURCE } from '@/state/stores/source'; +import { type SourceRef } from '@/city/session/stores/source'; import { sameSourceIdentity } from '@/utils/sources'; import { SourceRow } from '@/components/sources/SourceRow/SourceRow'; import { cityHref } from '@/router/location'; -export function DiscoverList() { +export function DiscoverList({ active }: { active: SourceRef | null }) { const repos = DISCOVER.value; - const active = ACTIVE_SOURCE.value; // src alone: a Discover row names a repo, not a branch, so it is the one on // screen whichever branch is loaded. Recents compare on full identity. if (repos.length === 0) return null; diff --git a/app/src/components/sources/NewProjectForm/NewProjectForm.tsx b/app/src/components/sources/NewProjectForm/NewProjectForm.tsx index 050ced853..2125b98c0 100644 --- a/app/src/components/sources/NewProjectForm/NewProjectForm.tsx +++ b/app/src/components/sources/NewProjectForm/NewProjectForm.tsx @@ -22,9 +22,9 @@ import { UnreachableSource, NoticeReason, } from '@/components/sources/UnreachableSource/UnreachableSource'; -import type { ScanErrorCode } from '@/api/manifest'; +import type { ScanErrorCode } from '@/city/session/api/manifest'; import type { SourcePayload } from '@/types/ui'; -import { SCAN_PROGRESS } from '@/state/stores/progress'; +import { useCity } from '@/city/CityProvider'; // Resolving a branch list means the server reaching the remote, and a typed URL // is valid for most of its last dozen characters. Wait for the typing to stop. @@ -55,6 +55,7 @@ export function NewProjectForm({ prefill, onSubmit, }: NewProjectFormProps) { + const { progress } = useCity(); // Editing the source retires the last attempt's banner. Local, not a write // back to SOURCE_ERROR: that failure is still the reason this route is open. const [retired, setRetired] = useState(false); @@ -76,7 +77,7 @@ export function NewProjectForm({ ? 'https://github.com/owner/repo or /absolute/path/to/repo' : 'https://github.com/owner/repo'; - const loading = SCAN_PROGRESS.value !== null; + const loading = progress.scan.value !== null; const activeSrc = source.trim(); // One field, classified by what's typed. Empty defaults to a URL so the // branch dropdown's absence (not a path) is the resting state. diff --git a/app/src/components/sources/RecentsList/RecentRow.tsx b/app/src/components/sources/RecentsList/RecentRow.tsx index 9dfa6aa35..ce406b1df 100644 --- a/app/src/components/sources/RecentsList/RecentRow.tsx +++ b/app/src/components/sources/RecentsList/RecentRow.tsx @@ -5,7 +5,7 @@ import { X } from 'lucide-preact'; import { SourceRow } from '@/components/sources/SourceRow/SourceRow'; -import type { RecentSource } from '@/state/stores/source'; +import type { RecentSource } from '@/state/stores/recents'; const UNAVAILABLE_REASON = "Local paths aren't enabled, so this one can't be opened here."; diff --git a/app/src/components/sources/RecentsList/RecentsList.tsx b/app/src/components/sources/RecentsList/RecentsList.tsx index 859c2f1c6..1dc0a82fc 100644 --- a/app/src/components/sources/RecentsList/RecentsList.tsx +++ b/app/src/components/sources/RecentsList/RecentsList.tsx @@ -4,15 +4,15 @@ // entry only; the scan cache is the skip-cache control's business. import './RecentsList.css'; import { useState } from 'preact/hooks'; -import { RECENTS, removeRecent, ACTIVE_SOURCE } from '@/state/stores/source'; +import { type SourceRef } from '@/city/session/stores/source'; +import { RECENTS, removeRecent } from '@/state/stores/recents'; import { SERVER_CONFIG } from '@/state/stores/serverData'; import { srcKind, SourceKind, sourceIdentity, sameSourceIdentity } from '@/utils/sources'; import { RecentRow } from './RecentRow'; import { cityHref } from '@/router/location'; -export function RecentsList() { +export function RecentsList({ active }: { active: SourceRef | null }) { const recents = RECENTS.value; - const active = ACTIVE_SOURCE.value; const allowLocal = SERVER_CONFIG.value.allowLocalRepos; const [confirming, setConfirming] = useState(null); // key of row diff --git a/app/src/components/timeline/TimelineScrubber/TimelineScrubber.tsx b/app/src/components/timeline/TimelineScrubber/TimelineScrubber.tsx index bb73c47fd..30c647239 100644 --- a/app/src/components/timeline/TimelineScrubber/TimelineScrubber.tsx +++ b/app/src/components/timeline/TimelineScrubber/TimelineScrubber.tsx @@ -1,22 +1,13 @@ // components/timeline/TimelineScrubber/TimelineScrubber.tsx — the scrub track. A time axis, so busy periods -// bunch and quiet stretches spread, driving SCRUB_POS as a float commit index so +// bunch and quiet stretches spread, driving the position as a float index so // the scrub controller stays index-based. See scrubberScale for the mapping. import './TimelineScrubber.css'; import { useEffect, useMemo, useRef } from 'preact/hooks'; -import { - TIMELINE_MODE, - SCRUB_POS, - SCRUB_MAX, - SCRUB_TODAY_MS, - TIMELINE_BUNDLE, - SCRUB_DRAGGING, - setScrubPos, -} from '@/state/stores/timeline'; import { ACCENT_THEME } from '@/state/settings/fields/theme'; import { SCRUBBER } from '@/state/settings/fields/scrubber'; import { formatFullDate, formatShortDate, localDay } from '@/utils/dates'; -import { showCommit } from '@/city/sceneHandle'; +import { useCity } from '@/city/CityProvider'; import { buildScrubberScale, commitFraction, @@ -27,12 +18,13 @@ import { } from './scrubberScale'; export function TimelineScrubber() { - const inTimeline = TIMELINE_MODE.value; - const bundle = TIMELINE_BUNDLE.value; + const { timeline, commands } = useCity(); + const inTimeline = timeline.mode.value; + const bundle = timeline.bundle.value; const commits = bundle?.commits ?? []; const indexWeight = SCRUBBER.value.INDEX_WEIGHT; - const todayMs = SCRUB_TODAY_MS.value; + const todayMs = timeline.todayMs.value; const scale = useMemo( () => buildScrubberScale( @@ -47,8 +39,8 @@ export function TimelineScrubber() { const trackRef = useRef(null); const canvasRef = useRef(null); - const maxIndex = SCRUB_MAX.value; - const pos = SCRUB_POS.value; + const maxIndex = timeline.scrubMax.value; + const pos = timeline.scrubPos.value; // A single-commit repo has no history to scrub: the handle pins to the present // (right edge, via the scale) and the track is inert rather than grab-and-freeze. const inert = maxIndex === 0; @@ -126,21 +118,21 @@ export function TimelineScrubber() { const r = el.getBoundingClientRect(); if (r.width === 0) return; const raw = fractionToIndex(scale, (clientX - r.left) / r.width); - setScrubPos(snapToStop(scale, indexToMs(scale, raw))); + timeline.setScrubPos(snapToStop(scale, indexToMs(scale, raw))); }; const onPointerDown = (e: PointerEvent) => { if (inert) return; - SCRUB_DRAGGING.value = true; + timeline.dragging.value = true; // Optional-chained: jsdom (and old browsers) lack pointer capture. (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); setFromClientX(e.clientX); }; const onPointerMove = (e: PointerEvent) => { - if (SCRUB_DRAGGING.peek()) setFromClientX(e.clientX); + if (timeline.dragging.peek()) setFromClientX(e.clientX); }; const onPointerUp = (e: PointerEvent) => { - SCRUB_DRAGGING.value = false; + timeline.dragging.value = false; const el = e.currentTarget as HTMLElement; el.releasePointerCapture?.(e.pointerId); // Focus back to the scene, so the next R or F reaches the camera. Keyboard @@ -162,7 +154,7 @@ export function TimelineScrubber() { else return; e.preventDefault(); e.stopPropagation(); - setScrubPos(next); + timeline.setScrubPos(next); }; return ( @@ -197,7 +189,7 @@ export function TimelineScrubber() { type="button" class="timeline-scrubber-edge" title={`Jump to the first commit: ${formatFullDate(commits[0].date)}`} - onClick={() => setScrubPos(0)} + onClick={() => timeline.setScrubPos(0)} > {formatShortDate(commits[0].date)} @@ -216,7 +208,7 @@ export function TimelineScrubber() { ? `Jump to the latest commit: ${formatFullDate(commits[lastCommit].date)}` : `Jump to today: ${formatFullDate(endDay)}` } - onClick={() => setScrubPos(maxIndex)} + onClick={() => timeline.setScrubPos(maxIndex)} > {formatShortDate(endDay)} @@ -231,7 +223,7 @@ export function TimelineScrubber() { type="button" class="timeline-scrubber-commit" title="Show this commit's details" - onClick={() => showCommit(commit.sha)} + onClick={() => commands.showCommit(commit.sha)} > {commit.sha.slice(0, 7)} {commit.subject || '(no subject)'} diff --git a/app/src/components/timeline/TimelineToggle/TimelineToggle.tsx b/app/src/components/timeline/TimelineToggle/TimelineToggle.tsx index 9506b5859..21dce3d61 100644 --- a/app/src/components/timeline/TimelineToggle/TimelineToggle.tsx +++ b/app/src/components/timeline/TimelineToggle/TimelineToggle.tsx @@ -2,20 +2,19 @@ // toggle centered at the bottom of the scene (above the time-travel bar). import './TimelineToggle.css'; -import { SOURCE_INFO } from '@/state/stores/source'; -import { TIMELINE_MODE } from '@/state/stores/timeline'; -import { loadTimelineScene, exitTimelineMode } from '@/hooks/useTimelineMode'; +import { useCity } from '@/city/CityProvider'; export function TimelineToggle() { - if (!SOURCE_INFO.value.src) return null; - const timeline = TIMELINE_MODE.value; + const { source, timeline: history, timelineMode } = useCity(); + const timeline = history.mode.value; + if (!source.info.value.src) return null; return (
@@ -23,7 +22,7 @@ export function TimelineToggle() { type="button" class={`timeline-toggle-btn${timeline ? ' is-active' : ''}`} aria-pressed={timeline} - onClick={() => !timeline && void loadTimelineScene()} + onClick={() => !timeline && void timelineMode.loadScene()} > Timeline diff --git a/app/src/constants/progress.ts b/app/src/constants/progress.ts index faf97a897..00505eef7 100644 --- a/app/src/constants/progress.ts +++ b/app/src/constants/progress.ts @@ -2,9 +2,9 @@ // the overlay advances through, and the sub-stages that run inside its last row // once the stream has handed over to the build. -import { ScanPhase } from '@/api/manifest'; +import { ScanPhase } from '@/city/session/api/manifest'; import { SourceKind } from '@/utils/sources'; -import { TimelineStage } from '@/types'; +import { TimelineStage, type Manifest } from '@/types'; // ── The overlay's rows ─────────────────────────────────────────────── @@ -13,6 +13,9 @@ export enum LoadingStep { Cloning = 'cloning', Scanning = 'scanning', Skeleton = 'skeleton', + /** The server walking git log, once the heights have landed: the longest + * stage of a cold scan, and the one the city's trees come out of. */ + History = 'history', Building = 'building', // Timeline-mode entry, one row per stage of the history stream: the blob // backfill, the commit walk, then blob resolution. @@ -21,16 +24,21 @@ export enum LoadingStep { TimelineBlobs = 'timeline-blobs', } -// Display order. 'skeleton' paints placeholders while the server resolves -// per-file metadata; 'building' tweens in the real heights and ends every list. +// Display order. 'history' is the git walk after the heights, and 'building' +// raises the final city, trees and all, so it ends every list. export const LOADING_STEPS: readonly LoadingStep[] = [ LoadingStep.Resolving, LoadingStep.Cloning, LoadingStep.Scanning, LoadingStep.Skeleton, + LoadingStep.History, LoadingStep.Building, ]; +// A rebuild with no stream behind it: a remount packs the manifest already in +// hand, so every fetch row above would be a row for work nobody did. +export const BUILD_ONLY_STEPS: readonly LoadingStep[] = [LoadingStep.Building]; + // Timeline's own list. Reuses LoadingStep.Building rather than inventing // a second label for the same act. export const TIMELINE_LOADING_STEPS: readonly LoadingStep[] = [ @@ -54,6 +62,7 @@ export const LOADING_STEP_LABELS: Record = { [LoadingStep.Cloning]: 'Cloning', [LoadingStep.Scanning]: 'Scanning files', [LoadingStep.Skeleton]: 'Sketching layout', + [LoadingStep.History]: 'Reading history', [LoadingStep.Building]: 'Building city', [LoadingStep.TimelineFetch]: 'Fetching history', [LoadingStep.TimelineHistory]: 'Walking commits', @@ -97,16 +106,22 @@ export function firstStepFor(steps: readonly LoadingStep[], kind: SourceKind | n return steps.find((step) => stepRuns(step, kind)) ?? steps[0]; } -/** Scan phase to step, by source kind: local skips resolving and cloning. One - * definition, shared by the overlay reactions and the inline progress. */ -export function stepForPhase(phase: ScanPhase | null, kind: SourceKind): LoadingStep { +/** Scan phase to step, by source kind. `applied` is the pending of the manifest + * the event carried: the only thing telling the two partials apart. */ +export function stepForPhase( + phase: ScanPhase | null, + kind: SourceKind, + applied?: Manifest['pending'] +): LoadingStep { switch (phase) { case ScanPhase.CloneProgress: return LoadingStep.Cloning; case ScanPhase.ScanProgress: return LoadingStep.Scanning; case ScanPhase.PartialManifest: - return LoadingStep.Skeleton; + // The skeleton's placeholder heights are what Sketching layout draws. Once + // metadata has landed the only stage left on the server is the git walk. + return applied && !applied.includes('metadata') ? LoadingStep.History : LoadingStep.Skeleton; case ScanPhase.CompleteManifest: return LoadingStep.Building; default: @@ -145,8 +160,8 @@ export enum BuildStage { Layout = 'layout', /** The batch that swaps manifest + layout, and the mesh rebuilds it fires. */ Assemble = 'assemble', - /** The deferred pass: tree placement off-thread, then its meshes. Runs with - * the city already up, and outlives the overlay in Live. */ + /** The deferred pass: tree placement off-thread, then its meshes. The last + * stage of every build, and the overlay waits it out. */ Decorate = 'decorate', } diff --git a/app/src/hooks/useDocumentTitle.ts b/app/src/hooks/useDocumentTitle.ts index f8ab608ac..daf168481 100644 --- a/app/src/hooks/useDocumentTitle.ts +++ b/app/src/hooks/useDocumentTitle.ts @@ -1,14 +1,15 @@ -// The single owner of document.title. Called once from . +// The single owner of document.title: it names the focused project. // // No in-progress state: PENDING_SOURCE_LABEL is set by entering Timeline too, // so a stale one stranded the tab at "(pending)". The overlay still shows it. import { useSignalEffect } from '@preact/signals'; -import { MANIFEST } from '@/state/stores/manifest'; +import { useCity } from '@/city/CityProvider'; export function useDocumentTitle(): void { + const { manifest } = useCity(); useSignalEffect(() => { - const m = MANIFEST.value; + const m = manifest.current.value; // tree.name is the server-normalized display name (owner/repo or basename). const label = (m as { tree?: { name?: string } } | null)?.tree?.name ?? ''; document.title = label ? `${label} — codecity` : 'codecity'; diff --git a/app/src/hooks/useHomeBackdrop.ts b/app/src/hooks/useHomeBackdrop.ts deleted file mode 100644 index 463304524..000000000 --- a/app/src/hooks/useHomeBackdrop.ts +++ /dev/null @@ -1,139 +0,0 @@ -// hooks/useHomeBackdrop.ts — paints a city behind the landing for as long as -// HomeView is mounted: the project you were last in, from whatever the server -// had cached for it, then the featured repo. Neither scans, and every failure -// is silent, since the hero image underneath is already a complete answer. - -// Applies the manifest STRAIGHT TO THE SCENE, never writing MANIFEST: that -// signal means "the project you opened", which a backdrop is not. - -import { useEffect } from 'preact/hooks'; -import { effect } from '@preact/signals'; -import { fetchCachedManifest, manifestUrlFor, streamManifest, ScanPhase } from '@/api/manifest'; -import { SERVER_CONFIG } from '@/state/stores/serverData'; -import { BACKDROP_HANDLE, type SceneHandle } from '@/city/sceneHandle'; -import { MANIFEST } from '@/state/stores/manifest'; -import { RECENTS, CURRENT_SOURCE, BACKDROP_CITY, BackdropKind } from '@/state/stores/source'; -import { identityBranch, resolveBranch, sameSourceIdentity } from '@/utils/sources'; -import type { Manifest } from '@/types'; - -interface Candidate { - src: string; - branch?: string; - kind: BackdropKind; - fetch: (signal: AbortSignal) => Promise; -} - -/** Stream the featured repo. Only the complete manifest: a skeleton's - * placeholder heights would show the city snapping into shape. */ -async function streamFeatured(src: string, signal: AbortSignal): Promise { - let complete: Manifest | null = null; - try { - for await (const event of streamManifest(manifestUrlFor({ src }), { signal })) { - if (event.phase === ScanPhase.Error) return null; - if (event.phase === ScanPhase.CompleteManifest) complete = event.manifest; - } - } catch (_) { - return null; - } - return complete; -} - -// Building a city holds the main thread for seconds on a big repo, and behind -// the landing that is a frozen page for a city nobody came to look at. -const BACKDROP_MAX_FILES = 5000; - -/** Whether this manifest is small enough to build without freezing the page. */ -function fitsBehindTheLanding(manifest: Manifest): boolean { - const files = manifest.tree?.descendants_file_count; - return files === undefined || files <= BACKDROP_MAX_FILES; -} - -/** Who gets to be the backdrop, best first. */ -function candidates(featuredRepo: string | undefined): Candidate[] { - const out: Candidate[] = []; - // The project you just left, still in memory: no round trip, and it is the - // city you were looking at a moment ago. - const loaded = MANIFEST.peek(); - const current = CURRENT_SOURCE.peek(); - if (current && loaded) { - out.push({ - src: current.src, - branch: current.branch, - kind: BackdropKind.Recent, - fetch: () => Promise.resolve(loaded as Manifest), - }); - } - const recent = RECENTS.peek()[0]; - if (recent && !sameSourceIdentity(recent, current ?? { src: '' })) { - out.push({ - src: recent.src, - branch: recent.branch, - kind: BackdropKind.Recent, - fetch: (signal) => fetchCachedManifest(recent.src, recent.branch, signal), - }); - } - // Skipped when it IS the recent one: it would only paint the same city again. - if (featuredRepo && featuredRepo !== recent?.src && featuredRepo !== current?.src) { - out.push({ - src: featuredRepo, - kind: BackdropKind.Featured, - fetch: (signal) => streamFeatured(featuredRepo, signal), - }); - } - return out; -} - -export function useHomeBackdrop(): void { - useEffect(() => { - const controller = new AbortController(); - // One go each, so a re-run cannot re-fetch a failure: this is what lets - // the effect give featured its turn when the config lands late. - const tried = new Set(); - let inFlight = false; - - async function tryNext(handle: SceneHandle, featuredRepo?: string): Promise { - const signal = controller.signal; - const next = candidates(featuredRepo).find((c) => !tried.has(`${c.kind}:${c.src}`)); - if (!next) return; - tried.add(`${next.kind}:${next.src}`); - inFlight = true; - try { - const manifest = await next.fetch(signal); - if (signal.aborted) return; - // Too big to build without freezing the page, or nothing there at all: - // either way this candidate is out and the next one gets its turn. - if (!manifest || !fitsBehindTheLanding(manifest)) { - void tryNext(handle, featuredRepo); - return; - } - await handle.applyManifest(manifest); - if (signal.aborted) return; - BACKDROP_CITY.value = { - src: next.src, - label: manifest.tree?.name ?? next.src, - // Normalised the way commitSource does: identity includes the - // branch, or the repo won't match its own row in recents. - branch: identityBranch(next.src, resolveBranch(manifest, next.branch)), - kind: next.kind, - }; - } finally { - inFlight = false; - } - } - - const stop = effect(() => { - // The landing's own canvas, which mounts with the view: leaving the route - // takes it (and the scene) with it, so there is nothing to hand back. - const handle = BACKDROP_HANDLE.value; - // Re-runs when the server config lands, which gives featured its turn. - const featured = SERVER_CONFIG.value.featuredRepo; - if (handle && !inFlight && !BACKDROP_CITY.peek()) void tryNext(handle, featured); - }); - - return () => { - stop(); - controller.abort(); - BACKDROP_CITY.value = null; - }; - }, []); -} diff --git a/app/src/hooks/useManifestSource.ts b/app/src/hooks/useManifestSource.ts deleted file mode 100644 index 778de7690..000000000 --- a/app/src/hooks/useManifestSource.ts +++ /dev/null @@ -1,440 +0,0 @@ -// hooks/useManifestSource.ts — The FETCH layer of the city's manifest pipeline. -// Scene-free by contract: it streams manifests and publishes canonical signals -// (MANIFEST, SCAN_PROGRESS, CURRENT_SOURCE via setCurrentSource, SOURCE_ERROR). -// The render layer consumes them and owns the apply's rebuild status. - -import { useEffect } from 'preact/hooks'; -import { computed, effect } from '@preact/signals'; - -import { - manifestUrlFor, - signatureUrlFor, - streamManifest, - ScanPhase, - ScanError, -} from '@/api/manifest'; -import { getServerConfig } from '@/api/config'; -import { getDiscover } from '@/api/discover'; -import { LIVE_UPDATES, LIVE_UPDATES_ACTIVE } from '@/state/settings/fields/updates'; -import { - RECENTS, - SOURCE_ERROR, - commitSource, - CURRENT_SOURCE, - activeExcludePathsFor, - ACTIVE_EXCLUDES, -} from '@/state/stores/source'; -import { DISCOVER, SERVER_CONFIG } from '@/state/stores/serverData'; -import { MANIFEST, setManifest } from '@/state/stores/manifest'; -import { - markError, - markRebuilding, - SCAN_PROGRESS, - PENDING_SOURCE_LABEL, -} from '@/state/stores/progress'; -import { TIMELINE_MODE, resetTimelineMode } from '@/state/stores/timeline'; -import { - srcKind, - SourceKind, - identityBranch, - sourceKey, - sameSourceIdentity, - sourceIdentity, -} from '@/utils/sources'; -import { readUrlView, type UrlView } from '@/router/viewParams'; -import { ROUTE_PARAMS, ROUTE_PATH } from '@/router/location'; -import { ROUTES } from '@/router/paths'; -import type { Manifest } from '@/types'; -import type { SourcePayload } from '@/types/ui'; - -// ── Shared helpers ─────────────────────────────────────────────────── - -/** Consume a manifest stream, publishing SCAN_PROGRESS per event and handing - * each skeleton/final to onManifest. Returns the final; throws without one. */ -async function pumpManifestStream( - url: string, - meta: { kind: SourceKind; branch?: string }, - onManifest: ( - manifest: Manifest, - phase: ScanPhase.PartialManifest | ScanPhase.CompleteManifest - ) => Promise | void, - signal?: AbortSignal -): Promise { - let lastManifest: Manifest | null = null; - // Only partials are applied in here, so the complete event carries the last - // applied `pending` forward rather than claiming its own as applied. - let appliedPending: Manifest['pending'] | undefined; - - for await (const event of streamManifest(url, { signal })) { - if (event.phase === ScanPhase.Error) throw new ScanError(event.error, event.code); - - if ('label' in event && event.label) { - // Server-side, so the document title and the overlay header name the - // project the same way instead of each deriving it from the src. - PENDING_SOURCE_LABEL.value = event.label; - } - - if (event.phase === ScanPhase.CloneProgress || event.phase === ScanPhase.ScanProgress) { - SCAN_PROGRESS.value = { - ...meta, - phase: event.phase, - percent: event.phase === ScanPhase.CloneProgress ? event.percent : undefined, - stage: event.phase === ScanPhase.CloneProgress ? event.stage : undefined, - mbOnDisk: event.phase === ScanPhase.CloneProgress ? event.mb_on_disk : undefined, - objects: event.phase === ScanPhase.CloneProgress ? event.objects : undefined, - objectsTotal: event.phase === ScanPhase.CloneProgress ? event.objects_total : undefined, - mib: event.phase === ScanPhase.CloneProgress ? event.mib : undefined, - filesScanned: event.phase === ScanPhase.ScanProgress ? event.files_scanned : undefined, - }; - continue; - } - - // tree.name beats the src basename, which for a working tree is whatever - // the folder is called (e.g. a git-worktree dir). - if (event.manifest.tree?.name) PENDING_SOURCE_LABEL.value = event.manifest.tree.name; - await onManifest(event.manifest, event.phase); - lastManifest = event.manifest; - if (event.phase === ScanPhase.PartialManifest) appliedPending = event.manifest.pending; - // After onManifest, so the overlay reaction can never see "heights final" - // ahead of the paint that shows them. - SCAN_PROGRESS.value = { ...meta, phase: event.phase, appliedPending }; - } - - if (!lastManifest) throw new Error('No manifest received'); - return lastManifest; -} - -// Injected, not imported (importing useTimelineMode back was a cycle); it -// registers before TIMELINE_MODE can turn on. -type TimelineRefresh = (opts?: { noCache?: boolean; overlay?: boolean }) => Promise; - -let timelineRefresh: TimelineRefresh | null = null; - -export function setTimelineRefreshHandler(fn: TimelineRefresh | null): void { - timelineRefresh = fn; -} - -/** The Timeline boot: `?mode=timeline` loads the history bundle instead of a - * HEAD scan, and commits its own manifest. Injected for the same cycle reason. */ -type TimelineBoot = (payload: { src: string; branch?: string; commit?: string }) => Promise; - -let timelineBoot: TimelineBoot | null = null; - -export function setTimelineBootHandler(fn: TimelineBoot | null): void { - timelineBoot = fn; -} - -// ── Single-writer generation guard ─────────────────────────────────── - -// Every write is gated on "am I still the current generation?", so a newer load -// silently drops an older one and any in-flight poll write with it. -let loadGeneration = 0; - -// The AbortController for the current foreground load, so the UI can cancel a -// slow clone/scan. A new load or a cancel aborts the previous one. -let loadController: AbortController | null = null; - -// ── Canonical source load (cold-boot + user switch) ────────────────── - -// The one way to load a source. The poll below is a separate op that shares -// only the MANIFEST sink and yields to this via the generation. -export async function loadSource(payload: SourcePayload): Promise { - // This attempt supersedes the last failure, so nothing outlives it to explain - // a load that is no longer the current one. - SOURCE_ERROR.value = null; - // A source switch always exits Timeline; the city layer reacts to the flip. - if (TIMELINE_MODE.peek()) resetTimelineMode(); - const myGen = ++loadGeneration; // claim authority; supersedes any in-flight load/poll - loadController?.abort(); // supersede any in-flight load - const controller = new AbortController(); - loadController = controller; - // A local source has no branch axis, so a stale deep-link's branch is dropped - // rather than carried into the URL, the overlay and the committed source. - const branch = identityBranch(payload.src, payload.branch); - // The server's label arrives with the first stream event, by which time the - // overlay is already up; recents cover the gap. - PENDING_SOURCE_LABEL.value = RECENTS.peek().find((r) => r.src === payload.src)?.label ?? null; - const meta = { - kind: srcKind(payload.src), - branch, - }; - SCAN_PROGRESS.value = { ...meta, phase: null }; // show overlay immediately - // What a cancel rolls back to, captured before the clear below: otherwise the - // canceled repo's geometry lingers under the unchanged header. - const prevManifest = MANIFEST.peek(); - // A DIFFERENT project retires the one on screen: the route changes on the - // click now, so the last city would draw under the overlay for the load. - const opened = CURRENT_SOURCE.peek(); - if (!opened || !sameSourceIdentity(opened, { src: payload.src, branch })) { - setManifest(null); - } - - try { - const url = manifestUrlFor({ - src: payload.src, - branch, - noCache: !!payload.skipCache, - exclude: activeExcludePathsFor(payload.src), - }); - // Skeleton streams out here; the final is published below, after the - // source is committed. - const manifest = await pumpManifestStream( - url, - meta, - (m, phase) => { - if (phase === ScanPhase.PartialManifest && myGen === loadGeneration) setManifest(m); - }, - controller.signal - ); - // A newer load superseded this one: it owns MANIFEST now, don't touch. - if (myGen !== loadGeneration) return; - // An aborted stream ends as done, not a throw, so a cancel arrives here - // holding the partial. Roll back rather than commit it. - if (controller.signal.aborted) { - setManifest(prevManifest); - return; - } - // One commit point, whichever view loaded it: source, recents, manifest. - commitSource(payload.src, branch, manifest); - } catch (err) { - if (myGen !== loadGeneration) return; // superseded — its error isn't current - if (controller.signal.aborted) { - setManifest(prevManifest); // user canceled: not an error; roll back any skeleton - return; - } - SOURCE_ERROR.value = { - error: err instanceof Error ? err.message : String(err), - code: err instanceof ScanError ? err.code : undefined, - prefill: { src: payload.src, branch }, - }; - } finally { - // Only the authoritative load tears the overlay down, or a superseded one - // clears it out from under the load still streaming. - if (myGen === loadGeneration) { - SCAN_PROGRESS.value = null; - if (loadController === controller) loadController = null; - } - } -} - -/** Abort the in-flight foreground load. Treated as a clean user cancel, not a - * failure: see the `catch` branch above. */ -export function cancelLoad(): void { - loadController?.abort(); -} - -/** Re-read the source already open, in whichever mode it is being viewed: - * Timeline refetches its bundle in place rather than dropping to live HEAD. */ -export function refreshCurrentSource(skipCache = false): void { - const cur = CURRENT_SOURCE.peek(); - if (!cur) return; - if (TIMELINE_MODE.peek() && timelineRefresh) { - // Asked for by hand, so it gets the same stepped overlay a Live refresh - // does: the history walk behind it runs for minutes on a big repo. - void timelineRefresh({ noCache: skipCache, overlay: true }); - return; - } - void loadSource({ src: cur.src, branch: cur.branch, skipCache: skipCache || undefined }); -} - -// ── Live-update poll loop ──────────────────────────────────────────── - -// Floor: the server walks the filesystem per poll, so tighter burns CPU. -// Ceiling: past a minute "live" stops feeling live. -const POLL_SECONDS_MIN = 1; -const POLL_SECONDS_MAX = 60; - -function _clampPollSeconds(s: number | unknown): number { - if (typeof s !== 'number' || !isFinite(s)) return POLL_SECONDS_MIN; - return Math.min(POLL_SECONDS_MAX, Math.max(POLL_SECONDS_MIN, s)); -} - -interface SignatureResponse { - root: string; - scanned_at: string; - content_signature: string; -} - -/** Start the live-update poll loop and the exclude-refresh reaction, returning - * a dispose for both. Exported so the reaction is directly testable. */ -export function setupLiveUpdates(): () => void { - let timer: number | null = null; - let inFlight = false; - - async function fetchAndApply(src: string, branch: string | undefined): Promise { - const myGen = loadGeneration; // capture; a foreground load bumping this drops our write - try { - for await (const event of streamManifest( - manifestUrlFor({ src, branch, exclude: activeExcludePathsFor(src) }) - )) { - if (event.phase === ScanPhase.Error) throw new ScanError(event.error, event.code); - // Skip the skeleton: the city is already drawn, and applying one would - // animate every building to placeholder heights and back on each save. - if (event.phase !== ScanPhase.CompleteManifest) continue; - if (myGen !== loadGeneration) return; // a foreground load started — this refresh is stale - const m = event.manifest; - if (m?.content_signature) setManifest(m); - } - } catch (err) { - if (myGen !== loadGeneration) return; // superseded by a load — not our error to surface - markError(err); - } - } - - // Cheap signature first, full manifest only when it differs. Targets the - // committed CURRENT_SOURCE, not the page URL, which lags a switch. - async function tick(): Promise { - if (inFlight) return; - if (TIMELINE_MODE.peek()) return; // Timeline mode owns the scene (union city + scrub) — no live poll - if (SCAN_PROGRESS.peek() !== null) return; // a foreground load is in flight — yield - const cur = CURRENT_SOURCE.peek(); - if (!cur) return; // nothing loaded yet - const current = MANIFEST.peek(); - if (!current) return; - const applied = (current as Manifest).content_signature; - inFlight = true; - try { - const sigResp = await fetch( - signatureUrlFor(cur.src, cur.branch, activeExcludePathsFor(cur.src)) - ); - if (!sigResp.ok) return; - const sig: SignatureResponse | null = await sigResp.json(); - if (!sig?.content_signature || sig.content_signature === applied) return; - await fetchAndApply(cur.src, cur.branch); - } catch (_) { - // Cheap-probe network blip: no rebuild attempted, so not surfaced. Next - // tick retries. - } finally { - inFlight = false; - } - } - - function start(): void { - stop(); - const seconds = _clampPollSeconds(LIVE_UPDATES.value.POLL_SECONDS); - timer = window.setInterval(tick, seconds * 1000); - } - function stop(): void { - if (timer != null) { - window.clearInterval(timer); - timer = null; - } - } - - const disposeEnabledEffect = effect(() => { - // Tracks the source too, so switching between a local tree and a clone - // starts or stops the timer without a reload. - if (LIVE_UPDATES_ACTIVE.value) start(); - else stop(); - }); - - // Excludes need their own trigger, since the poll is gated on - // LIVE_UPDATES_ACTIVE. Key-guarded so a source switch isn't read as an edit. - let lastExcludeKey: string | null = null; - const disposeExcludeRefresh = effect(() => { - const serialized = ACTIVE_EXCLUDES.value.join('\n'); - const cur = CURRENT_SOURCE.peek(); - const repoKey = cur ? sourceKey(cur.src) : null; - const nextKey = repoKey === null ? null : `${repoKey}|${serialized}`; - const prev = lastExcludeKey; - lastExcludeKey = nextKey; - if (prev === null || nextKey === null) return; // first run / no source - const [prevRepo] = prev.split('|', 1); - if (prevRepo !== repoKey) return; // source switched — the load owns it - if (prev === nextKey) return; // no actual change - if (SCAN_PROGRESS.peek() !== null) return; // yield to a foreground load - if (!cur) return; - if (inFlight) return; // the poll's tick is already covering this refresh - inFlight = true; - // Timeline owns the scene: excludes change the union data, so refetch its - // bundle + re-pack (it reports itself through the readout). Live: re-scan. - let refresh: Promise; - if (TIMELINE_MODE.peek() && timelineRefresh) { - refresh = timelineRefresh(); - } else { - markRebuilding(); // say so now, not after the re-scan streams back - refresh = fetchAndApply(cur.src, cur.branch); - } - void refresh.finally(() => { - inFlight = false; - }); - }); - - return () => { - stop(); - disposeEnabledEffect(); - disposeExcludeRefresh(); - }; -} - -/** The load the URL asks for, in the mode it asks for. A Timeline boot that - * fails to engage falls through to Live, so the page lands on a working city. */ -export async function bootLoad(boot: UrlView): Promise { - const src = boot.src; - if (!src) return; - if (boot.timeline && timelineBoot) { - await timelineBoot({ src, branch: boot.branch, commit: boot.commit ?? undefined }); - if (TIMELINE_MODE.peek()) return; - } - await loadSource({ src, branch: boot.branch }); -} - -/** The project the URL asks for, as one comparable string: a computed notifies - * only when THAT changes, so writing ?sel= or ?mode= cannot re-ask it. */ -const URL_SOURCE = computed(() => { - if (ROUTE_PATH.value !== ROUTES.CITY) return ''; - const view = readUrlView(ROUTE_PARAMS.value); - return view.src ? sourceIdentity(view.src, view.branch) : ''; -}); - -/** Load whatever project the URL names, whenever that changes: the boot read - * and every Back/Forward between cities are the same event. Returns a dispose. */ -export function attachRouteLoad(): () => void { - return effect(() => { - if (!URL_SOURCE.value) return; - // Peeked: the identity above is the trigger, and re-reading the params here - // must not subscribe this to the view ones alongside it. - const boot = readUrlView(ROUTE_PARAMS.peek()); - // Out of the tracking scope: the load writes signals this effect reads. - queueMicrotask(() => { - // Committing a remote keeps the branch the server resolved, which lands in - // the URL and moves the identity above. The city on screen is not a new ask. - const cur = CURRENT_SOURCE.peek(); - if (cur && boot.src && sameSourceIdentity(cur, { src: boot.src, branch: boot.branch })) { - return; - } - void bootLoad(boot); - }); - }); -} - -// ── Hook ───────────────────────────────────────────────────────────── - -/** Boot the fetch pipeline on mount. Nothing to hand back: loadSource and - * friends are module functions, so each view calls the one it needs. */ -export function useManifestSource(): void { - useEffect(() => { - let cancelled = false; - let disposeLiveUpdates: (() => void) | null = null; - // The URL drives what is rendered: boot read and every later Back/Forward. - // A bare ?src is complete; the server resolves the default branch. - const disposeRouteLoad = attachRouteLoad(); - (async () => { - // Independent boot reads, so they go out together rather than making the - // landing wait for two round trips in series. - const [serverConfig, discover] = await Promise.all([getServerConfig(), getDiscover()]); - if (cancelled) return; - SERVER_CONFIG.value = serverConfig; - DISCOVER.value = discover; - - // One loop for the app's lifetime: it re-reads the canonical signals per - // tick, so boot and every switch are covered without restarting it. - disposeLiveUpdates = setupLiveUpdates(); - })(); - return () => { - cancelled = true; - disposeRouteLoad(); - disposeLiveUpdates?.(); - }; - }, []); -} diff --git a/app/src/hooks/useTimelineMode.ts b/app/src/hooks/useTimelineMode.ts deleted file mode 100644 index 8bed3bb6a..000000000 --- a/app/src/hooks/useTimelineMode.ts +++ /dev/null @@ -1,281 +0,0 @@ -// hooks/useTimelineMode.ts — enter/exit the explicit Timeline mode. Enter packs -// the union city once and installs the scrub controller; exit reloads live HEAD. -// Every exit path only flips TIMELINE_MODE: the city layer (city/index.ts) -// reacts to that and does the scene teardown itself. - -import { fetchTimelineBundle } from '@/api/timeline'; -import { buildPathTimelines } from '@/city/timeline/replay'; -import { - CURRENT_SOURCE, - SOURCE_INFO, - RECENTS, - commitSource, - activeExcludePathsFor, -} from '@/state/stores/source'; -import { SCENE_HANDLE, whenSceneHandle } from '@/city/sceneHandle'; -import { - beginBuild, - enterBuildStage, - setBuildStagePercent, - markError, - markRebuilding, - setRebuildDetail, - showLoadingOverlay, - setLoadingStep, - setLoadingStepTail, - hideLoadingOverlay, - setLoadingCancel, - PENDING_SOURCE_LABEL, -} from '@/state/stores/progress'; -import { - BuildStage, - PACK_STAGES, - LoadingStep, - TIMELINE_LOADING_STEPS, - stepForTimelineStage, - transferTail, -} from '@/constants/progress'; -import { nextPaint } from '@/city/utils/nextPaint'; -import { srcKind } from '@/utils/sources'; -import { - TIMELINE_MODE, - TIMELINE_BUNDLE, - SCRUB_POS, - SCRUB_MAX, - resetTimelineMode, - beginTimelineMode, - setScrubPos, -} from '@/state/stores/timeline'; -import { - loadSource, - cancelLoad, - setTimelineRefreshHandler, - setTimelineBootHandler, -} from '@/hooks/useManifestSource'; -import { TimelineStage } from '@/types'; -import type { Manifest, TimelineBundle, TimelineProgress } from '@/types'; - -/** How far the current stage has got. Written beside its own step row, and - * standalone beside the freshness dot, so it names its own units. */ -function timelineStageTail(p: TimelineProgress): string | null { - if (p.stage === TimelineStage.Fetch) return transferTail(p); - if (p.stage === TimelineStage.History) { - return p.commits !== undefined ? `${p.commits.toLocaleString()} commits` : null; - } - if (p.blobsDone !== undefined && p.blobsTotal !== undefined) { - return `${p.blobsDone}/${p.blobsTotal} files`; - } - return null; -} - -/** Load a source in Timeline: its own call and manifest, committed the way Live - * commits its scan. `inPlace` is the refetch that holds the scrub. */ -export async function loadTimelineSource({ - src, - branch, - commit, - inPlace = false, - noCache = false, - overlay = !inPlace, -}: { - src: string; - branch?: string; - commit?: string; - inPlace?: boolean; - noCache?: boolean; - overlay?: boolean; -}): Promise { - const abort = new AbortController(); - let cancelled = false; - let committed = false; - - // Unoverlaid, the readout is the only progress surface: say so now, and the - // stage tails land beside it. Overlaid, a cancel has nothing to unwind. - if (inPlace && !overlay) markRebuilding(); - if (overlay) { - // Cancelling keeps whatever is on screen: nothing is touched until the pack - // below sets `committed`. - PENDING_SOURCE_LABEL.value = - SOURCE_INFO.peek().label || RECENTS.peek().find((r) => r.src === src)?.label || null; - showLoadingOverlay({ kind: srcKind(src), branch, steps: TIMELINE_LOADING_STEPS }, () => { - if (committed) return; - cancelled = true; - abort.abort(); - hideLoadingOverlay(); - }); - } - - // A cold boot can outrun the city it packs into; a refetch can't, so no handle - // there means nothing to refetch and waiting would hang the overlay. - if (inPlace && !SCENE_HANDLE.peek()) return; - const handle = await whenSceneHandle(); - if (cancelled) return; - - // The plan the whole Building row counts over, opened when the server starts - // assembling and reopened identically below so the bundle can't reset it. - const buildPlan = (clientStages: readonly BuildStage[]): BuildStage[] => [ - BuildStage.Assembling, - BuildStage.Replay, - ...clientStages, - ]; - - // One row per server stage, so a stall is attributable to the stage it is in - // and the rows below say what is still to come. - let assemblyOpen = false; - const onProgress = (p: TimelineProgress): void => { - if (p.stage === TimelineStage.Assemble) { - // The server's wait, but the same wait as the build after it: one readout. - - if (!assemblyOpen) { - assemblyOpen = true; - beginBuild(buildPlan(PACK_STAGES)); - } - if (p.percent != null) setBuildStagePercent(p.percent); - if (overlay) setLoadingStep(LoadingStep.Building); - return; - } - const tail = timelineStageTail(p); - if (!overlay) { - setRebuildDetail(tail); - return; - } - const step = stepForTimelineStage(p.stage); - setLoadingStep(step); - setLoadingStepTail(step, tail); - }; - - try { - const bundle = await fetchTimelineBundle(src, branch, onProgress, { - signal: abort.signal, - exclude: activeExcludePathsFor(src), - noCache, - }); - if (cancelled) return; // user backed out during the fetch — live view stands - committed = true; // past here the scene is repacked; no longer cancellable - TIMELINE_BUNDLE.value = bundle; - if (overlay) setLoadingStep(LoadingStep.Building); - markRebuilding(); - // The bundle's union manifest is a Manifest like any other — repo info, - // commits, signals — so the panes, header and tree read Timeline's own. - const manifest = bundle.unionManifest as unknown as Manifest; - // The replay and the fan-out below run before the apply that would name - // them: open the same plan the assembly did, and paint before the freeze. - beginBuild(buildPlan(handle.buildStagesFor(manifest))); - enterBuildStage(BuildStage.Replay); - await nextPaint(); - const timelines = buildPathTimelines(bundle); - // Before the manifest: the mode is what tells the scene layer whose city to - // pack, and the commit below would otherwise land as a live one. - beginTimelineMode(); - commitSource(src, branch, manifest); - await handle.applyManifest(manifest, [BuildStage.Assembling, BuildStage.Replay]); - // Flip after the pack: applyManifest rebuilds the street + footprint meshes opaque. - handle.timeline.setStreetsTransparent(true); - handle.timeline.setFootprintsTransparent(true); - handle.timeline.installScrubController(timelines, bundle.commitLineRanges); - // A refetch holds position (self-clamping if the bundle shrank), a named - // commit rests on it, and anything else opens at the present. - setScrubPos(scrubTarget(bundle, commit, inPlace)); - if (overlay) { - // Hold the overlay through the union city's first painted frame, then reveal. - requestAnimationFrame(() => { - hideLoadingOverlay(); - }); - } - } catch (err) { - if (cancelled) return; // user cancel already aborted the fetch + restored live - // A failure can predate the controller install, so the effect wouldn't - // fire: tear down here, best-effort so its own throw can't bury `err`. - if (!inPlace) { - try { - resetTimelineMode(); - handle.timeline.uninstallScrubController(); - handle.timeline.setStreetsTransparent(false); - handle.timeline.setFootprintsTransparent(false); - } catch { - /* teardown failed; surfacing err + hiding the overlay below is what matters */ - } - } - if (overlay) hideLoadingOverlay(); - markError(err); - } -} - -/** Where the scrubber lands. An unknown sha falls through to the present rather - * than erroring: the union cap can drop one, and a link can go stale. */ -function scrubTarget(bundle: TimelineBundle, commit: string | undefined, inPlace: boolean): number { - if (commit) { - const idx = bundle.commits.findIndex((c) => c.sha === commit); - if (idx >= 0) return idx; - } - return inPlace ? SCRUB_POS.peek() : SCRUB_MAX.peek(); -} - -/** Enter Timeline for the source already open. */ -export function loadTimelineScene( - opts: { - inPlace?: boolean; - noCache?: boolean; - overlay?: boolean; - commit?: string; - } = {} -): Promise { - const cur = CURRENT_SOURCE.peek(); - if (!cur) return Promise.resolve(); - return loadTimelineSource({ src: cur.src, branch: cur.branch, ...opts }); -} - -// A refresh in Timeline → refetch the bundle, not a HEAD re-scan. A callback -// because a direct import was a cycle; registered before the mode can turn on. -setTimelineRefreshHandler((opts) => loadTimelineScene({ inPlace: true, ...opts })); - -// The boot path, for the same reason. `?mode=timeline` loads the bundle and -// nothing else: Live's scan is a different view's load, and it can wait for one. -setTimelineBootHandler((payload) => loadTimelineSource(payload)); - -// Enter Timeline if it isn't on, then scrub to the commit. No-op if the sha -// isn't in the bundle (the union cap can drop one) or the mode failed to engage. -export async function viewCommitInTimeline(sha: string): Promise { - if (!TIMELINE_MODE.peek()) { - await loadTimelineScene({ commit: sha }); - return; // the load rests on the commit itself; a failure is surfaced already - } - const bundle = TIMELINE_BUNDLE.peek(); - if (!bundle) return; - const idx = bundle.commits.findIndex((c) => c.sha === sha); - if (idx >= 0) setScrubPos(idx); -} - -// Re-pack from the warm bundle, holding SCRUB_POS, so a settings Save in -// Timeline stays in Timeline instead of dropping to live HEAD. -export async function reapplyTimelineScene(): Promise { - const handle = SCENE_HANDLE.peek(); - const bundle = TIMELINE_BUNDLE.peek(); - if (!handle || !bundle) return; - const timelines = buildPathTimelines(bundle); - await handle.applyManifest(bundle.unionManifest as unknown as Manifest); - handle.timeline.setStreetsTransparent(true); - handle.timeline.setFootprintsTransparent(true); - handle.timeline.installScrubController(timelines, bundle.commitLineRanges); -} - -// Scene-free: the city-layer effect (city/index.ts) reacts to TIMELINE_MODE and does the scene teardown. -export function teardownTimelineMode(): void { - resetTimelineMode(); -} - -export function exitTimelineMode(): void { - const cur = CURRENT_SOURCE.peek(); - const scrubPos = SCRUB_POS.peek(); // remember where the scrubber was - teardownTimelineMode(); - if (!cur) return; - // Cancelling the reload re-enters Timeline where you were rather than dumping - // you on the switcher. Registered first, so loadSource's overlay keeps it. - setLoadingCancel(() => { - cancelLoad(); - void loadTimelineScene().then(() => { - if (TIMELINE_MODE.peek()) setScrubPos(scrubPos); - }); - }); - void loadSource({ src: cur.src, branch: cur.branch }); -} diff --git a/app/src/main.tsx b/app/src/main.tsx index 09739652b..377963e18 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -18,9 +18,3 @@ const mount = document.getElementById('app'); if (mount) { render(, mount); } - -// Debug-only README screenshot capture: only when opened with ?shot=. -// Dynamically imported so the harness never ships in a normal session. -if (new URLSearchParams(window.location.search).has('shot')) { - void import('@/city/capture/captureHarness').then((m) => m.initCaptureHarness()); -} diff --git a/app/src/router/urlBinding.ts b/app/src/router/urlBinding.ts new file mode 100644 index 000000000..f0cb04ec6 --- /dev/null +++ b/app/src/router/urlBinding.ts @@ -0,0 +1,93 @@ +// router/urlBinding.ts — the URL as an adapter over ONE project: it reflects +// that session's source into ?src/?branch, loads whatever the address bar +// names, and binds the view params on top. Attached to the session the address +// bar describes, which is one of them: a column view attaches it to none. + +import { URL_PARAMS } from '@/constants/urlParams'; +import { VIEW_PARAMS } from '@/router/params'; +import { ROUTES } from '@/router/paths'; +import { navigate, hrefFor, ROUTE_SEARCH, ROUTE_PATH, ROUTE_PARAMS } from '@/router/location'; +import { computed, effect } from '@preact/signals'; +import { attachViewUrlReactions } from '@/router/viewBinding'; +import { readUrlView } from '@/router/viewParams'; +import { sourceIdentity } from '@/utils/sources'; +import type { CitySession } from '@/city/session/session'; + +/** Drop the load from the URL and go home: a cancel with nothing to fall back + * to must not leave a reload re-running what it called off. */ +export function clearSourceUrl(): void { + // Anything the app does not own (an ?utm_source, say) rides along home: only + // the params describing the load that was called off are dropped. + const params = new URLSearchParams(ROUTE_SEARCH.peek()); + for (const key of [...Object.values(URL_PARAMS), ...Object.values(VIEW_PARAMS)]) { + params.delete(key); + } + navigate(hrefFor(ROUTES.HOME, params), { replace: true }); +} + +/** Reflect the applied source so reload/share reopens it, moving to /city if + * the load began at home. Exported for the view-params tests beside it. */ +export function attachSourceReflection(session: CitySession): () => void { + return effect(() => { + const cur = session.source.current.value; + if (!cur) return; + const params = new URLSearchParams(ROUTE_SEARCH.peek()); + // A different project than the URL was describing: its mode, scrub commit + // and selection belong to the one that just left. + const had = params.get(URL_PARAMS.SRC); + if (had && !session.source.isOpen(had, params.get(URL_PARAMS.BRANCH))) { + for (const key of Object.values(VIEW_PARAMS)) params.delete(key); + } + params.set(URL_PARAMS.SRC, cur.src); + if (cur.branch) params.set(URL_PARAMS.BRANCH, cur.branch); + else params.delete(URL_PARAMS.BRANCH); + // From the switcher this is a place you went, so it pushes and Back returns + // to the list; already on /city (a re-scan, a deep link) is the same place. + const fromHome = ROUTE_PATH.peek() === ROUTES.HOME; + navigate(hrefFor(ROUTES.CITY, params), { replace: !fromHome }); + }); +} + +/** What the address bar asks for, as one comparable string: a computed notifies + * only when THAT changes, so writing ?sel= or ?mode= cannot re-ask it. */ +const URL_SOURCE = computed(() => { + if (ROUTE_PATH.value !== ROUTES.CITY) return ''; + const view = readUrlView(ROUTE_PARAMS.value); + return view.src ? sourceIdentity(view.src, view.branch) : ''; +}); + +/** Follow the URL into `session`: the boot read and every later Back/Forward. + * A bare ?src is complete; the server resolves the default branch. */ +export function attachRouteLoad(session: CitySession): () => void { + // Whether the URL named a city on the last run, so this one can tell arriving + // at one from moving around inside it. + let onCityRoute = false; + return effect(() => { + if (!URL_SOURCE.value) { + onCityRoute = false; + return; + } + const arriving = !onCityRoute; + onCityRoute = true; + // Peeked: the identity above is the trigger, and re-reading the params here + // must not subscribe this to the view ones alongside it. + const asked = readUrlView(ROUTE_PARAMS.peek()); + // Out of the tracking scope: the load writes signals this effect reads. + queueMicrotask(() => { + // Arriving always asks: only the server knows if its scan still holds. + // Once here, that city again is the branch a commit put in the URL. + if (!arriving && session.source.isOpen(asked.src, asked.branch)) return; + void session.load.boot(asked); + }); + }); +} + +/** Point the address bar at `session`, both ways. Returns a dispose. */ +export function attachUrlBinding(session: CitySession): () => void { + const stops = [ + attachSourceReflection(session), + attachRouteLoad(session), + attachViewUrlReactions(session), + ]; + return () => stops.forEach((stop) => stop()); +} diff --git a/app/src/router/viewBinding.ts b/app/src/router/viewBinding.ts index 136b6c1ba..3ab996c9b 100644 --- a/app/src/router/viewBinding.ts +++ b/app/src/router/viewBinding.ts @@ -9,129 +9,126 @@ import { VIEW_PARAMS, TIMELINE_MODE_PARAM } from '@/router/params'; import { setRouteParams, ROUTE_PARAMS, ROUTE_PATH, type NavigateOptions } from './location'; import { parseSelection, selectionParam } from './viewParams'; import { ROUTES } from './paths'; -import { CURRENT_SOURCE } from '@/state/stores/source'; -import { BUILT_MANIFEST } from '@/state/stores/progress'; -import { goToPath, goToCommit, clearSelection } from '@/city/sceneHandle'; -import { FocusMode } from '@/city/render/cameraRig'; -import { - TIMELINE_MODE, - TIMELINE_BUNDLE, - SETTLED_COMMIT, - SETTLED_POS, - SCRUB_MAX, -} from '@/state/stores/timeline'; -import { PICKER_SELECTION_KEY } from '@/city/interaction/picker'; -import { loadTimelineScene, exitTimelineMode, viewCommitInTimeline } from '@/hooks/useTimelineMode'; +import type { CitySession } from '@/city/session/session'; +import { FocusMode } from '@/city/scene/render/cameraRig'; import { NodeKind } from '@/types'; -import type { PickerSelectionKey } from '@/types'; +import type { PickerSelectionKey } from '@/city/scene/types/picker'; const REPLACE: NavigateOptions = { replace: true }; -// ── Encoding ───────────────────────────────────────────────────────── - -/** The sha the scrubber rests on, or null at the present — so a link that - * means "now" still means it once the branch has moved on. */ -function settledCommitSha(): string | null { - if (SETTLED_POS.value >= SCRUB_MAX.value) return null; - return TIMELINE_BUNDLE.value?.commits[SETTLED_COMMIT.value]?.sha ?? null; -} - -function setOrDelete(params: URLSearchParams, key: string, value: string | null): void { - if (value === null) params.delete(key); - else params.set(key, value); -} - -// ── Reflection ─────────────────────────────────────────────────────── - -function reflectViewToUrl(): void { - const timeline = TIMELINE_MODE.value; - const selection = selectionParam(PICKER_SELECTION_KEY.value); - const commit = timeline ? settledCommitSha() : null; - // Replace, always: none of these is a place the user asked to go, and a drag - // would otherwise bury their own history under a hundred entries. - setRouteParams((params) => { - setOrDelete(params, VIEW_PARAMS.MODE, timeline ? TIMELINE_MODE_PARAM : null); - // A scrub position only means something in Timeline: back in Live you are at - // HEAD, so the commit leaves with the mode. - setOrDelete(params, VIEW_PARAMS.COMMIT, commit); - setOrDelete(params, VIEW_PARAMS.SELECTION, selection); - }, REPLACE); -} - -// ── Follow ─────────────────────────────────────────────────────────── - -/** Every kind restores alike: the same go-to command a list row sends, in the - * focus mode that centres the node without turning the camera off its angle. */ -function applySelection(selection: PickerSelectionKey | null): void { - if (!selection) clearSelection(); - else if (selection.kind === NodeKind.Commit) goToCommit(selection.sha, FocusMode.Recenter); - else goToPath(selection.path, FocusMode.Recenter); -} - -/** Put the view the URL describes on screen. Reads the live view untracked and - * acts only on what the URL newly asks for: the reflection writes this URL. */ -function installViewFollow(followed: Signal): () => void { - // What the URL asked for last pass. The actions below are async, so re-acting - // on a request already carried out re-issues it forever (see the test). - let asked: { mode: boolean; commit: string | null; selection: string | null } | null = null; - - return effect(() => { - const params = ROUTE_PARAMS.value; - const onCity = ROUTE_PATH.value === ROUTES.CITY; - const source = CURRENT_SOURCE.value; - const built = BUILT_MANIFEST.value !== null; - // Nothing to select in, and no bundle to scrub, until there is a city. - if (!onCity || !source || !built) return; - - const wantTimeline = params.get(VIEW_PARAMS.MODE) === TIMELINE_MODE_PARAM; - const wantCommit = params.get(VIEW_PARAMS.COMMIT); - const wantSelection = params.get(VIEW_PARAMS.SELECTION); - // settledCommitSha reads signals this effect's own action writes, so the - // whole comparison is untracked: tracked, it wakes on its own work. - const [modeOff, commitOff, selectionOff] = untracked(() => [ - wantTimeline !== TIMELINE_MODE.peek(), - wantTimeline && !!wantCommit && wantCommit !== settledCommitSha(), - wantSelection !== selectionParam(PICKER_SELECTION_KEY.peek()), - ]); - - // A dimension is acted on when the view is off it AND the URL is asking for - // something it has not already been asked for. - const fresh = asked; - asked = { mode: wantTimeline, commit: wantCommit, selection: wantSelection }; - const modeDiffers = modeOff && (fresh === null || fresh.mode !== wantTimeline); - const commitDiffers = commitOff && (fresh === null || fresh.commit !== wantCommit); - const selectionDiffers = selectionOff && (fresh === null || fresh.selection !== wantSelection); - - // The common case, and the gate must open NOW rather than a microtask - // later, or the view it protects has moved on by the time it does. - if (!modeDiffers && !commitDiffers && !selectionDiffers) { - followed.value = true; - return; - } - - // Out of the tracking scope: everything below writes signals this reads. - queueMicrotask(() => { - if (modeDiffers) { - if (wantTimeline) void loadTimelineScene({ commit: wantCommit ?? undefined }); - else exitTimelineMode(); - } else if (commitDiffers && wantCommit) { - void viewCommitInTimeline(wantCommit); +/** Bind one project's view (mode, scrubbed commit, selection) to the URL, both + * ways. Attached for the session the address bar describes; others are not. */ +export function attachViewUrlReactions(session: CitySession): () => void { + const { source, progress, timeline, commands, scene } = session; + + // ── Encoding ───────────────────────────────────────────────────────── + + /** The sha the scrubber rests on, or null at the present — so a link that + * means "now" still means it once the branch has moved on. */ + function settledCommitSha(): string | null { + if (timeline.settledPos.value >= timeline.scrubMax.value) return null; + return timeline.bundle.value?.commits[timeline.settledCommit.value]?.sha ?? null; + } + + function setOrDelete(params: URLSearchParams, key: string, value: string | null): void { + if (value === null) params.delete(key); + else params.set(key, value); + } + + // ── Reflection ─────────────────────────────────────────────────────── + + function reflectViewToUrl(): void { + const scrubbing = timeline.mode.value; + const selection = selectionParam(scene.value?.picker.selectionKey.value ?? null); + const commit = scrubbing ? settledCommitSha() : null; + // Replace, always: none of these is a place the user asked to go, and a drag + // would otherwise bury their own history under a hundred entries. + setRouteParams((params) => { + setOrDelete(params, VIEW_PARAMS.MODE, scrubbing ? TIMELINE_MODE_PARAM : null); + // A scrub position only means something in Timeline: back in Live you are at + // HEAD, so the commit leaves with the mode. + setOrDelete(params, VIEW_PARAMS.COMMIT, commit); + setOrDelete(params, VIEW_PARAMS.SELECTION, selection); + }, REPLACE); + } + + // ── Follow ─────────────────────────────────────────────────────────── + + /** Every kind restores alike: the same go-to command a list row sends, in the + * focus mode that centres the node without turning the camera off its angle. */ + function applySelection(selection: PickerSelectionKey | null): void { + if (!selection) commands.clearSelection(); + else if (selection.kind === NodeKind.Commit) + commands.goToCommit(selection.sha, FocusMode.Recenter); + else commands.goToPath(selection.path, FocusMode.Recenter); + } + + /** Put the view the URL describes on screen. Reads the live view untracked and + * acts only on what the URL newly asks for: the reflection writes this URL. */ + function installViewFollow(followed: Signal): () => void { + // What the URL asked for last pass. The actions below are async, so re-acting + // on a request already carried out re-issues it forever (see the test). + let asked: { mode: boolean; commit: string | null; selection: string | null } | null = null; + + return effect(() => { + const params = ROUTE_PARAMS.value; + const onCity = ROUTE_PATH.value === ROUTES.CITY; + const opened = source.current.value; + const built = progress.cityOnScreen.value; + // Nothing to select in, and no bundle to scrub, until there is a city. + if (!onCity || !opened || !built) return; + + const wantTimeline = params.get(VIEW_PARAMS.MODE) === TIMELINE_MODE_PARAM; + const wantCommit = params.get(VIEW_PARAMS.COMMIT); + const wantSelection = params.get(VIEW_PARAMS.SELECTION); + // settledCommitSha reads signals this effect's own action writes, so the + // whole comparison is untracked: tracked, it wakes on its own work. + const [modeOff, commitOff, selectionOff] = untracked(() => [ + wantTimeline !== timeline.mode.peek(), + wantTimeline && !!wantCommit && wantCommit !== settledCommitSha(), + wantSelection !== selectionParam(scene.peek()?.picker.selectionKey.peek() ?? null), + ]); + + // A dimension is acted on when the view is off it AND the URL is asking for + // something it has not already been asked for. + const fresh = asked; + asked = { mode: wantTimeline, commit: wantCommit, selection: wantSelection }; + const modeDiffers = modeOff && (fresh === null || fresh.mode !== wantTimeline); + const commitDiffers = commitOff && (fresh === null || fresh.commit !== wantCommit); + const selectionDiffers = + selectionOff && (fresh === null || fresh.selection !== wantSelection); + + // The common case, and the gate must open NOW rather than a microtask + // later, or the view it protects has moved on by the time it does. + if (!modeDiffers && !commitDiffers && !selectionDiffers) { + followed.value = true; + return; } - if (selectionDiffers) applySelection(parseSelection(wantSelection)); - followed.value = true; + + // Out of the tracking scope: everything below writes signals this reads. + queueMicrotask(() => { + if (modeDiffers) { + if (wantTimeline) + void session.timelineMode.loadScene({ commit: wantCommit ?? undefined }); + else session.timelineMode.exit(); + } else if (commitDiffers && wantCommit) { + void session.timelineMode.viewCommit(wantCommit); + } + if (selectionDiffers) applySelection(parseSelection(wantSelection)); + followed.value = true; + }); }); - }); -} + } + + /** Mount the URL⇄view binding. Returns a dispose. */ -/** Mount the URL⇄view binding. Returns a dispose. */ -export function attachViewUrlReactions(): () => void { // The reflection waits for the follow's first pass, or it would describe an // empty view over the URL it is about to be told to restore. const followed = signal(false); const stopFollow = installViewFollow(followed); const stopReflect = effect(() => { // Like the ?src reflection: an unloaded page has no view to describe. - if (!CURRENT_SOURCE.value || !followed.value) return; + if (!source.current.value || !followed.value) return; reflectViewToUrl(); }); return () => { diff --git a/app/src/router/viewParams.ts b/app/src/router/viewParams.ts index 035f3c271..9f4678bca 100644 --- a/app/src/router/viewParams.ts +++ b/app/src/router/viewParams.ts @@ -7,7 +7,7 @@ import { URL_PARAMS } from '@/constants/urlParams'; import { VIEW_PARAMS, TIMELINE_MODE_PARAM, SELECTION_KIND_PARAMS } from '@/router/params'; import { identityBranch } from '@/utils/sources'; import { NodeKind } from '@/types'; -import type { PickerSelectionKey } from '@/types'; +import type { PickerSelectionKey } from '@/city/scene/types/picker'; /** The view a URL is asking for. */ export interface UrlView { diff --git a/app/src/state/cityChrome.ts b/app/src/state/cityChrome.ts new file mode 100644 index 000000000..cab08dbae --- /dev/null +++ b/app/src/state/cityChrome.ts @@ -0,0 +1,36 @@ +// state/city/chrome.ts — codecity's answer to what a city asks of the app it is +// mounted in: which pane opens, which drawer closes, and whether a modal has +// the keyboard. The city layer knows only the interface (see city/types). + +import { IS_PHONE } from '@/state/stores/viewport'; +import { + OVERLAY_OPEN, + SIDEBAR_COLLAPSED, + dismissSelectionPane, + openSelectionPane, +} from '@/state/stores/chrome'; +import type { CityChrome } from '@/city/scene/types'; + +export class SessionChrome implements CityChrome { + keyboardBusy = (): boolean => OVERLAY_OPEN.value; + + /** You asked for the node by name, so its details are the answer, and only + * the phone drawer has to move. */ + showDetails = (): void => { + openSelectionPane(); + this.collapseDrawerOnPhone(); + }; + + /** The other half of that choice: you asked to see the city, so what is in + * the way goes, and the chip stands in for the details. */ + revealCity = (): void => { + dismissSelectionPane(); + this.collapseDrawerOnPhone(); + }; + + /** Phone: the left drawer covers the city, so a camera move behind it is one + * you can't see. It is the whole screen there and a column everywhere else. */ + private collapseDrawerOnPhone(): void { + if (IS_PHONE.peek()) SIDEBAR_COLLAPSED.value = true; + } +} diff --git a/app/src/state/settings/indicators.ts b/app/src/state/settings/indicators.ts index 94c2f8738..4a28a8f60 100644 --- a/app/src/state/settings/indicators.ts +++ b/app/src/state/settings/indicators.ts @@ -5,7 +5,7 @@ import { computed } from '@preact/signals'; import { forEachSettingStore, getFieldKeys } from '@/state/settings/schema'; import { getDefault } from '@/state/persist'; import { deepEqual } from '@/utils/deep'; -import { LIVE_UPDATES } from '@/state/settings/fields/updates'; +import { LIVE_UPDATES } from '@/city/session/settings/updates'; import { ACCENT_THEME, SURFACE_THEME } from '@/state/settings/fields/theme'; import { SYNTAX_THEME } from '@/state/settings/fields/syntaxTheme'; diff --git a/app/src/state/settings/schema.ts b/app/src/state/settings/schema.ts index 7bdbc26f2..5323de8ae 100644 --- a/app/src/state/settings/schema.ts +++ b/app/src/state/settings/schema.ts @@ -90,6 +90,11 @@ export function forEachSettingStore(cb: (store: SettingStore) => void): void { for (const s of _SETTING_STORES) cb(s as SettingStore); } +/** What a store's fields are, for anything folding its values by route. */ +export function fieldsOf(store: SettingStore): FieldMap { + return _FIELDS.get(store) ?? {}; +} + const _AUTOSAVE_STORES = new WeakSet(); /** Write-through: widgets apply on change instead of staging drafts. The @@ -202,15 +207,3 @@ export function getFieldKeys(store: object): string[] { /** A string that changes iff a field with this route changes, so the wrapping * computed notifies for one route only. Call inside a computed(). */ -export function routeSignature(route: ChangeRoute): string { - let sig = ''; - for (const [store, fields] of _FIELDS) { - const v = (store as SettingStore).value as Record; - for (const key in fields) { - if (fields[key].route === route) { - sig += `${key}=${JSON.stringify(v[key])};`; - } - } - } - return sig; -} diff --git a/app/src/state/settings/transfer.ts b/app/src/state/settings/transfer.ts index 7b063e399..9a347b7e5 100644 --- a/app/src/state/settings/transfer.ts +++ b/app/src/state/settings/transfer.ts @@ -5,7 +5,8 @@ import { batch } from '@preact/signals'; import { getStoreName, getDefault } from '@/state/persist'; -import { CURRENT_SOURCE, activeExcludePathsFor, setExcludesFor } from '@/state/stores/source'; +import { type SourceStore } from '@/city/session/stores/source'; +import { activeExcludePathsFor, setExcludesFor } from '@/city/session/stores/excludes'; import { deepEqual } from '@/utils/deep'; import { coerceFieldValue, type SettingStore } from './schema'; import { dropDrafts } from './drafts'; @@ -134,35 +135,42 @@ function readExcludes(raw: unknown): TransferExcludes | null { }; } -export const EXCLUDES_PART: TransferPart = { - key: 'EXCLUDES', - family: TransferFamily.Scan, - read: () => { - const current = CURRENT_SOURCE.peek(); - // An empty list is a real answer, not a missing one: "I hide nothing here". - return { - ...(current ? { src: current.src } : {}), - ...(current?.branch ? { branch: current.branch } : {}), - paths: current ? activeExcludePathsFor(current.src) : [], - } satisfies TransferExcludes; - }, - write: (value) => { - // Onto the repo the list is FOR, which is the exporter's, not whichever - // city is on screen. Nothing here opens it: it is there when you next go. - const excludes = readExcludes(value); - if (excludes?.src) setExcludesFor(excludes.src, excludes.paths); - return []; - }, - differsFrom: (value) => { - const excludes = readExcludes(value); - // Whose list is being compared: the file's repo on the way in, the open one - // when asking whether this browser has anything to send. - const src = excludes?.src ?? CURRENT_SOURCE.peek()?.src; - if (!src) return false; - const next = [...new Set(excludes?.paths ?? [])].sort(); - return !deepEqual(activeExcludePathsFor(src), next); - }, -}; +/** The hidden-paths part, for one project: whose list travels depends on which + * repo is open, so this is made per session rather than being a module const. */ +/** The one key the excludes travel under, for readers that have no part. */ +export const EXCLUDES_KEY = 'EXCLUDES'; + +export function excludesPart(source: SourceStore): TransferPart { + return { + key: EXCLUDES_KEY, + family: TransferFamily.Scan, + read: () => { + const current = source.current.peek(); + // An empty list is a real answer, not a missing one: "I hide nothing here". + return { + ...(current ? { src: current.src } : {}), + ...(current?.branch ? { branch: current.branch } : {}), + paths: current ? activeExcludePathsFor(current.src) : [], + } satisfies TransferExcludes; + }, + write: (value) => { + // Onto the repo the list is FOR, which is the exporter's, not whichever + // city is on screen. Nothing here opens it: it is there when you next go. + const excludes = readExcludes(value); + if (excludes?.src) setExcludesFor(excludes.src, excludes.paths); + return []; + }, + differsFrom: (value) => { + const excludes = readExcludes(value); + // Whose list is being compared: the file's repo on the way in, the open one + // when asking whether this browser has anything to send. + const src = excludes?.src ?? source.current.peek()?.src; + if (!src) return false; + const next = [...new Set(excludes?.paths ?? [])].sort(); + return !deepEqual(activeExcludePathsFor(src), next); + }, + }; +} // ── Export ───────────────────────────────────────────────────────────────── @@ -233,7 +241,7 @@ export function parseSettingsFile( /** Which repo a file's hidden paths will be filed under, for the UI to name. * Null when it carries none, or carries a list that names no repo. */ export function excludesOrigin(parsed: ParsedSettingsFile): TransferExcludes | null { - const raw = parsed.file[TransferFamily.Scan]?.[EXCLUDES_PART.key]; + const raw = parsed.file[TransferFamily.Scan]?.[EXCLUDES_KEY]; const excludes = readExcludes(raw); return excludes?.src ? excludes : null; } diff --git a/app/src/state/stores/manifest.ts b/app/src/state/stores/manifest.ts deleted file mode 100644 index 726415c0c..000000000 --- a/app/src/state/stores/manifest.ts +++ /dev/null @@ -1,19 +0,0 @@ -// state/stores/manifest.ts — the manifest of the project you opened, at HEAD. -// The one the fetch layer writes and everything else reads. Two neighbours -// answer the same question differently: `scrub.ts` for what a Timeline position -// implies, `build.ts` for whether the world is currently being built from this. - -import { signal } from '@preact/signals'; -import type { Manifest, DirNode } from '@/types'; - -// The union spans a final Manifest, a bare DirNode, and the loose skeleton the -// stream emits before it is fully typed. -export type ManifestValue = Manifest | DirNode | { tree?: unknown; [k: string]: unknown } | null; - -export const MANIFEST = signal(null); - -/** Set the current manifest: skeleton, final, live-update or a rollback. The - * fetch layer is the single writer; everything else reads MANIFEST. */ -export function setManifest(m: ManifestValue): void { - MANIFEST.value = m ?? null; -} diff --git a/app/src/state/stores/progress.ts b/app/src/state/stores/progress.ts deleted file mode 100644 index 2191e137f..000000000 --- a/app/src/state/stores/progress.ts +++ /dev/null @@ -1,306 +0,0 @@ -// state/stores/progress.ts — how far along the thing you are waiting for is: -// what the SERVER reports, what the BUILD is doing, and what the OVERLAY shows -// on top of both. The overlay is a reduction over the other two (a running max -// over rows, the last tail each saw), which is why it needs the driver below. - -import { signal, effect } from '@preact/signals'; -import { SourceKind } from '@/utils/sources'; -import { ScanPhase, CloneStage } from '@/api/manifest'; -import type { Manifest } from '@/types'; -import { - BuildStage, - buildStageTail, - type BuildProgress, - LoadingStep, - LOADING_STEPS, - firstStepFor, - stepForPhase, - transferTail, -} from '@/constants/progress'; -import { MANIFEST, type ManifestValue } from './manifest'; -import type { LoadingOverlayShowOpts, LoadingOverlayState } from '@/types/ui'; - -// ── What the server is doing ───────────────────────────────────────── - -export interface ScanProgress { - /** Kind of source being loaded (drives the overlay's initial step). */ - kind: SourceKind; - branch?: string; - /** Latest stream phase, or null when the load just started and no stream - * event has arrived yet (overlay shows the kind-based initial step). */ - phase: ScanPhase | null; - /** Cloning percent (0-100) when phase === Cloning. */ - percent?: number; - /** Cloning stage (e.g. Receiving, Updating) when present. */ - stage?: CloneStage; - /** Working-tree size on disk (MB) during the silent promisor blob fetch — - * a clone-progress heartbeat with no stage/percent. */ - mbOnDisk?: number; - /** Git's own counts for the clone, where its line carried them. */ - objects?: number; - objectsTotal?: number; - mib?: number; - /** Files scanned so far when phase === Scanning. */ - filesScanned?: number; - /** `pending` of the manifest most recently APPLIED this load. Absent until - * its first manifest event, so a previous repo's can't leak in. */ - appliedPending?: Manifest['pending']; -} - -/** Non-null while a source is actively loading; null when idle/done. */ -export const SCAN_PROGRESS = signal(null); - -// ── What the build is doing ────────────────────────────────────────── - -/** State of the most recent world rebuild. Decorating is the city already on - * screen with its deferred pass (trees and friends) still in flight. */ -export enum RebuildStatus { - /** Nothing has been built yet. Distinct from Idle so "a build finished" is - * answerable: consumers that wait for Idle used to pass at boot. */ - Pending = 'pending', - Idle = 'idle', - Rebuilding = 'rebuilding', - Decorating = 'decorating', - Error = 'error', -} - -export const REBUILD_STATUS = signal(RebuildStatus.Pending); - -/** The manifest the FINISHED city was built from, trees included: a consumer - * that aims the camera at a node needs that node to exist. */ -export const BUILT_MANIFEST = signal(null); - -/** Error message from the most recent failed rebuild; null when idle/success. */ -export const LAST_REBUILD_ERROR = signal(null); - -/** Progress beside "rebuilding…", for the one build nothing else reports: - * Timeline's no-overlay refetch. Every transition clears it. */ -export const REBUILD_DETAIL = signal(null); - -/** Epoch millis of the most recent finished apply, in whichever mode: a live - * scan or Timeline's history bundle both land here via markIdle. */ -export const LAST_UPDATED_AT = signal(0); - -/** Which stage the running build is on, null between builds. The one source - * behind both of its readouts (see state/loadingReactions.ts). */ -export const BUILD_PROGRESS = signal(null); - -// ── Status transitions (single owner of each state + its coupled writes) ── - -// Every rebuild path goes through these, so the status/error/timestamp set -// can't drift across the four call sites. markIdle ends every applyManifest. -export function markRebuilding(): void { - REBUILD_STATUS.value = RebuildStatus.Rebuilding; - REBUILD_DETAIL.value = null; - BUILD_PROGRESS.value = null; -} - -// Decoration is the build's last stage, not the end of it: Timeline's overlay -// stays up through the tree pass, and the trees land at the END of that stage. -export function markDecorating(): void { - REBUILD_STATUS.value = RebuildStatus.Decorating; - enterBuildStage(BuildStage.Decorate); -} - -/** The city is on screen. Set by the composer once the meshes exist and a frame - * carrying them has been presented — NOT when applyStructure returns. */ -export function markIdle(): void { - REBUILD_STATUS.value = RebuildStatus.Idle; - BUILT_MANIFEST.value = MANIFEST.peek(); - LAST_REBUILD_ERROR.value = null; - REBUILD_DETAIL.value = null; - BUILD_PROGRESS.value = null; - LAST_UPDATED_AT.value = Date.now(); -} - -export function markError(err: unknown): void { - REBUILD_STATUS.value = RebuildStatus.Error; - // Logged with the stack, where a developer can use it. The UI shows a generic - // line: the message names our internals and a user cannot act on it. - console.error('[codecity] city build failed', err); - LAST_REBUILD_ERROR.value = err instanceof Error ? err.message : String(err); - REBUILD_DETAIL.value = null; - BUILD_PROGRESS.value = null; -} - -/** How far along the rebuild already announced by markRebuilding is. */ -export function setRebuildDetail(detail: string | null): void { - REBUILD_DETAIL.value = detail; -} - -// ── Build stages ───────────────────────────────────────────────────── - -// The build's own transitions, owned here for the same reason as the status -// ones above. The plan is per build: only what runs is an honest denominator. - -/** Open a build on the first of the stages it is going to run. */ -export function beginBuild(stages: readonly BuildStage[]): void { - BUILD_PROGRESS.value = { stages, index: 0, percent: null }; -} - -/** Advance to a stage of the declared plan. A stage the plan didn't list is - * ignored rather than appended: the denominator was already shown. */ -export function enterBuildStage(stage: BuildStage): void { - const prev = BUILD_PROGRESS.peek(); - if (!prev) return; - const index = prev.stages.indexOf(stage); - if (index < 0 || index === prev.index) return; - BUILD_PROGRESS.value = { ...prev, index, percent: null }; -} - -/** Report progress within the current stage, for one that can measure itself. */ -export function setBuildStagePercent(percent: number): void { - const prev = BUILD_PROGRESS.peek(); - if (!prev || prev.percent === percent) return; - BUILD_PROGRESS.value = { ...prev, percent }; -} - -// ── What the overlay shows ─────────────────────────────────────────── - -/** Repo name in the loading overlay's header, shown before the manifest lands. - * Overlay-owned: showLoadingOverlay/hideLoadingOverlay control its lifetime. */ -export const PENDING_SOURCE_LABEL = signal(null); - -export const LOADING_OVERLAY = signal({ - visible: false, - showOpts: null, - activeStep: null, - stepTails: {}, -}); - -// A load that can be backed out of registers its own abort; null falls back to -// the App default. -export const LOADING_CANCEL = signal<(() => void) | null>(null); - -// peek, not value: these are called from inside other effects, and tracking the -// prior state would subscribe an effect to a signal it goes on to write. - -// Omitting onCancel leaves any registered handler in place, so a caller can -// pre-register one before the reaction shows the overlay. -export function showLoadingOverlay( - opts: LoadingOverlayShowOpts, - onCancel?: (() => void) | null -): void { - LOADING_OVERLAY.value = { - visible: true, - showOpts: opts, - activeStep: firstStepFor(opts.steps ?? LOADING_STEPS, opts.kind), - stepTails: {}, - }; - if (onCancel !== undefined) LOADING_CANCEL.value = onCancel; -} - -export function setLoadingCancel(onCancel: (() => void) | null): void { - LOADING_CANCEL.value = onCancel; -} - -export function hideLoadingOverlay(): void { - LOADING_OVERLAY.value = { ...LOADING_OVERLAY.peek(), visible: false }; - LOADING_CANCEL.value = null; - // The header belongs to the overlay, so it clears here rather than at each - // call site: one that forgets leaves a stale label over the next load. - PENDING_SOURCE_LABEL.value = null; -} - -export function setLoadingStep(step: LoadingStep): void { - const prev = LOADING_OVERLAY.peek(); - if (prev.activeStep === step) return; - LOADING_OVERLAY.value = { ...prev, activeStep: step }; -} - -export function setLoadingStepTail(step: LoadingStep, tail: string | null): void { - const prev = LOADING_OVERLAY.peek(); - LOADING_OVERLAY.value = { - ...prev, - stepTails: { ...prev.stepTails, [step]: tail }, - }; -} - -// ── The Live driver ────────────────────────────────────────────────── - -export function attachOverlayDriver(): () => void { - const stops = [attachScanReaction(), attachBuildReaction()]; - return () => stops.forEach((stop) => stop()); -} - -// The overlay's row only: these stages pass in a few frames, and the freshness -// readout flickering through them cost more attention than they are worth. -function attachBuildReaction(): () => void { - return effect(() => { - setLoadingStepTail(LoadingStep.Building, buildStageTail(BUILD_PROGRESS.value)); - }); -} - -function attachScanReaction(): () => void { - let overlayUp = false; - // How far down the list this load has got. A row that lights up again after a - // later one reads as the whole load starting over. - let reached = -1; - const advance = (step: LoadingStep): void => { - if (!overlayUp) return; - const index = LOADING_STEPS.indexOf(step); - if (index <= reached) return; - reached = index; - setLoadingStep(step); - }; - return effect(() => { - const p = SCAN_PROGRESS.value; - // The stream finishing is not the city appearing: Idle now means a frame of - // it has been presented, so every earlier status keeps the overlay up. - const status = REBUILD_STATUS.value; - const building = status === RebuildStatus.Rebuilding || status === RebuildStatus.Decorating; - const hide = () => { - if (overlayUp) hideLoadingOverlay(); - overlayUp = false; - }; - if (!p) { - if (building) { - // Stream done, city still assembling — keep the overlay on "Building". - advance(LoadingStep.Building); - return; - } - hide(); - return; - } - // A load's first event: a new list, so it starts from the top again. - if (p.phase === null) reached = -1; - // The skeleton's own build belongs to Sketching layout: placeholder heights - // are what that row draws. Building city is the real ones going up. - const sketching = p.appliedPending?.includes('metadata') ?? false; - if (p.appliedPending && !sketching) { - // Real heights: only git history is still streaming, and it adds trees to - // an already-correct city. Lift as soon as that paint lands. - if (!building) { - hide(); - return; - } - advance(LoadingStep.Building); - return; - } - if (!overlayUp) { - // null→non-null: show the overlay at the kind-based initial step - // (Resolving for git, Scanning for local). - showLoadingOverlay({ kind: p.kind, branch: p.branch }); - overlayUp = true; - reached = LOADING_STEPS.indexOf(firstStepFor(LOADING_STEPS, p.kind)); - } - advance(stepForPhase(p.phase, p.kind)); - if (p.phase === ScanPhase.CloneProgress) { - // A heartbeat during the silent promisor blob fetch has no percent at - // all, and shows the working tree growing on disk instead. - const tail = transferTail(p) ?? (p.mbOnDisk !== undefined ? `${p.mbOnDisk} MB` : null); - setLoadingStepTail(LoadingStep.Cloning, tail); - } else if (p.phase === ScanPhase.ScanProgress) { - setLoadingStepTail( - LoadingStep.Scanning, - p.filesScanned !== undefined ? `${p.filesScanned.toLocaleString()} files` : null - ); - } else if (p.phase === ScanPhase.PartialManifest || p.phase === ScanPhase.CompleteManifest) { - // Progress tails done. - setLoadingStepTail(LoadingStep.Cloning, null); - setLoadingStepTail(LoadingStep.Scanning, null); - } - // p.phase === null: just-started; showLoadingOverlay already set the - // kind-based initial step — nothing more to do until a real event. - }); -} diff --git a/app/src/state/stores/recents.ts b/app/src/state/stores/recents.ts new file mode 100644 index 000000000..14923af68 --- /dev/null +++ b/app/src/state/stores/recents.ts @@ -0,0 +1,56 @@ +// state/stores/recents.ts — the repos you have opened, most recent first. +// App-wide and persisted: it outlives any one city, and two cities open at +// once both feed it. + +import { persistedSignal } from '@/state/persist'; +import { PERSISTED_KEYS } from '@/constants/storage'; +import { MAX_RECENT_SOURCES } from '@/constants/ui'; +import { sameSourceIdentity } from '@/utils/sources'; +import { effect, untracked } from '@preact/signals'; +import type { CitySession } from '@/city/session/session'; + +export interface RecentSource { + src: string; // exactly what was typed / passed; goes into ?src= + branch?: string; // identity; identityBranch strips it for a local source + // Display only. The label comes from the git remote, so every worktree of one + // repo shares it and this is all that tells those rows apart. + checkout?: string; + label: string; // derived at save time: basename(src) or "owner/repo" + lastOpenedAt: number; // ms since epoch, for MRU sort +} + +/** Persisted list of recently-opened sources. Hydrates at module load. */ +export const RECENTS = persistedSignal(PERSISTED_KEYS.RECENTS, []); + +/** Push (or update) an entry, most-recent first. Dedupes by source identity, so + * a local path is one row regardless of checkout. Capped, oldest dropped. */ +export function pushRecent(entry: Omit): void { + const now = Date.now(); + const filtered = RECENTS.value.filter((r) => !sameSourceIdentity(r, entry)); + filtered.unshift({ ...entry, lastOpenedAt: now }); + RECENTS.value = filtered.slice(0, MAX_RECENT_SOURCES); +} + +/** Drop the entry matching the given source identity. No-op if not present. */ +export function removeRecent(src: string, branch?: string): void { + RECENTS.value = RECENTS.value.filter((r) => !sameSourceIdentity(r, { src, branch })); +} + +/** Record every repo `session` opens. Attached to the city you opened and to no + * other: the landing's backdrop shows you a repo you never asked for. */ +export function attachRecents(session: CitySession): () => void { + return effect(() => { + const cur = session.source.current.value; + const info = session.source.info.value; + if (!cur || !info.label) return; + untracked(() => + pushRecent({ + src: cur.src, + label: info.label, + branch: cur.branch, + // The checkout the manifest reports, which a worktree row is told apart by. + checkout: info.branch, + }) + ); + }); +} diff --git a/app/src/state/stores/serverData.ts b/app/src/state/stores/serverData.ts index f021d6aa8..32652a8d9 100644 --- a/app/src/state/stores/serverData.ts +++ b/app/src/state/stores/serverData.ts @@ -5,6 +5,9 @@ import { signal } from '@preact/signals'; import { DEFAULT_SERVER_CONFIG, type ServerConfig } from '@/api/config'; import type { DiscoverEntry } from '@/api/discover'; +import { useEffect } from 'preact/hooks'; +import { getServerConfig } from '@/api/config'; +import { getDiscover } from '@/api/discover'; export type { ServerConfig, DiscoverEntry }; export { DEFAULT_SERVER_CONFIG }; @@ -14,3 +17,19 @@ export const SERVER_CONFIG = signal(DEFAULT_SERVER_CONFIG); // Empty until the fetch lands, and forever if Discover is off. The tab keys its // visibility off that, so there is no "loaded yet?" flag to keep in step. export const DISCOVER = signal([]); + +/** The two boot reads the whole app shares, together rather than in series. */ +export function useServerData(): void { + useEffect(() => { + let cancelled = false; + void (async () => { + const [config, discover] = await Promise.all([getServerConfig(), getDiscover()]); + if (cancelled) return; + SERVER_CONFIG.value = config; + DISCOVER.value = discover; + })(); + return () => { + cancelled = true; + }; + }, []); +} diff --git a/app/src/state/stores/source.ts b/app/src/state/stores/source.ts deleted file mode 100644 index 69d1ea198..000000000 --- a/app/src/state/stores/source.ts +++ /dev/null @@ -1,253 +0,0 @@ -// state/stores/source.ts — which repo: the one you opened, the ones before it, -// the folders you hid inside it, and the one merely showing behind the landing. -// The excludes are here rather than in settings/ because useManifestSource -// sends them in the manifest URL beside src and branch. - -import { signal, computed, effect, type ReadonlySignal } from '@preact/signals'; -import { persistedSignal } from '@/state/persist'; -import { PERSISTED_KEYS } from '@/constants/storage'; -import { MAX_RECENT_SOURCES } from '@/constants/ui'; -import { URL_PARAMS } from '@/constants/urlParams'; -import { VIEW_PARAMS } from '@/router/params'; -import { ROUTES } from '@/router/paths'; -import { navigate, hrefFor, ROUTE_SEARCH, ROUTE_PATH } from '@/router/location'; -import { MANIFEST, setManifest } from '@/state/stores/manifest'; -import { - srcKind, - SourceKind, - resolveBranch, - identityBranch, - sourceKey, - sameSourceIdentity, -} from '@/utils/sources'; -import type { Manifest, SourceError } from '@/types'; - -// ── Currently-loaded source ────────────────────────────────────────── - -/** The applied source, or null when none is (cold boot / picker open). Written - * only by commitSource below, so it means "a load succeeded". */ -export const CURRENT_SOURCE = signal<{ src: string; branch?: string } | null>(null); - -/** The last load failure, or null. A fetch outcome, not a UI command: App - * reacts by opening the picker, and clears it when the user acts. */ -export const SOURCE_ERROR = signal(null); - -/** What is on screen: the project you opened, or the featured repo the landing - * renders. Lists mark rows against this, so one repo marks the same way. */ -export const ACTIVE_SOURCE = computed<{ src: string; branch?: string } | null>(() => { - const current = CURRENT_SOURCE.value; - if (current) return current; - const backdrop = BACKDROP_CITY.value; - return backdrop ? { src: backdrop.src, branch: backdrop.branch } : null; -}); - -/** The loaded source's stable hash, or null. Namespaces per-source storage. */ -export const CURRENT_SOURCE_KEY = computed(() => - CURRENT_SOURCE.value ? sourceKey(CURRENT_SOURCE.value.src, CURRENT_SOURCE.value.branch) : null -); - -/** Drop the load from the URL and go home: a cancel with nothing to fall back - * to must not leave a reload re-running what it called off. */ -export function clearSourceUrl(): void { - // Anything the app does not own (an ?utm_source, say) rides along home: only - // the params describing the load that was called off are dropped. - const params = new URLSearchParams(ROUTE_SEARCH.peek()); - for (const key of [...Object.values(URL_PARAMS), ...Object.values(VIEW_PARAMS)]) { - params.delete(key); - } - navigate(hrefFor(ROUTES.HOME, params), { replace: true }); -} - -// Reflect the applied source so reload/share reopens it, moving to /city if the -// load began at home. -effect(() => { - const cur = CURRENT_SOURCE.value; - if (!cur) return; - const params = new URLSearchParams(ROUTE_SEARCH.peek()); - // A different project than the URL was describing: its mode, scrub commit - // and selection belong to the one that just left. - const had = params.get(URL_PARAMS.SRC); - if ( - had && - !sameSourceIdentity({ src: had, branch: params.get(URL_PARAMS.BRANCH) ?? undefined }, cur) - ) { - for (const key of Object.values(VIEW_PARAMS)) params.delete(key); - } - params.set(URL_PARAMS.SRC, cur.src); - if (cur.branch) params.set(URL_PARAMS.BRANCH, cur.branch); - else params.delete(URL_PARAMS.BRANCH); - // From the switcher this is a place you went, so it pushes and Back returns - // to the list; already on /city (a re-scan, a deep link) is the same place. - const fromHome = ROUTE_PATH.peek() === ROUTES.HOME; - navigate(hrefFor(ROUTES.CITY, params), { replace: !fromHome }); -}); - -export interface SourceInfo { - /** Human-readable project label (owner/repo or directory name). */ - label: string; - /** Branch name when the loaded source is a git URL with a known branch. */ - branch: string | undefined; - /** Original git URL when the source is a hosted git repo. */ - sourceUrl: string | undefined; - /** Raw source as entered: the git URL for a remote, the path for a local. */ - src: string | undefined; -} - -/** A working tree on disk rather than a clone. Only a working tree can change - * under the app, so anything watching for change keys off this. */ -export const CURRENT_SOURCE_IS_LOCAL = computed(() => { - const cur = CURRENT_SOURCE.value; - return cur ? srcKind(cur.src) === SourceKind.Local : false; -}); - -export const SOURCE_INFO = computed(() => { - const cur = CURRENT_SOURCE.value; - const m = MANIFEST.value; - if (!cur || !m) { - return { label: '', branch: undefined, sourceUrl: undefined, src: undefined }; - } - const manifest = m as Manifest; - return { - label: manifest.tree?.name ?? '', - branch: resolveBranch(manifest, cur.branch), - sourceUrl: srcKind(cur.src) === SourceKind.Remote ? cur.src : undefined, - src: cur.src, - }; -}); - -// ── Recently-opened sources (persisted) ────────────────────────────── - -export interface RecentSource { - src: string; // exactly what was typed / passed; goes into ?src= - branch?: string; // identity; identityBranch strips it for a local source - // Display only. The label comes from the git remote, so every worktree of one - // repo shares it and this is all that tells those rows apart. - checkout?: string; - label: string; // derived at save time: basename(src) or "owner/repo" - lastOpenedAt: number; // ms since epoch, for MRU sort -} - -/** Persisted list of recently-opened sources. Hydrates at module load. */ -export const RECENTS = persistedSignal(PERSISTED_KEYS.RECENTS, []); - -/** Push (or update) an entry, most-recent first. Dedupes by source identity, so - * a local path is one row regardless of checkout. Capped, oldest dropped. */ -export function pushRecent(entry: Omit): void { - const now = Date.now(); - const filtered = RECENTS.value.filter((r) => !sameSourceIdentity(r, entry)); - filtered.unshift({ ...entry, lastOpenedAt: now }); - RECENTS.value = filtered.slice(0, MAX_RECENT_SOURCES); -} - -/** Commit a loaded source: CURRENT_SOURCE, its recents entry, and the manifest - * the UI reads. Every mode ends its load here, with its own manifest. */ -export function commitSource(src: string, branch: string | undefined, manifest: Manifest): void { - // ONE identity for the load, used by both writes below. Deriving it twice is - // what listed a repo twice (#185): see the test for the two rows it produced. - const checkout = resolveBranch(manifest, branch); - // A local source carries no branch: its checkout is dynamic, so identity omits - // it. The header still shows it, read off the manifest — display, not identity. - const idBranch = identityBranch(src, checkout); - // Source before manifest: the camera-reframe reaction reads CURRENT_SOURCE at - // apply-start, and the apply is kicked off by the manifest write. - CURRENT_SOURCE.value = { src, branch: idBranch }; - setManifest(manifest); - pushRecent({ - src, - // tree.name is the canonical owner/repo; a worktree's basename is its folder. - label: manifest.tree?.name || src, - branch: idBranch, - checkout, - }); -} - -/** Drop the entry matching the given source identity. No-op if not present. */ -export function removeRecent(src: string, branch?: string): void { - RECENTS.value = RECENTS.value.filter((r) => !sameSourceIdentity(r, { src, branch })); -} - -// ── The city behind the landing ─────────────────────────────────────── - -/** Which repo the backdrop came from. */ -export enum BackdropKind { - /** The most recent project, from whatever the server had cached for it. */ - Recent = 'recent', - /** The server's featured repo. */ - Featured = 'featured', -} - -export interface BackdropCity { - src: string; - label: string; - /** The loaded branch, normalised like CURRENT_SOURCE's: identity includes it, - * so a row storing @main only matches when this carries it too. */ - branch?: string; - kind: BackdropKind; -} - -/** Written only once the backdrop has actually painted, so nothing names a repo - * you can't see. Null means the hero image is what's showing. */ -export const BACKDROP_CITY = signal(null); - -// ── Folders you have hidden, per repo ───────────────────────────────── - -/** repo key -> sorted, de-duped rel-paths. Whole-object persistence: the keys - * are runtime repo hashes, so diff-vs-default would drop every write. */ -export const EXCLUDES = persistedSignal>( - PERSISTED_KEYS.EXCLUDES, - {}, - { whole: true } -); - -/** Repo-scoped key: src only (branch ignored) so excludes hold across branches. */ -function repoKeyFor(src: string): string { - return sourceKey(src); -} - -function currentRepoKey(): string | null { - const cur = CURRENT_SOURCE.value; - return cur ? repoKeyFor(cur.src) : null; -} - -/** The loaded repo's exclude list (empty when no source / none set). Reactive. */ -export const ACTIVE_EXCLUDES: ReadonlySignal = computed(() => { - const key = currentRepoKey(); - return key ? (EXCLUDES.value[key] ?? []) : []; -}); - -/** Peek the excludes for an explicit src — for the imperative fetch layer. */ -export function activeExcludePathsFor(src: string): string[] { - return EXCLUDES.peek()[repoKeyFor(src)] ?? []; -} - -/** Replace one repo's exclude list. Sorted and de-duped, and an empty list - * drops the slot so the store holds only repos that hide something. */ -export function setExcludesFor(src: string, next: readonly string[]): void { - const key = repoKeyFor(src); - const sorted = [...new Set(next)].sort(); - const map = { ...EXCLUDES.peek() }; - if (sorted.length === 0) delete map[key]; - else map[key] = sorted; - EXCLUDES.value = map; -} - -function setForCurrentRepo(next: string[]): void { - const cur = CURRENT_SOURCE.peek(); - if (!cur) return; // no source loaded: nothing to key against - setExcludesFor(cur.src, next); -} - -/** Hide `path` from the current repo's city. Sorted + de-duped. No-op if none. */ -export function addExclude(path: string): void { - setForCurrentRepo([...(EXCLUDES.peek()[currentRepoKey() ?? ''] ?? []), path]); -} - -/** Restore `path` (remove from the current repo's excludes). */ -export function removeExclude(path: string): void { - setForCurrentRepo((EXCLUDES.peek()[currentRepoKey() ?? ''] ?? []).filter((p) => p !== path)); -} - -/** Restore everything for the current repo. */ -export function clearExcludes(): void { - setForCurrentRepo([]); -} diff --git a/app/src/state/stores/timeline.ts b/app/src/state/stores/timeline.ts deleted file mode 100644 index efd1d41ec..000000000 --- a/app/src/state/stores/timeline.ts +++ /dev/null @@ -1,385 +0,0 @@ -// state/stores/timeline.ts — the history you are looking at and where you are in -// it: the mode, the loaded bundle, the scrub position, and everything that -// position implies. Derived reads live beside the position they read because -// resetting the mode moves it, so splitting them would make the two files cyclic. - -import { signal, batch, computed, effect, type ReadonlySignal } from '@preact/signals'; -import type { DirNode, Manifest, TimelineBundle, TreeNode } from '@/types'; -import { NodeKind } from '@/types'; -import { parseDateMs, epochDayAt } from '@/utils/dates'; -import { findNodeByPath } from '@/utils/manifest'; -import { MANIFEST, type ManifestValue } from './manifest'; -import { - buildPathTimelines, - ruinStateAt, - PathState, - blobShaAt, - entryAt, - statsAtDeletion, - lastModifiedIndexAt, -} from '@/city/timeline/replay'; -import type { PathTimeline } from '@/city/timeline/replay'; - -// ── Mode, history, and where you are in it ─────────────────────────── - -// Distinct render mode (union city + scrub). SCRUB_POS is a float commit index so scrubbing interpolates. -export const TIMELINE_MODE = signal(false); -export const TIMELINE_BUNDLE = signal(null); - -/** How many commits the loaded bundle holds. */ -const COMMIT_COUNT = computed(() => TIMELINE_BUNDLE.value?.commits.length ?? 0); - -/** Read once per Timeline entry rather than per frame: the stop today sits at - * has to hold still while it is being scrubbed to. */ -const _todayMs = signal(Date.now()); - -/** Today, when it is later than the last commit: the city goes on aging after - * the last thing anyone committed. Null when the newest commit is today. */ -export const SCRUB_TODAY_MS = computed(() => { - const bundle = TIMELINE_BUNDLE.value; - if (!bundle || bundle.commits.length === 0) return null; - // Parsed the way the bar prints dates, so the stop lands on the day it names. - const newest = parseDateMs(bundle.commits[bundle.commits.length - 1].date); - const today = _todayMs.value; - if (!Number.isFinite(newest)) return null; - // Whole days: a commit from this morning is not a stop away from now, and a - // track ending hours past its last tick reads as a rounding error. - return Math.floor(epochDayAt(today)) > Math.floor(epochDayAt(newest)) ? today : null; -}); - -/** The moment Timeline treats as now. Set on entry; tests pin it. */ -export function setTodayMs(ms: number): void { - _todayMs.value = ms; -} - -/** Highest valid scrub index for the loaded bundle, 0 when there is none. One - * past the last commit when today is a stop of its own. */ -export const SCRUB_MAX = computed( - () => Math.max(0, COMMIT_COUNT.value - 1) + (SCRUB_TODAY_MS.value === null ? 0 : 1) -); - -const _scrubPos = signal(0); - -// Clamped against the current bundle, not at each write, so a bundle swap alone -// can't leave a stale position out of range. Readers never clamp defensively. -export const SCRUB_POS: ReadonlySignal = computed(() => - Math.min(Math.max(_scrubPos.value, 0), SCRUB_MAX.value) -); - -/** The only way to move the scrubber; readonly SCRUB_POS makes that a type error to bypass. */ -export function setScrubPos(pos: number): void { - _scrubPos.value = pos; -} - -// The whole commit SCRUB_POS lands on, so anything keyed on presence recomputes -// once a crossing. Capped: past the last one the city is still its, only older. -export const SCRUB_COMMIT = computed(() => - Math.min(Math.floor(SCRUB_POS.value), Math.max(0, COMMIT_COUNT.value - 1)) -); - -// Anything that would reflow the layout waits for this to clear, or the track -// resizes under the pointer mid-drag and the position jumps. -export const SCRUB_DRAGGING = signal(false); - -// The commit content fetches key on: follows the scrub but holds still mid-drag, -// so dragging across a long history doesn't refetch once per commit crossed. -export const SETTLED_COMMIT = signal(0); - -// The same rest point as a position. SETTLED_COMMIT caps at the newest commit, -// so it can't tell that stop from the today stop past it; this can. -export const SETTLED_POS = signal(0); - -effect(() => { - const commit = SCRUB_COMMIT.value; - const pos = SCRUB_POS.value; - if (SCRUB_DRAGGING.value) return; - batch(() => { - SETTLED_COMMIT.value = commit; - SETTLED_POS.value = pos; - }); -}); - -// Called BEFORE the union city is packed: the mode tells the scene layer whose -// city to pack. The position follows, once its bundle is loaded. -export function beginTimelineMode(): void { - batch(() => { - TIMELINE_MODE.value = true; - _todayMs.value = Date.now(); - }); -} - -// Shared by every exit path (toggle-off, source switch); scene-free, the scene layer reacts to TIMELINE_MODE. -export function resetTimelineMode(): void { - batch(() => { - TIMELINE_MODE.value = false; - setScrubPos(0); - TIMELINE_BUNDLE.value = null; - SCRUB_DRAGGING.value = false; - }); -} - -// ── What that position implies ─────────────────────────────────────── - -const EMPTY: ReadonlySet = new Set(); - -// Timelines depend only on the bundle — rebuild when it changes, NOT per scrub. -// (A second copy from the scrub controller's; both are pure reads of the bundle.) -const _TIMELINES = computed | null>(() => { - const bundle = TIMELINE_BUNDLE.value; - return bundle ? buildPathTimelines(bundle) : null; -}); - -// Record present paths into `out`; returns whether this node is present. A file -// is present when live at pos; a directory is present iff any descendant is. -function _collect( - node: TreeNode, - timelines: Map, - pos: number, - out: Set -): boolean { - if (node.type === NodeKind.File) { - const pt = node.path != null ? timelines.get(node.path) : undefined; - const present = pt ? ruinStateAt(pt, pos) === PathState.Present : false; - if (present && node.path != null) out.add(node.path); - return present; - } - let anyPresent = false; - for (const child of node.children ?? []) { - if (_collect(child, timelines, pos, out)) anyPresent = true; - } - if (anyPresent && node.path != null) out.add(node.path); - return anyPresent; -} - -/** A file's measures at the scrub position, or at its deletion if already gone. */ -export interface ScrubbedFileStats { - // Null when the blob was never fetched: unknown at this commit, not zero. - lines: number | null; - bytes: number | null; - /** True when these are the values the file had when it was deleted. */ - atDeletion: boolean; -} - -// .value, not .peek(): the footer reads this in render. At the SETTLED commit, -// so the number always describes the blob the content fetch serves. -export function scrubbedStatsFor(path: string): ScrubbedFileStats | null { - if (!TIMELINE_MODE.value) return null; - const pt = _TIMELINES.value?.get(path); - if (!pt) return null; - const pos = SETTLED_COMMIT.value; - const gone = statsAtDeletion(pt, pos); - if (gone) return { lines: gone.lines, bytes: gone.bytes, atDeletion: true }; - const entry = entryAt(pt, pos); - if (!entry) return null; - return { lines: entry.lines, bytes: entry.bytes, atDeletion: false }; -} - -/** Blob sha for a path at the scrub position; null in Live or when absent. */ -export function scrubbedBlobShaFor(path: string | null | undefined): string | null { - if (!path || !TIMELINE_MODE.value) return null; - const pt = _TIMELINES.value?.get(path); - // Settled, not live: content fetches wait for the drag to end. - return pt ? blobShaAt(pt, SETTLED_COMMIT.value) : null; -} - -/** No content to fetch at this scrub position, so callers must NOT fall back to - * a by-path read: that hits HEAD, where a union file may not exist (#122). */ -export function hasNoContentAtScrub(path: string | null | undefined): boolean { - return TIMELINE_MODE.value && scrubbedBlobShaFor(path) === null; -} - -export const PRESENT_PATHS: ReadonlySignal> = computed(() => { - if (!TIMELINE_MODE.value) return EMPTY; - const bundle = TIMELINE_BUNDLE.value; - const timelines = _TIMELINES.value; - if (!bundle || !timelines) return EMPTY; - const pos = SCRUB_COMMIT.value; - const tree = (bundle.unionManifest as unknown as { tree?: TreeNode }).tree; - if (!tree) return EMPTY; - const out = new Set(); - _collect(tree, timelines, pos, out); - return out; -}); - -// ── The tree the panes render ──────────────────────────────────────── - -// Keep a node iff it's present at the scrubbed commit. A present directory always -// has ≥1 present descendant, so filtering its children never leaves it empty. -function _filterPresent(node: TreeNode, present: ReadonlySet): TreeNode | null { - if (!present.has(node.path ?? '')) return null; - if (node.type === NodeKind.File) return node; - const children: TreeNode[] = []; - for (const child of node.children ?? []) { - const kept = _filterPresent(child, present); - if (kept) children.push(kept); - } - return { ...node, children }; -} - -export const PANE_MANIFEST: ReadonlySignal = computed(() => { - const bundle = TIMELINE_BUNDLE.value; - if (!TIMELINE_MODE.value || !bundle) return MANIFEST.value; - - const union = bundle.unionManifest as unknown as ManifestValue; - const tree = (union as { tree?: DirNode } | null)?.tree; - if (!tree) return union; - - // Root always stays (an empty tree still needs a container); its children are - // filtered to the present-at-scrub subtrees. - const present = PRESENT_PATHS.value; - const children: TreeNode[] = []; - for (const child of tree.children ?? []) { - const kept = _filterPresent(child, present); - if (kept) children.push(kept); - } - return { ...(union as object), tree: { ...tree, children } } as unknown as ManifestValue; -}); - -// ── A folder's rollups at the scrub position ───────────────────────── - -// Mirrors api/scan/treebuild.py's accumulator: files add themselves, a finished -// subdirectory is absorbed whole, and the extensionless bucket sorts last. -interface Rollup { - count: number; - fileCount: number; - dirCount: number; - size: number; - createdMin: string | null; - modifiedMax: string | null; - ext: Map; -} - -function _emptyRollup(): Rollup { - return { - count: 0, - fileCount: 0, - dirCount: 0, - size: 0, - createdMin: null, - modifiedMax: null, - ext: new Map(), - }; -} - -// Empty stands for "no date", so it loses to any real one rather than sorting -// before every ISO string. -function _minIso(a: string | null, b: string | null | undefined): string | null { - if (!b) return a; - if (!a) return b; - return b < a ? b : a; -} - -function _maxIso(a: string | null, b: string | null | undefined): string | null { - if (!b) return a; - if (!a) return b; - return b > a ? b : a; -} - -function _addExt(roll: Rollup, ext: string | null, count: number, size: number): void { - const bucket = roll.ext.get(ext); - if (bucket) { - bucket.count += count; - bucket.size += size; - } else { - roll.ext.set(ext, { count, size }); - } -} - -function _extBreakdown(roll: Rollup): DirNode['descendants_ext_breakdown'] { - return [...roll.ext.entries()] - .map(([ext, { count, size }]) => ({ ext, count, size })) - .sort( - (a, b) => - b.count - a.count || - Number(a.ext === null) - Number(b.ext === null) || - (a.ext ?? '').localeCompare(b.ext ?? '') - ); -} - -/** The commit date of the last change to `path` at or before `pos`. The union's - * own `modified` is the last change EVER, which may be in the scrub's future. */ -function _modifiedAt(pt: PathTimeline, pos: number, commitDates: (string | undefined)[]): string { - const idx = lastModifiedIndexAt(pt, pos); - return commitDates[idx] ?? ''; -} - -// Post-order, so a directory absorbs children that already know their own -// totals. Returns null for anything absent at `pos`, which prunes emptied dirs. -function _rebuildAt( - node: TreeNode, - pos: number, - timelines: Map, - commitDates: (string | undefined)[], - into: Rollup -): TreeNode | null { - if (node.type === NodeKind.File) { - const pt = node.path != null ? timelines.get(node.path) : undefined; - if (!pt || ruinStateAt(pt, pos) !== PathState.Present) return null; - const entry = entryAt(pt, pos); - const size = entry?.bytes ?? 0; - const created = (node as { created?: string }).created ?? ''; - const modified = _modifiedAt(pt, pos, commitDates); - const file = { ...node, size, lines: entry?.lines ?? 0, modified } as TreeNode; - - into.count += 1; - into.fileCount += 1; - into.size += size; - into.createdMin = _minIso(into.createdMin, created); - into.modifiedMax = _maxIso(into.modifiedMax, modified); - const ext = (node as { extension?: string }).extension; - _addExt(into, ext ? ext.toLowerCase() : null, 1, size); - return file; - } - - const own = _emptyRollup(); - const files: TreeNode[] = []; - const subdirs: TreeNode[] = []; - for (const child of node.children ?? []) { - const kept = _rebuildAt(child, pos, timelines, commitDates, own); - if (!kept) continue; - (kept.type === NodeKind.File ? files : subdirs).push(kept); - } - if (files.length === 0 && subdirs.length === 0) return null; - - into.count += 1 + own.count; - into.fileCount += own.fileCount; - into.dirCount += 1 + own.dirCount; - into.size += own.size; - into.createdMin = _minIso(into.createdMin, own.createdMin); - into.modifiedMax = _maxIso(into.modifiedMax, own.modifiedMax); - for (const [ext, { count, size }] of own.ext) _addExt(into, ext, count, size); - - const children = [...files, ...subdirs]; - return { - ...node, - children, - children_count: children.length, - children_file_count: files.length, - children_dir_count: subdirs.length, - descendants_count: own.count, - descendants_file_count: own.fileCount, - descendants_dir_count: own.dirCount, - descendants_size: own.size, - descendants_created_min: own.createdMin, - descendants_modified_max: own.modifiedMax, - descendants_ext_breakdown: _extBreakdown(own), - } as TreeNode; -} - -/** A directory at the settled commit: the union's structure, every measure - * re-added from the per-blob numbers the buildings use. Null in Live. */ -export function scrubbedDirFor(path: string): DirNode | null { - if (!TIMELINE_MODE.value) return null; - const bundle = TIMELINE_BUNDLE.value; - const timelines = _TIMELINES.value; - if (!bundle || !timelines) return null; - const union = (bundle.unionManifest as unknown as { tree?: TreeNode }).tree; - if (!union) return null; - - const node = findNodeByPath(bundle.unionManifest as unknown as Manifest, path); - if (!node || node.type !== NodeKind.Directory) return null; - - const commitDates = bundle.commits.map((c) => (c as { date?: string }).date); - const rebuilt = _rebuildAt(node, SETTLED_COMMIT.value, timelines, commitDates, _emptyRollup()); - return (rebuilt as DirNode | null) ?? null; -} diff --git a/app/src/types/building.ts b/app/src/types/building.ts deleted file mode 100644 index 8cc2739d8..000000000 --- a/app/src/types/building.ts +++ /dev/null @@ -1,71 +0,0 @@ -// types/building.ts — pairs with state/stores/settings/buildings.ts (the tunables). -// Defines the Building shape the layout step produces; the city renderer -// (city/index.ts createCity) reads it to instantiate three.js meshes. - -import type { FileNode } from './manifest'; - -/** Direction the door faces. Layout sets this; engine reads it. */ -export enum BuildingOrient { - North = 'n', - South = 's', - East = 'e', - West = 'w', -} - -/** - * One building in the laid-out city. - * x, y — center on the ground plane (world units, y is along the second axis) - * w, d — footprint width (x-axis) and depth (z-axis) - * h — height (y-axis, world coords) - * color — HSL string computed by colors.ts - * file — the manifest file node this building represents - * floors — stamped during layout for label/tooltip use - */ -export interface Building { - x: number; - y: number; - w: number; - d: number; - h: number; - color: string; - /** - * 0..1 weathering signal — 0 = file created most recently in the repo, - * 1 = file created earliest. Normalized against the repo's - * minCreated/maxCreated so the scale rescales as the codebase ages. - * Independent of `color` (which keys on last-modified): a bright, - * recently-edited file can still be highly weathered if it was - * originally created long ago. - * - * Optional because layout produces buildings without it; the - * world post-layout step fills it in after the manifest's - * date ranges are known. Buffer-builder defaults to 0 when absent. - */ - createdAge?: number; - - /** - * 0..1 staleness signal — 0 = file modified most recently in the repo, - * 1 = file modified earliest. Normalized against the repo's - * minModified/maxModified so the scale rescales as the codebase ages. - * Mirror of `createdAge` but on the modified-date axis. - * - * Optional because layout produces buildings without it; the - * world post-layout step fills it in after the manifest's - * date ranges are known. Buffer-builder defaults to 0 when absent. - */ - modifiedAge?: number; - file: FileNode; - orient: BuildingOrient; - floors?: number; - - /** - * Cell ID for spatial grid bucketing. Set by CellTile insertion. - * Used by BuildingIndex for reverse lookup (raycaster hit → building). - */ - cellId?: number; - - /** - * Slot ID within a cell's InstancedMesh. Set by CellTile insertion. - * Combined with cellId as "cellId:slotId" key for byCellSlot index. - */ - slotId?: number; -} diff --git a/app/src/types/index.ts b/app/src/types/index.ts index ea669615e..96fe9d0bf 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -1,12 +1,8 @@ // types/index.ts — barrel re-export. Lets callers do -// import { NodeKind, FileNode, Building } from '@/types'; +// import { NodeKind, FileNode } from '@/types'; +import { Building } from '@/city/scene/types'; // without remembering which subfile owns what. export * from './manifest'; export * from './timeline'; -export * from './building'; -export * from './street'; -export * from './scene'; -export * from './picker'; -export * from './animation'; export * from './ui'; diff --git a/app/src/types/manifest.contract.ts b/app/src/types/manifest.contract.ts index fc4cb6bf8..bcf2ea3fc 100644 --- a/app/src/types/manifest.contract.ts +++ b/app/src/types/manifest.contract.ts @@ -2,7 +2,7 @@ // What a derived type can't check: our hand-written enums still match the wire. import type { components } from './manifest.generated'; import { NodeKind } from './manifest'; -import { ScanPhase, CloneStage } from '@/api/manifest'; +import { ScanPhase, CloneStage } from '@/city/session/api/manifest'; import { TimelineStage } from './timeline'; type Schemas = components['schemas']; diff --git a/app/src/types/manifest.generated.ts b/app/src/types/manifest.generated.ts index d57820849..4487570e2 100644 --- a/app/src/types/manifest.generated.ts +++ b/app/src/types/manifest.generated.ts @@ -264,19 +264,6 @@ export interface components { * @enum {string} */ CloneStage: "receiving" | "resolving" | "counting" | "updating"; - /** CommitDateRange */ - CommitDateRange: { - /** - * Oldest - * @description Oldest commit date (YYYY-MM-DD), or null when the repo has no commits - */ - oldest: string | null; - /** - * Newest - * @description Newest commit date (YYYY-MM-DD), or null when the repo has no commits - */ - newest: string | null; - }; /** CommitDetailResponse */ CommitDetailResponse: { /** Sha */ @@ -353,17 +340,6 @@ export interface components { /** Message */ message: string; }; - /** DateRangeMs */ - DateRangeMs: { - /** Mincreated */ - minCreated: number; - /** Maxcreated */ - maxCreated: number; - /** Minmodified */ - minModified: number; - /** Maxmodified */ - maxModified: number; - }; /** DateRanges */ DateRanges: { /** @@ -684,7 +660,6 @@ export interface components { * @description Commits in the full history, however many `commits` carries */ commitCount: number; - commitDates: components["schemas"]["CommitDateRange"]; maxCommitsPerDay: components["schemas"]["DayLeader"] | null; /** Maxcommitstreakdays */ maxCommitStreakDays: number; @@ -753,8 +728,6 @@ export interface components { }; /** Commitlineranges */ commitLineRanges: components["schemas"]["RangeStat"][]; - /** Commitdateranges */ - commitDateRanges: components["schemas"]["DateRangeMs"][]; /** Notes */ notes: string[]; }; diff --git a/app/src/types/manifest.ts b/app/src/types/manifest.ts index ce5fcfe12..0eb7b268f 100644 --- a/app/src/types/manifest.ts +++ b/app/src/types/manifest.ts @@ -33,7 +33,6 @@ export type RangeStat = Schemas['RangeStat']; export type FileLeader = Schemas['FileLeader']; export type DirLeader = Schemas['DirLeader']; export type CommitLeader = Schemas['CommitLeader']; -export type CommitDateRange = Schemas['CommitDateRange']; export type DayLeader = Schemas['DayLeader']; export type AuthorStat = Schemas['AuthorStat']; export type RepoStats = Schemas['RepoStats']; diff --git a/app/src/types/ui.ts b/app/src/types/ui.ts index a0dab4a54..b50655ebe 100644 --- a/app/src/types/ui.ts +++ b/app/src/types/ui.ts @@ -3,9 +3,20 @@ import type { SourceKind } from '@/utils/sources'; import type { LoadingStep } from '@/constants/progress'; -import type { ScanErrorCode } from '@/api/manifest'; +import type { ScanErrorCode } from '@/city/session/api/manifest'; /** Left-sidebar tab IDs. Discriminator on the activity bar's mounted pane. */ +/** One stat on a selection pane's bottom row, or in a hover tooltip: the two + * read the same numbers because they build them from the same items. */ +export interface StatItem { + text: string; + /** Hover tooltip, for a value the short form abbreviates (an exact date). + * Defaults to the text, so a truncated item can still be read in full. */ + title?: string; + /** How readily this item gives up width. Higher yields first; default 1. */ + shrink?: number; +} + export enum SidebarTab { Explore = 'explore', Search = 'search', @@ -17,6 +28,9 @@ export enum SidebarTab { export interface SourcePayload { src: string; branch?: string; + /** What to call it until the server says: whoever opened it may already know + * (a recents row does), and the overlay is up before the first event. */ + label?: string; /** When true, this open forces a fresh scan (server-side ?no_cache=1). * Not persisted — re-opening from a recent uses cached scan by default. */ skipCache?: boolean; @@ -34,7 +48,9 @@ export interface SourceError { /** Options for showing the loading overlay. */ export interface LoadingOverlayShowOpts { - kind: SourceKind; + /** Null when no source was fetched at all: a build-only overlay has no + * clone or scan rows to decide the shape of. */ + kind: SourceKind | null; branch?: string; /** Custom step list (e.g. Timeline-mode entry). Defaults to LOADING_STEPS. */ steps?: readonly LoadingStep[]; diff --git a/app/src/components/panes/PaneStats/statItems.ts b/app/src/utils/statItems.ts similarity index 77% rename from app/src/components/panes/PaneStats/statItems.ts rename to app/src/utils/statItems.ts index 38a7501f4..1c8a4b56d 100644 --- a/app/src/components/panes/PaneStats/statItems.ts +++ b/app/src/utils/statItems.ts @@ -1,13 +1,13 @@ -// components/panes/PaneStats/statItems.ts — Builds the stat rows the file and road -// panes hand to . Pure: the caller supplies the node, so the same -// formatting rules serve both panes without either reaching for the picker. +// utils/statItems.ts — the stat rows a file or a road reports. Pure: the caller +// supplies the node, so the selection panes and the city's hover tooltip read +// the same numbers without either reaching for the picker. import type { FileNode, DirNode } from '@/types'; import { formatShortDate, formatRelativeAgeShort, parseDateMs } from '@/utils/dates'; import { formatBytes } from '@/utils/format'; import { humanLanguageFor } from '@/utils/syntaxLanguages'; -import { scrubbedStatsFor } from '@/state/stores/timeline'; -import type { PaneStatItem } from './PaneStats'; +import type { TimelineStore } from '@/city/session/stores/timeline'; +import type { StatItem } from '@/types/ui'; /** Direct and recursive counts as one item, collapsing to a single number when * a folder has no subfolders to distinguish them. */ @@ -15,7 +15,7 @@ function countItem( direct: number | null | undefined, total: number | null | undefined, label: string -): PaneStatItem | null { +): StatItem | null { if (direct == null && total == null) return null; if (direct == null) return { text: `${total} ${label}`, title: `${total} total` }; if (total == null || direct === total) { @@ -28,7 +28,7 @@ function countItem( } /** Relative age with the exact date as its tooltip. */ -function ageItem(iso: string, label: string, now: number): PaneStatItem { +function ageItem(iso: string, label: string, now: number): StatItem { return { text: `${label} ${formatRelativeAgeShort(parseDateMs(iso), now)}`, title: `${label} ${formatShortDate(iso)}`, @@ -41,14 +41,17 @@ export interface FileStatOpts { dates?: boolean; /** Reference point for the relative ages. Defaults to now. */ now?: number; + /** The history this file is being read through, when it is being scrubbed: + * the replayed numbers win over the node's max-over-history ones. */ + timeline?: TimelineStore | null; } -export function fileStatItems(file: FileNode, opts: FileStatOpts = {}): PaneStatItem[] { - const { dates = true, now = Date.now() } = opts; - const items: PaneStatItem[] = []; +export function fileStatItems(file: FileNode, opts: FileStatOpts = {}): StatItem[] { + const { dates = true, now = Date.now(), timeline = null } = opts; + const items: StatItem[] = []; // In Timeline the static node carries max-over-history values, so the replayed // ones win where they exist (at deletion for a file already gone). - const scrubbed = file.path != null ? scrubbedStatsFor(file.path) : null; + const scrubbed = file.path != null ? (timeline?.scrubbedStatsFor(file.path) ?? null) : null; const lines = scrubbed ? scrubbed.lines : file.lines; const size = scrubbed ? scrubbed.bytes : file.size; @@ -73,10 +76,10 @@ export function fileStatItems(file: FileNode, opts: FileStatOpts = {}): PaneStat return items; } -export function directoryStatItems(dir: DirNode): PaneStatItem[] { +export function directoryStatItems(dir: DirNode): StatItem[] { // Lead with the kind: a folder named `app` and a file named `app` look alike, // and the counts that follow only make sense once you know which this is. - const items: PaneStatItem[] = [{ text: 'directory', shrink: 200 }]; + const items: StatItem[] = [{ text: 'directory', shrink: 200 }]; const files = countItem(dir.children_file_count, dir.descendants_file_count, 'files'); if (files) items.push(files); const dirs = countItem(dir.children_dir_count, dir.descendants_dir_count, 'dirs'); diff --git a/app/src/views/CityView/CityView.tsx b/app/src/views/CityView/CityView.tsx index e8ebb348e..bbe1220fe 100644 --- a/app/src/views/CityView/CityView.tsx +++ b/app/src/views/CityView/CityView.tsx @@ -4,7 +4,6 @@ // and the syntax theme the file preview reads. import './CityView.css'; -import { useEffect } from 'preact/hooks'; import { useSignalEffect } from '@preact/signals'; import { CityHeader } from './chrome/CityHeader/CityHeader'; @@ -16,38 +15,28 @@ import { LoadingOverlay } from '@/components/loading/LoadingOverlay/LoadingOverl import { HljsThemeLink } from '@/views/CityView/HljsThemeLink/HljsThemeLink'; import { SelectionAnnouncer } from '@/views/CityView/SelectionAnnouncer/SelectionAnnouncer'; import { useShortcutsKey } from '@/hooks/useShortcutsKey'; -import { cancelLoad, refreshCurrentSource } from '@/hooks/useManifestSource'; -import { attachViewUrlReactions } from '@/router/viewBinding'; import { navigate } from '@/router/location'; import { ROUTES } from '@/router/paths'; -import { LOADING_CANCEL } from '@/state/stores/progress'; -import { CURRENT_SOURCE, clearSourceUrl } from '@/state/stores/source'; -import { - clearSelection, - runCollisionCheck, - runStemDiagnostic, - runTreeGroundingCheck, -} from '@/city/sceneHandle'; +import { clearSourceUrl } from '@/router/urlBinding'; +import { useCity } from '@/city/CityProvider'; export function CityView() { + const session = useCity(); + const { source, progress, commands, load } = session; // The panel it opens lives in this view's footer, so the key belongs here. useShortcutsKey(); - // Mode, scrub commit and selection are this view's: the landing has nothing - // to describe, and reflecting there would write them onto `/`. - useEffect(() => attachViewUrlReactions(), []); - // A newly loaded source has nothing selected in it yet. useSignalEffect(() => { - if (CURRENT_SOURCE.value) clearSelection(); + if (source.current.value) commands.clearSelection(); }); const onCancelLoad = () => { // A load with something to go back to registers its own handler; one with // nothing to go back to leaves the URL describing what it just called off. - const registered = LOADING_CANCEL.peek(); + const registered = progress.cancel.peek(); if (registered) registered(); else { - cancelLoad(); + load.cancel(); clearSourceUrl(); } }; @@ -59,16 +48,16 @@ export function CityView() { {/* The header owns the control; which read a refresh means in the mode you are in is the fetch layer's call. */} - navigate(ROUTES.HOME)} onRefresh={refreshCurrentSource} /> + navigate(ROUTES.HOME)} onRefresh={load.refresh} />
{/* Belongs to this route: a load started from the landing shows its progress there, in the card it was started from. */} diff --git a/app/src/views/CityView/SelectionAnnouncer/SelectionAnnouncer.tsx b/app/src/views/CityView/SelectionAnnouncer/SelectionAnnouncer.tsx index 9f0a19518..5ed3f6285 100644 --- a/app/src/views/CityView/SelectionAnnouncer/SelectionAnnouncer.tsx +++ b/app/src/views/CityView/SelectionAnnouncer/SelectionAnnouncer.tsx @@ -2,9 +2,9 @@ // live region speaking the city selection to screen readers, since the canvas is // a graphic and cannot announce its own (WCAG 4.1.3). import { useComputed } from '@preact/signals'; -import { SCENE_HANDLE } from '@/city/sceneHandle'; +import { useCity } from '@/city/CityProvider'; import { NodeKind } from '@/types'; -import type { PickTarget } from '@/types'; +import type { PickTarget } from '@/city/scene/types/picker'; function describe(sel: PickTarget | null): string { if (!sel) return ''; @@ -21,11 +21,12 @@ function describe(sel: PickTarget | null): string { } export function SelectionAnnouncer() { + const { scene } = useCity(); // The computed dedupes an equal string, so re-resolving the same selection // across a rebuild does not re-announce it. const message = useComputed(() => { - const handle = SCENE_HANDLE.value; - return handle ? describe(handle.picker.selection.value) : ''; + const made = scene.value; + return made ? describe(made.picker.selection.value) : ''; }); return (
diff --git a/app/src/views/CityView/chrome/CityFooter/transferGroups.ts b/app/src/views/CityView/chrome/CityFooter/transferGroups.ts index 7e4d65bbd..6bbce4397 100644 --- a/app/src/views/CityView/chrome/CityFooter/transferGroups.ts +++ b/app/src/views/CityView/chrome/CityFooter/transferGroups.ts @@ -8,7 +8,7 @@ import { TransferFamily, type TransferGroup } from '@/state/settings/transfer'; import { CONTROLS_SECTIONS } from '@/views/CityView/panes/ControlsPane/ControlsPane'; import { ACCENT_THEME, SURFACE_THEME } from '@/state/settings/fields/theme'; import { SYNTAX_THEME } from '@/state/settings/fields/syntaxTheme'; -import { LIVE_UPDATES } from '@/state/settings/fields/updates'; +import { LIVE_UPDATES } from '@/city/session/settings/updates'; /** Settings that deliberately do not travel. Auto-refresh is a fact about this * machine and this server, not a look: an imported 1s poll hammers a stranger's. */ diff --git a/app/src/views/CityView/chrome/CityHeader/CityHeader.tsx b/app/src/views/CityView/chrome/CityHeader/CityHeader.tsx index 2bffef9f3..5a8ad15b6 100644 --- a/app/src/views/CityView/chrome/CityHeader/CityHeader.tsx +++ b/app/src/views/CityView/chrome/CityHeader/CityHeader.tsx @@ -5,8 +5,6 @@ import './CityHeader.css'; import { ExternalLink } from 'lucide-preact'; -import { SOURCE_INFO } from '@/state/stores/source'; -import { MANIFEST } from '@/state/stores/manifest'; import type { Manifest } from '@/types'; import { navigate } from '@/router/location'; import { ROUTES } from '@/router/paths'; @@ -15,6 +13,7 @@ import { ChromeCluster } from '@/views/CityView/chrome/ChromeCluster/ChromeClust import { ProjectSwitcher } from '@/components/sources/ProjectSwitcher/ProjectSwitcher'; import { CopyButton } from '@/components/buttons/CopyButton/CopyButton'; import { ScanMenu } from '@/components/menus/ScanMenu/ScanMenu'; +import { useCity } from '@/city/CityProvider'; export interface AppHeaderProps { /** Fires when the user clicks the project chip to switch source. */ @@ -25,8 +24,9 @@ export interface AppHeaderProps { } export function CityHeader({ onSwitchSource, onRefresh }: AppHeaderProps = {}) { - const si = SOURCE_INFO.value; - const remoteUrl = (MANIFEST.value as Manifest)?.repo?.remote_url ?? null; + const { source, manifest } = useCity(); + const si = source.info.value; + const remoteUrl = (manifest.current.value as Manifest)?.repo?.remote_url ?? null; // Nothing loaded: the freshness cluster would be reporting on a project that // doesn't exist, and refresh would have nothing to re-open. const hasProject = Boolean(si.src); diff --git a/app/src/views/CityView/chrome/CitySidebarLeft/CitySidebarLeft.tsx b/app/src/views/CityView/chrome/CitySidebarLeft/CitySidebarLeft.tsx index 5b0a50951..e1a18cff9 100644 --- a/app/src/views/CityView/chrome/CitySidebarLeft/CitySidebarLeft.tsx +++ b/app/src/views/CityView/chrome/CitySidebarLeft/CitySidebarLeft.tsx @@ -9,16 +9,14 @@ import { ACTIVITY_BAR_TABS, DEFAULT_SIDEBAR_TAB, TabPlacement } from '@/constant import { SIDEBAR_TAB, SIDEBAR_COLLAPSED } from '@/state/stores/chrome'; import { CHANGED_SETTINGS_COUNT } from '@/state/settings/indicators'; import { SidebarTab, NodeKind } from '@/types'; -import type { PickTarget, TreeNode } from '@/types'; -import { SCENE_HANDLE, goToPath, hoverPath, clearHover } from '@/city/sceneHandle'; -import { MANIFEST } from '@/state/stores/manifest'; -import { PANE_MANIFEST } from '@/state/stores/timeline'; -import { CURRENT_SOURCE } from '@/state/stores/source'; +import type { TreeNode } from '@/types'; +import type { PickTarget } from '@/city/scene/types/picker'; import { ExplorePane } from '@/views/CityView/panes/ExplorePane/ExplorePane'; import { InfoPane } from '@/views/CityView/panes/InfoPane/InfoPane'; import { SearchPane } from '@/views/CityView/panes/SearchPane/SearchPane'; import { ControlsPane } from '@/views/CityView/panes/ControlsPane/ControlsPane'; import { Sidebar, SidebarSide } from '@/components/panes/Sidebar/Sidebar'; +import { useCity } from '@/city/CityProvider'; // ── Helpers ────────────────────────────────────────────────────────── @@ -81,6 +79,7 @@ function ActivityBar({ activeTab, collapsed, onIconClick }: ActivityBarProps) { // ── Main component ─────────────────────────────────────────────────── export function CitySidebarLeft() { + const { manifest, source, timeline, commands, scene } = useCity(); // Both live in the store so the header can send you to a pane; still not // persisted, and still force-closed on every world load. const activeTab = SIDEBAR_TAB; @@ -95,7 +94,7 @@ export function CitySidebarLeft() { // Mirror picker.selection.value → selectedPath. Re-runs on every // SCENE_HANDLE swap and every picker change. useSignalEffect(() => { - const handle = SCENE_HANDLE.value; + const handle = scene.value; if (!handle) { selectedPath.value = null; return; @@ -104,7 +103,7 @@ export function CitySidebarLeft() { }); useSignalEffect(() => { - const handle = SCENE_HANDLE.value; + const handle = scene.value; if (!handle) { hoveredPath.value = null; return; @@ -115,7 +114,7 @@ export function CitySidebarLeft() { // Closed on every world commit, so a new city opens unobscured. Live reloads // don't write CURRENT_SOURCE, so this can't fight a manual change. useSignalEffect(() => { - if (CURRENT_SOURCE.value) { + if (source.current.value) { activeTab.value = DEFAULT_SIDEBAR_TAB; collapsed.value = true; } @@ -123,7 +122,7 @@ export function CitySidebarLeft() { // Auto-collapse when the manifest has no content (cold-boot empty state). // The activity bar stays visible but the panel is hidden. - const manifestIsEmpty = useComputed(() => !MANIFEST.value); + const manifestIsEmpty = useComputed(() => !manifest.current.value); const onIconClick = (tab: SidebarTab) => { if (!collapsed.value && tab === activeTab.value) { @@ -141,10 +140,10 @@ export function CitySidebarLeft() { // Bound at call time, so they always reach the live scene. Rows hand back a // node; the path-shaped handlers use the scene commands directly below. const onTreeSelect = (node: TreeNode) => { - if (node?.path) goToPath(node.path); + if (node?.path) commands.goToPath(node.path); }; const onTreeHover = (node: TreeNode) => { - if (node?.path) hoverPath(node.path); + if (node?.path) commands.hoverPath(node.path); }; // Effective collapsed: forced when manifest is empty. @@ -163,21 +162,25 @@ export function CitySidebarLeft() {
{tab === SidebarTab.Explore && ( )} {tab === SidebarTab.Search && ( - + )} - {tab === SidebarTab.Info && } + {tab === SidebarTab.Info && } {tab === SidebarTab.Controls && ( )} diff --git a/app/src/views/CityView/chrome/CitySidebarRight/CitySidebarRight.tsx b/app/src/views/CityView/chrome/CitySidebarRight/CitySidebarRight.tsx index a2af9b533..32d2ffef0 100644 --- a/app/src/views/CityView/chrome/CitySidebarRight/CitySidebarRight.tsx +++ b/app/src/views/CityView/chrome/CitySidebarRight/CitySidebarRight.tsx @@ -5,24 +5,10 @@ import './CitySidebarRight.css'; import { useComputed } from '@preact/signals'; import { NodeKind } from '@/types'; +import type { CityScene } from '@/city/scene/types'; +import type { ManifestValue } from '@/city/session/stores/manifest'; import type { CommitEntry, DirNode, FileNode, Manifest } from '@/types'; -import { - SCENE_HANDLE, - type SceneHandle, - clearSelection, - focusPath, - focusCommit, -} from '@/city/sceneHandle'; -import { MANIFEST } from '@/state/stores/manifest'; -import { - PANE_MANIFEST, - PRESENT_PATHS, - TIMELINE_MODE, - scrubbedDirFor, -} from '@/state/stores/timeline'; -import { SOURCE_INFO, addExclude } from '@/state/stores/source'; import { ROOT_PATH } from '@/constants/manifest'; -import { viewCommitInTimeline } from '@/hooks/useTimelineMode'; import { findNodeByPath, sourceOf } from '@/utils/manifest'; import { FilePreviewPane } from '@/views/CityView/panes/FilePreviewPane/FilePreviewPane'; import type { FilePreviewPaneState } from '@/views/CityView/panes/FilePreviewPane/FilePreviewPane'; @@ -32,6 +18,7 @@ import { StreetPane } from '@/views/CityView/panes/StreetPane/StreetPane'; import type { StreetPaneState } from '@/views/CityView/panes/StreetPane/StreetPane'; import { Sidebar, SidebarSide } from '@/components/panes/Sidebar/Sidebar'; import { SELECTION_PANE_DISMISSED, dismissSelectionPane } from '@/state/stores/chrome'; +import { useCity } from '@/city/CityProvider'; /** Which pane the right sidebar is showing, from the current picker selection. */ enum SidebarPaneKind { @@ -41,12 +28,14 @@ enum SidebarPaneKind { } // The commit and street panes need things no manifest field holds, so these -// derive them from the scene handle. +// derive them from the city and the manifest it was built from. -function commitStateFor(handle: SceneHandle, commit: CommitEntry): CommitPaneState { - // peek: the calling computed already re-derives on MANIFEST, so a tracked - // read here would only double it. - const m = MANIFEST.peek() as Manifest; +function commitStateFor( + handle: CityScene, + manifest: ManifestValue, + commit: CommitEntry +): CommitPaneState { + const m = manifest as Manifest; return { commit, remoteUrl: m?.repo?.remote_url ?? null, @@ -60,10 +49,11 @@ function commitStateFor(handle: SceneHandle, commit: CommitEntry): CommitPaneSta // ── Main component ─────────────────────────────────────────────────── export function CitySidebarRight() { + const { manifest, source, timeline, timelineMode, commands, scene } = useCity(); // Computeds read during render: no effect writing signals, no bridge, and a // live-update poll re-derives every pane on its own. const activeKind = useComputed(() => { - const sel = SCENE_HANDLE.value?.picker.selection.value ?? null; + const sel = scene.value?.picker.selection.value ?? null; // Every selection opens the panel: the sidebar is the only place one is // shown. The panes handle the union-city caveat themselves. if (sel?.kind === NodeKind.Commit) return SidebarPaneKind.Commit; @@ -74,8 +64,8 @@ export function CitySidebarRight() { const fileState = useComputed(() => { // History manifest, so the pane follows the scrub: a file absent at this // commit says so here instead of quietly showing HEAD's version. - const m = PANE_MANIFEST.value as Manifest | DirNode | null; - const sel = SCENE_HANDLE.value?.picker.selection.value ?? null; + const m = timeline.paneManifest.value as Manifest | DirNode | null; + const sel = scene.value?.picker.selection.value ?? null; if (sel?.kind !== NodeKind.File) return { file: null }; const fresh = findNodeByPath(m, sel.file.path); // Excludes never reach here: they're filtered out of the timeline union too, @@ -85,44 +75,44 @@ export function CitySidebarRight() { file: present ? fresh : sel.file, // The scrub manifest's own source: its paths are what the pane reads by. source: sourceOf(m as Manifest | null), - rootLabel: SOURCE_INFO.value.label, + rootLabel: source.info.value.label, rootPath: (m as Manifest)?.tree?.path ?? ROOT_PATH, remoteUrl: (m as Manifest)?.repo?.remote_url ?? null, - branch: SOURCE_INFO.value.branch, - isAbsent: TIMELINE_MODE.value && !present, + branch: source.info.value.branch, + isAbsent: timeline.mode.value && !present, }; }); const commitState = useComputed(() => { - void MANIFEST.value; // re-derive on live-update rebuilds - const inTimeline = TIMELINE_MODE.value; // re-derive so the button label tracks the mode - const handle = SCENE_HANDLE.value; + void manifest.current.value; // re-derive on live-update rebuilds + const inTimeline = timeline.mode.value; // re-derive so the button label tracks the mode + const handle = scene.value; const sel = handle?.picker.selection.value ?? null; return handle && sel?.kind === NodeKind.Commit ? { - ...commitStateFor(handle, sel.commit), - source: sourceOf(MANIFEST.value as Manifest | null), + ...commitStateFor(handle, manifest.current.value, sel.commit), + source: sourceOf(manifest.current.value as Manifest | null), inTimeline, } : { commit: null }; }); const streetState = useComputed(() => { - const m = MANIFEST.value as Manifest | DirNode | null; - const sel = SCENE_HANDLE.value?.picker.selection.value ?? null; + const m = manifest.current.value as Manifest | DirNode | null; + const sel = scene.value?.picker.selection.value ?? null; if (sel?.kind !== NodeKind.Directory) return { directory: null }; // In Timeline the rollups are re-added at the settled commit from the same // per-blob numbers the buildings use, so the pane cannot disagree with them. - const fresh = TIMELINE_MODE.value - ? scrubbedDirFor(sel.dir.path) + const fresh = timeline.mode.value + ? timeline.scrubbedDirFor(sel.dir.path) : findNodeByPath(m, sel.dir.path); return { directory: fresh?.type === NodeKind.Directory ? fresh : sel.dir, - rootLabel: SOURCE_INFO.value.label, + rootLabel: source.info.value.label, rootPath: (m as Manifest)?.tree?.path ?? ROOT_PATH, remoteUrl: (m as Manifest)?.repo?.remote_url ?? null, - branch: SOURCE_INFO.value.branch, + branch: source.info.value.branch, // The same set the sidebar tree filters on, so a road the tree has dropped // can't still read as live here. - isAbsent: TIMELINE_MODE.value && !PRESENT_PATHS.value.has(sel.dir.path), + isAbsent: timeline.mode.value && !timeline.presentPaths.value.has(sel.dir.path), }; }); @@ -134,13 +124,13 @@ export function CitySidebarRight() { // Clearing the drawers out of the way on a phone is the focus command's job // (stores/scene), so every focus button in the app behaves the same. - const onFileFocus = (file: FileNode) => focusPath(file.path); - const onCommitFocus = (commit: CommitEntry) => focusCommit(commit.sha); - const onStreetFocus = (dir: DirNode) => focusPath(dir.path); + const onFileFocus = (file: FileNode) => commands.focusPath(file.path); + const onCommitFocus = (commit: CommitEntry) => commands.focusCommit(commit.sha); + const onStreetFocus = (dir: DirNode) => commands.focusPath(dir.path); const onExcludeNode = (path: string) => { - addExclude(path); - clearSelection(); // the node is about to vanish from the re-fetched manifest + source.addExclude(path); + commands.clearSelection(); // the node is about to vanish from the re-fetched manifest }; const kind = activeKind.value; @@ -167,7 +157,7 @@ export function CitySidebarRight() { state={commitState} onClose={dismiss} onFocus={onCommitFocus} - onViewInTimeline={(commit) => void viewCommitInTimeline(commit.sha)} + onViewInTimeline={(commit) => void timelineMode.viewCommit(commit.sha)} /> )} {kind === SidebarPaneKind.Street && ( diff --git a/app/src/views/CityView/chrome/CityStage/CityStage.tsx b/app/src/views/CityView/chrome/CityStage/CityStage.tsx index 3a9221df2..5f162794a 100644 --- a/app/src/views/CityView/chrome/CityStage/CityStage.tsx +++ b/app/src/views/CityView/chrome/CityStage/CityStage.tsx @@ -3,14 +3,18 @@ import './CityStage.css'; import { City } from '@/city/City'; +import { useCity } from '@/city/CityProvider'; import { TimelineScrubber } from '@/components/timeline/TimelineScrubber/TimelineScrubber'; import { TimelineToggle } from '@/components/timeline/TimelineToggle/TimelineToggle'; import { SelectionChip } from '@/views/CityView/chrome/CityStage/SelectionChip/SelectionChip'; export function CityStage() { + const session = useCity(); return (
- + {/* Opaque: nothing sits behind this canvas, so a resize gap would flash + the page through it. */} +
diff --git a/app/src/views/CityView/chrome/CityStage/SelectionChip/SelectionChip.tsx b/app/src/views/CityView/chrome/CityStage/SelectionChip/SelectionChip.tsx index eaaaa7dc4..31cc067fe 100644 --- a/app/src/views/CityView/chrome/CityStage/SelectionChip/SelectionChip.tsx +++ b/app/src/views/CityView/chrome/CityStage/SelectionChip/SelectionChip.tsx @@ -6,7 +6,7 @@ import './SelectionChip.css'; import { PanelRightOpen, X } from 'lucide-preact'; import { useComputed } from '@preact/signals'; import { NodeKind } from '@/types'; -import { SCENE_HANDLE, clearSelection } from '@/city/sceneHandle'; +import { useCity } from '@/city/CityProvider'; import { SELECTION_PANE_DISMISSED, openSelectionPane } from '@/state/stores/chrome'; import { KindBadge } from '@/components/nodes/KindBadge/KindBadge'; @@ -20,8 +20,9 @@ interface ChipSelection { /** The selected node as the chip shows it, or null when nothing is selected. */ function useChipSelection() { + const { scene } = useCity(); return useComputed(() => { - const sel = SCENE_HANDLE.value?.picker.selection.value ?? null; + const sel = scene.value?.picker.selection.value ?? null; if (sel?.kind === NodeKind.File) return { label: sel.file.name, kind: NodeKind.File, extension: sel.file.extension }; if (sel?.kind === NodeKind.Directory) return { label: sel.dir.name, kind: NodeKind.Directory }; @@ -32,6 +33,7 @@ function useChipSelection() { } export function SelectionChip() { + const { commands } = useCity(); const selection = useChipSelection(); // Only in the state the pane leaves behind: something selected, details put // away. With the pane open it would name what the pane already titles. @@ -47,7 +49,7 @@ export function SelectionChip() { class="selection-chip-clear" title="Clear selection" aria-label={`Clear selection: ${label}`} - onClick={clearSelection} + onClick={commands.clearSelection} >