# BUILD-SPEC — Treebones interactive 3D map
For json's local crew, who'll build this with Claude. This is groundwork, not a build: nothing gets deployed, published, or shared until the test gate in §8 passes.

## 0. Brief (json, 2026-10-21) and hard rules
- An interactive web map of Treebones (maybe an app later). The look: a cute, Shire-like **3D tilt-shift miniature diorama** with a touch of fairyland twinkle. **Tap a lodging and the camera flies to it.**
- Web-first, mobile-first, fast. Keep the data and the scene reusable by a future app.
- Inputs: resort photos, drone footage, video, public aerial imagery, and json's Treebones material doc (**not received yet**).
- **Unit positions come from the OFFICIAL resort map** (`sources/TBR-Resort-Map.webp`), georeferenced per AERIAL.md.
- **Only real Treebones photos. No AI-generated imagery of the resort**: no generated textures of the buildings, no generated "concept" renders, no generated photos. Hand-modelled stylised geometry is fine. It's a model, not a photo.
- Don't invent units, names, amenities, or copy. Every label and blurb traces to UNITS.csv (official site) or json's doc.
- Photos ship only after John Handy says yes. Don't contact Treebones; json handles that.

## 1. Recommended stack
| Layer | Pick | Why |
|---|---|---|
| App shell | **Vite + React + TypeScript** | Fast dev, small output, easy to wrap later (Capacitor) or port to React Native |
| 3D | **three.js via @react-three/fiber + @react-three/drei** | Mature, works well on iOS WebKit, big ecosystem |
| Post FX | **@react-three/postprocessing**: a custom **tilt-shift** pass (a vertical-gradient blur band, cheaper than true DOF) + light bloom for the twinkle | Real `DepthOfField` is expensive on iPhone; a tilt-shift band reads the same at diorama angles |
| Camera | drei `CameraControls` (camera-controls lib): `setLookAt(..., true)` for smooth fly-to, clamped polar/azimuth/distance | Built-in damping and tweening, fine on touch |
| Assets | **glTF/GLB**, Draco or Meshopt geometry compression, **KTX2 (Basis)** textures, built with `gltf-transform` | Smallest download, GPU-compressed textures |
| Terrain | Mesh baked offline from the **USGS 1 m DEM** (`sources/terrain/…tif`): resample to about 2–4 m, decimate to ≤ 25k tris, then low-poly/terrace it for the stylised look. Base colour from NAIP shown as **flat, posterised vertex colours or a tiny palette texture**, never the raw photo | Real shape, toy look, small size |
| Vegetation | `InstancedMesh` low-poly cypress, pine, and scrub (2–3 species, 1 draw call each). Optional: place from LiDAR first returns or NAIP canopy | Thousands of trees cheaply |
| Twinkle | One `THREE.Points` layer (about 300–800 points) with a custom shader: additive, size attenuation, per-point phase sine flicker, more visible at dusk. Firefly drift near trees, sparkles on decks | One draw call |
| 2D fallback | Plain HTML/SVG: the official map image + absolutely-positioned buttons from `units.json mapPos` | Works everywhere, accessible, also the v0 product |
| Hosting (later) | Static (Cloudflare Pages / Netlify / Vercel). Domain TBD (QUESTIONS.md) | No server needed |

Alternatives considered: Babylon.js (fine, heavier); Unity/Godot WebGL (too heavy for a mobile-first start); Google 3D Tiles (ruled out by license, see AERIAL.md).

## 2. Performance budget (target: iPhone 12 / iOS 17+ Safari, still usable on iPhone XR)
| Metric | Budget |
|---|---|
| First meaningful paint (2D map + skeleton) | < 1.5 s on 4G |
| Interactive 3D | < 4 s on 4G |
| Total JS (gzip) | ≤ 250 KB initial; three/r3f in a lazy chunk |
| Scene payload | ≤ 3 MB at first load (terrain + core GLBs + KTX2). Unit detail GLBs and photos lazy-load on tap |
| Draw calls | ≤ 80 |
| Triangles on screen | ≤ 150k |
| Textures | ≤ 1024² each (KTX2), ≤ 24 MB GPU total |
| Frame rate | 60 fps target, **never under 30**. `PerformanceMonitor` drops DPR (cap 2 → 1.5 → 1) and disables bloom/tilt-shift if it dips |
| Memory | Stay well under the iOS tab limit (≈ 300–400 MB). Test that the tab survives 5 min of fly-tos without a reload |
| Battery | `frameloop="demand"` when idle; pause the twinkle when the tab is hidden |

## 3. Data model — `units.json` (shared by web now, app later)
A draft generated from UNITS.csv is in `data/units.draft.json` (status `draft-unverified`).
```ts
type Units = {
  schemaVersion: "0.1.0";
  resort: { id: "treebones"; name: string; address: string;
            geo: { lat: number; lon: number; source: string };
            officialMap: { url: string; width: number; height: number; origin: "top-left"; note: string };
            crs: "EPSG:26910" };
  units: Unit[];
};
type Unit = {
  id: string;                 // stable slug, e.g. "yurt-07"; never reuse
  name: string;               // display name (official site wording)
  type: "yurt"|"treehouse"|"twig_hut"|"human_nest"|"autonomous_tent"|"tiny_house"|"campsite"
       |"restaurant"|"sushi_bar"|"pool"|"hot_tub"|"restrooms"|"massage"|"amphitheater"
       |"garden"|"studio"|"viewpoint"|"parking"|"trail"|"private"|"context"|"unknown";
  officialMapLabel: string;   // exact text on the official map
  mapPos: { x: number; y: number; confidence: string; onMap: string } | null; // 0..1 on official map image
  geo: { lat: number; lon: number; source: "georef-tps"|"json-doc"|"drone"|"survey" } | null;
  scenePos: { x: number; y: number; z: number; headingDeg?: number } | null; // metres in scene (UTM - origin)
  camera?: { position: [number,number,number]; target: [number,number,number] }; // authored fly-to shot
  interactive: "yes"|"no"|"ask";
  bookingUrl: string | null;  // resort-wide WebRez today; deep links if Treebones provides
  infoUrl: string | null;
  blurb: string;              // one line, verbatim from official site
  photos: string[];           // keys into a photos manifest (rights-cleared only in production)
  accessibility?: string;     // only from official text (e.g. "suspension bridge", "ladder")
  notes: string | null;
  status: "draft-unverified"|"verified";
};
```
Rules: the scene origin is a fixed UTM point (suggest 639429 E, 3972206 N, the official lat/lon) with +Y up and metres as units. **The app reads exactly the same JSON.** Keep the 3D scene file (`scene.glb`) free of business data; meshes are linked to units only by node name = `unit.id`.

## 4. Scene and assets per unit
Everything is hand-modelled, low-poly, stylised. Use photos (PHOTO-INDEX.csv) as **modelling reference only**.
| Unit(s) | Asset | Ref photos | Notes |
|---|---|---|---|
| Yurt 1–16 | `yurt.glb` (one mesh, instanced ×16) + `deck.glb` variants; per-yurt colour (tan/green vs red per photos) | MG_5126, MG_5351, Yurts-at…, TBR-2016-yurts, Yurts-looking-over-water | Which yurt is which colour, which 2 are family yurts, and deck orientation all need confirming |
| Treehouse | `treehouse.glb` + `rope_bridge.glb` | The-Treehouse…, treehouse, MG_9618 | Elevated in pines |
| Twig Hut | `twig_hut.glb` (two-storey woven form, stylised) | The-Twig-Hut…, twighut_treebones | |
| Human Nest | `nest.glb` + ladder | Treebones_Resort_nest-scaled, nest-golden-reveal | |
| Autonomous tents (2) | `auto_tent.glb` (barrel-vault shell, translucent glow at dusk) + deck + fire pit | 35A8402, 35A8543-1, autonomous-tents-night-bluff | Positions unknown (off-map) |
| The Cove | `cove.glb` (rounded 3D-printed pod) + tub + red chairs | cove-day-06, cove-day-07-aerial, cove-daytime-exterior | Not on official map; needs location |
| Campsites 101–105 | `campsite.glb` (pad + picnic table) | Campsites-at… | Count conflict (4 vs 5) |
| The Lodge | `lodge.glb` (cloverleaf of round roofs, chimney) | TB24-59, The.Lodge_.Restaurant, NAIP | Hero landmark |
| Wild Coast Sushi | marker on Lodge until located | Sushi-Bar | |
| Pool, Spa | simple shapes, animated water shader (cheap) | NAIP | |
| Stage / amphitheater | `stage.glb` (canopy shell) + lawn bowl | 4-scaled, 3-scaled | |
| Massage studio, Red Door Studio, restrooms, garden, parking | simple stylised blocks | NAIP | |
| Owner's Home, private road | neutral unlabeled massing or omit | n/a | Never clickable |
| Paths | ribbon meshes traced from the official map + NAIP | | "Cart and footpaths only beyond this point" |
| Terrain, ocean, Hwy 1 | baked terrain, stylised ocean plane, road ribbon | DEM, NAIP | |

## 5. Art direction (to confirm with json; nothing here is final)
- **Miniature look:** high-angle camera (about 35–55° down), long-ish focal length (FOV about 25–35°) so it reads like a tabletop. Tilt-shift blur band centred on the selected unit. Slightly boosted saturation and contrast. Soft rounded/bevelled edges. Little props (chairs, fire pits) slightly oversized for charm.
- **Shire feel:** rounded hills (terraced terrain, smooth normals), puffy instanced trees, warm windows, curved paths. Stay true to the real place: Big Sur scrub, cypress and pine, red-trim yurts, the ocean at the edge.
- **Palette.** Raw swatches median-cut sampled from real Treebones photos (2026-10-22):
  canvas cream `#F5F0E9` and redwood deck `#805A48` / `#5B3625` (MG_5351); dry-grass olive `#7F7444` and scrub `#52521F` (4-scaled, amphitheater);
  sea/sky haze `#96AAB4`, `#BBC7D1` (Yurts-looking-over-water); dusk ocean blue `#163F71` and twilight violet `#555B7A` (autonomous-tents-night-bluff).
  These are muddy, as photo averages are. The crew should propose a brighter **stylised** version of each (same hue family, more saturation and value) and have json approve it. The yurt-trim red and window-glow amber need picking by hand from MG_5126 and An-Autonomous-Tent…
- **Lighting:** one warm directional "sun" + hemisphere fill, baked AO on the terrain. Two presets: **Golden hour** (default, matches most of the photos) and **Dusk** (twinkle on, windows and autonomous tents glowing). Optional fog band over the ocean.
- **Twinkle:** subtle. Fireflies near trees, a few sparkles on decks and in the Twig Hut/Nest. Never covers labels. Off under `prefers-reduced-motion`.
- **UI:** tap a unit → camera flies over (about 1.2 s ease-in-out), a bottom sheet with name, official blurb, photos (once cleared), and an official "Book" link that opens WebRez/Tock in a new tab. List view of all units as an alternative way in.

## 6. Accessibility and fallback
- **2D fallback = v0 product:** the official map image + accessible buttons at `mapPos`. Served when WebGL2 is missing, on a device-memory/GPU heuristic, under `prefers-reduced-motion`, or when the user taps "Simple map". Remember the choice.
- Every 3D hotspot has a matching DOM button (an off-screen list synced to the scene) for keyboard and screen readers. Visible focus ring; Enter = fly to; Esc = back to overview.
- Text contrast WCAG AA; tap targets ≥ 44 px; labels scale with Dynamic Type/zoom.
- Don't use colour alone (yurts vs campsites also get icon shapes).
- Accessibility notes only from official text (Treehouse: suspension bridge and stairs; Nest: ladder; autonomous tents: "path is hilly"). Treebones also has an /accessibility/ page; link it.
- Offline-friendly: there's **no cell service at Treebones** (per site). Add a service worker (later) so guests who loaded the map beforehand still have the 2D map on property.

## 7. Phase plan
| Phase | Deliverable | Exit criteria |
|---|---|---|
| **v0 — 2D clickable official map** | Official map + hotspots from `units.json`, bottom sheet, booking links, list view | All units json approves; positions checked against the traced overlay; passes §8 |
| **v1 — 3D diorama** | Georeferenced terrain, stylised units, fly-to, tilt-shift, twinkle, 2D fallback kept | Meets §2 budget on a real iPhone; json signs off on the look; photos cleared |
| **v2 — app** | Capacitor wrapper of the same web build (fastest) or React Native + expo-gl/r3f-native reusing `units.json` and GLBs | Same data, same assets; offline pack |

Before v1, georeference (AERIAL.md), get json's doc, and fill in the off-map units.

## 8. Test-first gate (no link gets shared until this passes)
1. **iPhone WebKit**: real iPhone Safari (iOS 17 and current), plus Playwright `webkit` with an iPhone 13 device profile in CI. Check load, tap-to-fly, the bottom sheet, the 2D fallback toggle, rotation, the low-power-mode frame rate, and a 5-minute memory soak.
2. **Desktop**: Chrome, Safari, Firefox: mouse, keyboard-only, screen reader (VoiceOver) pass.
3. Automated: Playwright smoke test per unit (tap → sheet shows the correct official name), Lighthouse mobile ≥ 90 performance on the 2D build, bundle-size check in CI.
4. Content check: every label and blurb diffed against UNITS.csv / json's doc. No AI imagery. Photo rights list signed off.
5. Only then does json share a preview link (password-protected or unlisted).
