diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index bf4161f..b9b3e3d 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -22,6 +22,8 @@
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@react-three/drei": "9.117.0",
+ "@react-three/fiber": "8.17.10",
"@tiptap/core": "^2.11.5",
"@tiptap/extension-bold": "^2.11.5",
"@tiptap/extension-bullet-list": "^2.11.5",
@@ -53,9 +55,12 @@
"node-pty": "^1.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-reconciler": "0.29.2",
"react-router-dom": "^7.1.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
+ "@takram/three-clouds": "^0.7.1",
+ "three": "0.160.0",
"tippy.js": "^6.3.7",
"turndown": "^7.2.1",
"zod": "^3.24.4"
@@ -69,6 +74,7 @@
"@types/node": "^22.10.5",
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.5",
+ "@types/three": "0.160.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"electron": "37",
diff --git a/apps/desktop/src/renderer/public/hub-assets/arch.glb b/apps/desktop/src/renderer/public/hub-assets/arch.glb
new file mode 100644
index 0000000..63b8df1
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/arch.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/barn.glb b/apps/desktop/src/renderer/public/hub-assets/barn.glb
new file mode 100644
index 0000000..068acf6
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/barn.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/barrell.glb b/apps/desktop/src/renderer/public/hub-assets/barrell.glb
new file mode 100644
index 0000000..abbbe39
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/barrell.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/bench.glb b/apps/desktop/src/renderer/public/hub-assets/bench.glb
new file mode 100644
index 0000000..3fc3edb
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/bench.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/boulder.glb b/apps/desktop/src/renderer/public/hub-assets/boulder.glb
new file mode 100644
index 0000000..19df164
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/boulder.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/chapel.glb b/apps/desktop/src/renderer/public/hub-assets/chapel.glb
new file mode 100644
index 0000000..881ce41
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/chapel.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/computer.glb b/apps/desktop/src/renderer/public/hub-assets/computer.glb
new file mode 100644
index 0000000..4f2b8a8
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/computer.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/computer_new.glb b/apps/desktop/src/renderer/public/hub-assets/computer_new.glb
new file mode 100644
index 0000000..48c266e
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/computer_new.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/crate.glb b/apps/desktop/src/renderer/public/hub-assets/crate.glb
new file mode 100644
index 0000000..9e15a79
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/crate.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/daisy.glb b/apps/desktop/src/renderer/public/hub-assets/daisy.glb
new file mode 100644
index 0000000..bcff5e6
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/daisy.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/empty_room.glb b/apps/desktop/src/renderer/public/hub-assets/empty_room.glb
new file mode 100644
index 0000000..48117c3
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/empty_room.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/farm_cabbage.glb b/apps/desktop/src/renderer/public/hub-assets/farm_cabbage.glb
new file mode 100644
index 0000000..3425f87
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/farm_cabbage.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/farm_carrot.glb b/apps/desktop/src/renderer/public/hub-assets/farm_carrot.glb
new file mode 100644
index 0000000..aac8c11
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/farm_carrot.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/farm_wheat.glb b/apps/desktop/src/renderer/public/hub-assets/farm_wheat.glb
new file mode 100644
index 0000000..ed58367
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/farm_wheat.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/fence.glb b/apps/desktop/src/renderer/public/hub-assets/fence.glb
new file mode 100644
index 0000000..c852868
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/fence.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/fountain.glb b/apps/desktop/src/renderer/public/hub-assets/fountain.glb
new file mode 100644
index 0000000..2d44c6c
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/fountain.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/garland.glb b/apps/desktop/src/renderer/public/hub-assets/garland.glb
new file mode 100644
index 0000000..133308d
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/garland.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/hay_bale.glb b/apps/desktop/src/renderer/public/hub-assets/hay_bale.glb
new file mode 100644
index 0000000..da1dc94
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/hay_bale.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/hay_cart.glb b/apps/desktop/src/renderer/public/hub-assets/hay_cart.glb
new file mode 100644
index 0000000..7bbec4a
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/hay_cart.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/hay_pile.glb b/apps/desktop/src/renderer/public/hub-assets/hay_pile.glb
new file mode 100644
index 0000000..7d6b42d
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/hay_pile.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/house.glb b/apps/desktop/src/renderer/public/hub-assets/house.glb
new file mode 100644
index 0000000..bb5265c
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/house.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/house_2story_purple.glb b/apps/desktop/src/renderer/public/hub-assets/house_2story_purple.glb
new file mode 100644
index 0000000..2c173b4
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/house_2story_purple.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/house_blue.glb b/apps/desktop/src/renderer/public/hub-assets/house_blue.glb
new file mode 100644
index 0000000..43da1b8
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/house_blue.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/house_purple.glb b/apps/desktop/src/renderer/public/hub-assets/house_purple.glb
new file mode 100644
index 0000000..d10bc0c
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/house_purple.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/house_red.glb b/apps/desktop/src/renderer/public/hub-assets/house_red.glb
new file mode 100644
index 0000000..012c74a
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/house_red.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/market_stall_blue.glb b/apps/desktop/src/renderer/public/hub-assets/market_stall_blue.glb
new file mode 100644
index 0000000..420bfba
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/market_stall_blue.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/market_stall_red.glb b/apps/desktop/src/renderer/public/hub-assets/market_stall_red.glb
new file mode 100644
index 0000000..f963d5c
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/market_stall_red.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/obelisk.glb b/apps/desktop/src/renderer/public/hub-assets/obelisk.glb
new file mode 100644
index 0000000..17abd4d
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/obelisk.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/picnic_table.glb b/apps/desktop/src/renderer/public/hub-assets/picnic_table.glb
new file mode 100644
index 0000000..c39267c
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/picnic_table.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/pillar.glb b/apps/desktop/src/renderer/public/hub-assets/pillar.glb
new file mode 100644
index 0000000..38977c6
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/pillar.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/pond.glb b/apps/desktop/src/renderer/public/hub-assets/pond.glb
new file mode 100644
index 0000000..df895e2
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/pond.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/potted_bush.glb b/apps/desktop/src/renderer/public/hub-assets/potted_bush.glb
new file mode 100644
index 0000000..e4c3d5e
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/potted_bush.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/shroom.glb b/apps/desktop/src/renderer/public/hub-assets/shroom.glb
new file mode 100644
index 0000000..ac0ae38
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/shroom.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/stonepath.glb b/apps/desktop/src/renderer/public/hub-assets/stonepath.glb
new file mode 100644
index 0000000..fd6704b
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/stonepath.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/streetlight.glb b/apps/desktop/src/renderer/public/hub-assets/streetlight.glb
new file mode 100644
index 0000000..dbece11
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/streetlight.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/suburban_garden_1k.hdr b/apps/desktop/src/renderer/public/hub-assets/suburban_garden_1k.hdr
new file mode 100644
index 0000000..0e3fcf0
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/suburban_garden_1k.hdr differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/texture.png b/apps/desktop/src/renderer/public/hub-assets/texture.png
new file mode 100644
index 0000000..1f23422
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/texture.png differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/tree_pine.glb b/apps/desktop/src/renderer/public/hub-assets/tree_pine.glb
new file mode 100644
index 0000000..5feb257
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/tree_pine.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/tree_square.glb b/apps/desktop/src/renderer/public/hub-assets/tree_square.glb
new file mode 100644
index 0000000..3ab4e7f
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/tree_square.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/treetall.glb b/apps/desktop/src/renderer/public/hub-assets/treetall.glb
new file mode 100644
index 0000000..5d2a281
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/treetall.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/well.glb b/apps/desktop/src/renderer/public/hub-assets/well.glb
new file mode 100644
index 0000000..6b31e69
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/well.glb differ
diff --git a/apps/desktop/src/renderer/public/hub-assets/windmill.glb b/apps/desktop/src/renderer/public/hub-assets/windmill.glb
new file mode 100644
index 0000000..4c48a81
Binary files /dev/null and b/apps/desktop/src/renderer/public/hub-assets/windmill.glb differ
diff --git a/apps/desktop/src/renderer/src/components/browser/browser-tab-dialog.tsx b/apps/desktop/src/renderer/src/components/browser/browser-tab-dialog.tsx
index cbb1908..ad9439c 100644
--- a/apps/desktop/src/renderer/src/components/browser/browser-tab-dialog.tsx
+++ b/apps/desktop/src/renderer/src/components/browser/browser-tab-dialog.tsx
@@ -713,7 +713,7 @@ export function BrowserTabDialog({
event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
diff --git a/apps/desktop/src/renderer/src/components/browser/terminal-dialog.tsx b/apps/desktop/src/renderer/src/components/browser/terminal-dialog.tsx
index e4fdf80..0152716 100644
--- a/apps/desktop/src/renderer/src/components/browser/terminal-dialog.tsx
+++ b/apps/desktop/src/renderer/src/components/browser/terminal-dialog.tsx
@@ -23,6 +23,7 @@ interface TerminalDialogProps {
onUpdateConfig: (config: TerminalNodeConfig) => void
workspaceRootDir?: string
boardRootDir?: string | null
+ portalContainer?: HTMLElement | null
}
export function TerminalDialog({
@@ -34,7 +35,8 @@ export function TerminalDialog({
onUpdateConfig,
sessionId,
workspaceRootDir,
- boardRootDir
+ boardRootDir,
+ portalContainer
}: TerminalDialogProps): React.ReactElement {
const termRef = useRef
(null)
const fitRef = useRef(null)
@@ -443,6 +445,7 @@ export function TerminalDialog({
return (
e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
diff --git a/apps/desktop/src/renderer/src/components/hub/fluffy-grass.tsx b/apps/desktop/src/renderer/src/components/hub/fluffy-grass.tsx
new file mode 100644
index 0000000..94bc3b2
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/fluffy-grass.tsx
@@ -0,0 +1,358 @@
+import { useRef, useMemo, useEffect } from 'react'
+import { useFrame } from '@react-three/fiber'
+import * as THREE from 'three'
+import { useVillage } from './village-context'
+import { seededRandom } from './hub-assets'
+import type { RoadSegment } from './use-village-layout'
+import type { VillageNeighborhood } from './village-types'
+import { useHubWorldLighting } from '@/hooks/use-hub-world-lighting'
+
+/* ------------------------------------------------------------------ */
+/* Procedural textures */
+/* ------------------------------------------------------------------ */
+
+function createNoiseTexture(size = 256): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas')
+ canvas.width = size
+ canvas.height = size
+ const ctx = canvas.getContext('2d')!
+ const imageData = ctx.createImageData(size, size)
+ const rng = seededRandom(42)
+
+ const gridSize = 16
+ const grid: number[][] = []
+ for (let y = 0; y <= gridSize; y++) {
+ grid[y] = []
+ for (let x = 0; x <= gridSize; x++) {
+ grid[y][x] = rng()
+ }
+ }
+
+ for (let py = 0; py < size; py++) {
+ for (let px = 0; px < size; px++) {
+ const gx = (px / size) * gridSize
+ const gy = (py / size) * gridSize
+ const ix = Math.floor(gx)
+ const iy = Math.floor(gy)
+ const fx = gx - ix
+ const fy = gy - iy
+ const sx = fx * fx * (3 - 2 * fx)
+ const sy = fy * fy * (3 - 2 * fy)
+
+ const nx = (ix + 1) % (gridSize + 1)
+ const ny = (iy + 1) % (gridSize + 1)
+ const a = grid[iy][ix]
+ const b = grid[iy][nx]
+ const c = grid[ny][ix]
+ const d = grid[ny][nx]
+
+ const v = Math.floor((a + (b - a) * sx + (c - a) * sy + (a - b - c + d) * sx * sy) * 255)
+ const idx = (py * size + px) * 4
+ imageData.data[idx] = v
+ imageData.data[idx + 1] = v
+ imageData.data[idx + 2] = v
+ imageData.data[idx + 3] = 255
+ }
+ }
+
+ ctx.putImageData(imageData, 0, 0)
+ const tex = new THREE.CanvasTexture(canvas)
+ tex.wrapS = THREE.RepeatWrapping
+ tex.wrapT = THREE.RepeatWrapping
+ return tex
+}
+
+function createGrassAlphaTexture(size = 128): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas')
+ canvas.width = size
+ canvas.height = size
+ const ctx = canvas.getContext('2d')!
+ const rng = seededRandom(99)
+
+ ctx.fillStyle = '#000'
+ ctx.fillRect(0, 0, size, size)
+
+ ctx.fillStyle = '#fff'
+ const bladeCount = 10
+ for (let i = 0; i < bladeCount; i++) {
+ const baseX = ((i + 0.5) / bladeCount) * size
+ const baseWidth = 3 + rng() * 5
+ const height = size * (0.5 + rng() * 0.4)
+ const lean = (rng() - 0.5) * 12
+
+ ctx.beginPath()
+ ctx.moveTo(baseX - baseWidth / 2, size)
+ ctx.lineTo(baseX + lean, size - height)
+ ctx.lineTo(baseX + baseWidth / 2, size)
+ ctx.closePath()
+ ctx.fill()
+ }
+
+ return new THREE.CanvasTexture(canvas)
+}
+
+/* ------------------------------------------------------------------ */
+/* Grass blade geometry — 3 intersecting quads (star pattern) */
+/* ------------------------------------------------------------------ */
+
+function createGrassGeometry(): THREE.BufferGeometry {
+ const h = 1.0
+ const w = 0.6
+ const positions: number[] = []
+ const uvs: number[] = []
+ const indices: number[] = []
+
+ for (let i = 0; i < 3; i++) {
+ const angle = (i * Math.PI) / 3
+ const cos = Math.cos(angle)
+ const sin = Math.sin(angle)
+ const hw = w / 2
+ const base = i * 4
+
+ positions.push(-hw * cos, 0, -hw * sin)
+ uvs.push(0, 0)
+ positions.push(hw * cos, 0, hw * sin)
+ uvs.push(1, 0)
+ positions.push(hw * cos, h, hw * sin)
+ uvs.push(1, 1)
+ positions.push(-hw * cos, h, -hw * sin)
+ uvs.push(0, 1)
+
+ indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
+ }
+
+ const geo = new THREE.BufferGeometry()
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
+ geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
+ geo.setIndex(indices)
+ geo.computeVertexNormals()
+ return geo
+}
+
+/* ------------------------------------------------------------------ */
+/* Placement helpers — avoid roads, cul-de-sacs, and houses */
+/* ------------------------------------------------------------------ */
+
+function isOnRoad(x: number, z: number, roads: RoadSegment[], margin = 1.5): boolean {
+ for (const seg of roads) {
+ const dx = x - seg.cx
+ const dz = z - seg.cz
+ const cos = Math.cos(-seg.angle)
+ const sin = Math.sin(-seg.angle)
+ const lx = dx * cos - dz * sin
+ const lz = dx * sin + dz * cos
+ if (Math.abs(lx) < seg.width / 2 + margin && Math.abs(lz) < seg.length / 2 + margin) return true
+ }
+ return false
+}
+
+function isNearStructure(x: number, z: number, neighborhoods: VillageNeighborhood[]): boolean {
+ for (const n of neighborhoods) {
+ if (Math.hypot(x - n.position[0], z - n.position[2]) < 10) return true
+ for (const h of n.houses) {
+ if (Math.hypot(x - h.worldPosition[0], z - h.worldPosition[2]) < 5) return true
+ }
+ }
+ return false
+}
+
+/* ------------------------------------------------------------------ */
+/* Shaders */
+/* ------------------------------------------------------------------ */
+
+const grassVertexShader = /* glsl */ `
+ uniform float uTime;
+ uniform sampler2D uNoiseTexture;
+ uniform float uNoiseScale;
+
+ varying vec2 vUv;
+ varying vec2 vGlobalUV;
+ varying vec3 vNormal;
+
+ #include
+
+ void main() {
+ vUv = uv;
+ vNormal = normalize(normalMatrix * normal);
+
+ vec4 modelPosition = modelMatrix * instanceMatrix * vec4(position, 1.0);
+
+ float terrainSize = 200.0;
+ vGlobalUV = vec2(modelPosition.xz) / terrainSize + 0.5;
+
+ // Wind
+ vec2 windDir = normalize(vec2(1.0, 0.7));
+ float windStrength = uv.y;
+ vec4 noise = texture2D(uNoiseTexture, vGlobalUV + uTime * 0.002);
+ float wave = sin(40.0 * dot(windDir, vGlobalUV) + noise.r * 5.0 + uTime * 1.5) * 0.15 * windStrength;
+
+ modelPosition.x += wave;
+ modelPosition.z += wave * 0.7;
+
+ // Height variation from noise
+ float heightNoise = texture2D(uNoiseTexture, vGlobalUV * uNoiseScale).r;
+ modelPosition.y += heightNoise * 0.25 * uv.y;
+
+ vec4 mvPosition = viewMatrix * modelPosition;
+ gl_Position = projectionMatrix * mvPosition;
+
+ #include
+ }
+`
+
+const grassFragmentShader = /* glsl */ `
+ uniform vec3 uBaseColor;
+ uniform vec3 uTipColor1;
+ uniform vec3 uTipColor2;
+ uniform vec3 uLightDir;
+ uniform sampler2D uGrassAlphaTexture;
+ uniform sampler2D uNoiseTexture;
+ uniform float uNoiseScale;
+ uniform float uDaylight;
+ uniform float uNightFactor;
+
+ varying vec2 vUv;
+ varying vec2 vGlobalUV;
+ varying vec3 vNormal;
+
+ #include
+
+ void main() {
+ float alpha = texture2D(uGrassAlphaTexture, vUv).r;
+ if (alpha < 0.1) discard;
+
+ float variation = texture2D(uNoiseTexture, vGlobalUV * uNoiseScale).r;
+ vec3 tipColor = mix(uTipColor1, uTipColor2, variation);
+ vec3 color = mix(uBaseColor, tipColor, vUv.y);
+
+ // Shift grass toward a cooler, desaturated moonlit palette at night.
+ vec3 nightColor = color * vec3(0.34, 0.42, 0.58);
+ color = mix(nightColor, color, uDaylight);
+
+ // Approximate the current scene lighting instead of a fixed daytime sun.
+ vec3 lightDir = normalize(uLightDir);
+ float NdotL = max(dot(vNormal, lightDir), 0.0);
+ // Blend front and back face lighting for double-sided grass
+ float diffuse = max(NdotL, max(dot(-vNormal, lightDir), 0.0) * 0.6);
+ float lighting = mix(0.34, 0.55, uDaylight) + diffuse * mix(0.16, 0.55, uDaylight);
+ lighting += vUv.y * uNightFactor * 0.06;
+
+ gl_FragColor = vec4(color * lighting, 1.0);
+
+ #include
+ #include
+ #include
+ }
+`
+
+/* ------------------------------------------------------------------ */
+/* Component */
+/* ------------------------------------------------------------------ */
+
+const GRASS_COUNT = 20000
+
+export function FluffyGrass() {
+ const { roads, neighborhoods } = useVillage()
+ const meshRef = useRef(null)
+ const lighting = useHubWorldLighting()
+
+ const noiseTexture = useMemo(() => createNoiseTexture(), [])
+ const grassAlphaTexture = useMemo(() => createGrassAlphaTexture(), [])
+ const grassGeometry = useMemo(() => createGrassGeometry(), [])
+
+ const uniforms = useMemo(
+ () => ({
+ ...THREE.UniformsLib.fog,
+ uTime: { value: 0 },
+ uNoiseTexture: { value: noiseTexture },
+ uGrassAlphaTexture: { value: grassAlphaTexture },
+ uBaseColor: { value: new THREE.Color('#3d6b45') },
+ uTipColor1: { value: new THREE.Color('#6aad5e') },
+ uTipColor2: { value: new THREE.Color('#4a7c59') },
+ uLightDir: { value: new THREE.Vector3(0.65, 0.7, 0.3).normalize() },
+ uNoiseScale: { value: 1.5 },
+ uDaylight: { value: 1 },
+ uNightFactor: { value: 0 },
+ }),
+ [noiseTexture, grassAlphaTexture]
+ )
+
+ const material = useMemo(
+ () =>
+ new THREE.ShaderMaterial({
+ uniforms,
+ vertexShader: grassVertexShader,
+ fragmentShader: grassFragmentShader,
+ side: THREE.DoubleSide,
+ transparent: true,
+ fog: true,
+ }),
+ [uniforms]
+ )
+
+ // Scatter grass instances avoiding roads and structures
+ useEffect(() => {
+ const mesh = meshRef.current
+ if (!mesh) return
+
+ const rng = seededRandom(54321)
+ const matrix = new THREE.Matrix4()
+ const pos = new THREE.Vector3()
+ const quat = new THREE.Quaternion()
+ const scl = new THREE.Vector3()
+ const euler = new THREE.Euler()
+
+ // Determine village bounds — keep grass close, don't extend too far out
+ let minX = -20, maxX = 20, minZ = -20, maxZ = 20
+ for (const n of neighborhoods) {
+ for (const h of n.houses) {
+ minX = Math.min(minX, h.worldPosition[0] - 10)
+ maxX = Math.max(maxX, h.worldPosition[0] + 10)
+ minZ = Math.min(minZ, h.worldPosition[2] - 10)
+ maxZ = Math.max(maxZ, h.worldPosition[2] + 10)
+ }
+ }
+
+ let placed = 0
+ let attempts = 0
+ while (placed < GRASS_COUNT && attempts < GRASS_COUNT * 5) {
+ attempts++
+ const x = minX + rng() * (maxX - minX)
+ const z = minZ + rng() * (maxZ - minZ)
+
+ if (isOnRoad(x, z, roads)) continue
+ if (isNearStructure(x, z, neighborhoods)) continue
+
+ pos.set(x, 0, z)
+ euler.set(0, rng() * Math.PI * 2, 0)
+ quat.setFromEuler(euler)
+ const s = 0.5 + rng() * 0.43
+ scl.set(s, s + rng() * 0.34, s)
+ matrix.compose(pos, quat, scl)
+ mesh.setMatrixAt(placed, matrix)
+ placed++
+ }
+
+ mesh.count = placed
+ mesh.instanceMatrix.needsUpdate = true
+ }, [roads, neighborhoods])
+
+ // Animate wind
+ useFrame((_, delta) => {
+ uniforms.uTime.value += delta
+ })
+
+ useEffect(() => {
+ uniforms.uLightDir.value.set(...lighting.directionalPosition).normalize()
+ uniforms.uDaylight.value = lighting.daylightFactor
+ uniforms.uNightFactor.value = lighting.nightFactor
+ }, [lighting.daylightFactor, lighting.directionalPosition, lighting.nightFactor, uniforms])
+
+ return (
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/fps-camera-controller.tsx b/apps/desktop/src/renderer/src/components/hub/fps-camera-controller.tsx
new file mode 100644
index 0000000..6e919ea
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/fps-camera-controller.tsx
@@ -0,0 +1,489 @@
+import { useRef, useEffect } from 'react'
+import { useFrame, useThree } from '@react-three/fiber'
+import * as THREE from 'three'
+import { useVillage } from './village-context'
+import { TOWN_CAR_ID } from './village-types'
+
+const WALK_SPEED = 6
+const RUN_SPEED = 14
+const LOOK_SPEED = 0.002
+const PLAYER_HEIGHT = 1.7
+const INTERACTION_DISTANCE = 3.5
+const CAR_INTERACTION_DISTANCE = 4.5
+const GRAB_DISTANCE = 6
+const AUTO_ENTER_DISTANCE = 1.5
+const DOOR_OFFSET_Z = 5
+const GRAB_CARRY_DISTANCE = 6
+const CAR_ACCELERATION = 16
+const CAR_REVERSE_ACCELERATION = 11
+const CAR_DRAG = 4.5
+const CAR_BRAKE_POWER = 18
+const CAR_MAX_SPEED = 24
+const CAR_MAX_REVERSE_SPEED = 9
+const CAR_STEER_SPEED = 1.9
+const CAR_CAMERA_DISTANCE = 7.5
+const CAR_CAMERA_HEIGHT = 3.6
+const CAR_CAMERA_LOOK_AHEAD = 4.5
+const CAR_EXIT_OFFSET = 2.8
+const CAMERA_FOLLOW_STIFFNESS = 8
+
+type Interactable = {
+ id: string
+ pos: THREE.Vector3
+ action: 'enter-house' | 'exit-house' | 'use-board' | 'enter-car'
+ boardId: string
+}
+
+function tryRequestPointerLock(el: HTMLElement) {
+ try { el.requestPointerLock() } catch { /* ignore in Electron */ }
+}
+
+export function FPSCameraController() {
+ const { camera, gl } = useThree()
+ const {
+ neighborhoods, scenery, location, enterHouse, exitHouse, interact,
+ setHoveredId, savedCameraPos, saveCameraPos,
+ grabbedObjectId, objectPositions, grabObject, placeObject, updateGrabbedPosition,
+ persistedPlayerPos, persistPlayerPos,
+ carStateRef, isDriving, enterCar, exitCar, persistCarTransform
+ } = useVillage()
+ const keysRef = useRef(new Set())
+ const yawRef = useRef(0)
+ const pitchRef = useRef(0)
+ const isLockedRef = useRef(false)
+ const velocityYRef = useRef(0)
+ const isGroundedRef = useRef(true)
+ const autoEnteredRef = useRef(null)
+ const initializedRef = useRef(false)
+ const persistFrameCounter = useRef(0)
+
+ const interactablesRef = useRef([])
+ const doorPositionsRef = useRef<{ boardId: string; pos: THREE.Vector3 }[]>([])
+ const carInteractableRef = useRef(null)
+
+ // Keep refs for values accessed by event handlers to avoid re-registering effects
+ const grabbedObjectIdRef = useRef(grabbedObjectId)
+ grabbedObjectIdRef.current = grabbedObjectId
+ const objectPositionsRef = useRef(objectPositions)
+ objectPositionsRef.current = objectPositions
+ const sceneryRef = useRef(scenery)
+ sceneryRef.current = scenery
+ const locationRef = useRef(location)
+ locationRef.current = location
+ const grabObjectRef = useRef(grabObject)
+ grabObjectRef.current = grabObject
+ const placeObjectRef = useRef(placeObject)
+ placeObjectRef.current = placeObject
+
+ useEffect(() => {
+ if (location.type === 'outdoor') {
+ const doors: Interactable[] = []
+ const doorPositions: { boardId: string; pos: THREE.Vector3 }[] = []
+ for (const n of neighborhoods) {
+ for (const h of n.houses) {
+ const cos = Math.cos(h.worldRotation)
+ const sin = Math.sin(h.worldRotation)
+ const dx = DOOR_OFFSET_Z * sin
+ const dz = DOOR_OFFSET_Z * cos
+ const doorPos = new THREE.Vector3(h.worldPosition[0] + dx, 0, h.worldPosition[2] + dz)
+ doors.push({ id: `door-${h.id}`, pos: doorPos, action: 'enter-house', boardId: h.id })
+ doorPositions.push({ boardId: h.id, pos: doorPos })
+ }
+ }
+ const carPos = carStateRef.current.position
+ const carInteractable: Interactable = {
+ id: TOWN_CAR_ID,
+ pos: new THREE.Vector3(carPos[0], 0, carPos[2]),
+ action: 'enter-car',
+ boardId: ''
+ }
+ doors.push(carInteractable)
+ interactablesRef.current = doors
+ doorPositionsRef.current = doorPositions
+ carInteractableRef.current = carInteractable
+ autoEnteredRef.current = null
+ } else {
+ const items: Interactable[] = [
+ { id: 'exit-door', pos: new THREE.Vector3(0, 0, 12), action: 'exit-house', boardId: '' },
+ { id: 'interior-agent-0', pos: new THREE.Vector3(2, 0, 2), action: 'use-board', boardId: location.boardId },
+ ]
+ window.api.browserTabs.list(location.boardId).then((tabs: { id: string }[]) => {
+ window.api.graphNodes.list(location.boardId).then((nodes: { id: string; nodeType: string }[]) => {
+ const allIds = [
+ ...tabs.map((t: { id: string }) => t.id),
+ ...nodes.filter((n: { nodeType: string }) => n.nodeType === 'terminal').map((n: { id: string }) => n.id)
+ ]
+ let seed = 0
+ for (let ci = 0; ci < location.boardId.length; ci++) seed = ((seed << 5) - seed + location.boardId.charCodeAt(ci)) | 0
+ seed = Math.abs(seed)
+ const rng = () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646 }
+ const placed: [number, number][] = []
+ const hs = 8, margin = 2.5
+ allIds.forEach((itemId) => {
+ for (let attempt = 0; attempt < 20; attempt++) {
+ const x = (rng() - 0.5) * (16 - margin * 2 - 2)
+ const z = -hs + margin + rng() * (16 - margin * 2 - 4)
+ const tooClose = placed.some(([px, pz]) => Math.hypot(x - px, z - pz) < 2.8)
+ if (!tooClose) {
+ placed.push([x, z])
+ items.push({ id: `desk-${itemId}`, pos: new THREE.Vector3(x, 0, z), action: 'use-board', boardId: location.boardId })
+ break
+ }
+ }
+ rng()
+ })
+ interactablesRef.current = items
+ }).catch(() => {})
+ }).catch(() => {})
+ interactablesRef.current = items
+ doorPositionsRef.current = []
+ carInteractableRef.current = null
+ }
+ }, [carStateRef, location, neighborhoods])
+
+ useEffect(() => {
+ // Position setup — never return early, event listeners must always be registered below
+ let positionSet = false
+
+ // Restore persisted player position on first mount
+ if (!initializedRef.current && persistedPlayerPos) {
+ if (location.type === 'outdoor' && persistedPlayerPos.locationType === 'outdoor') {
+ camera.position.set(persistedPlayerPos.x, persistedPlayerPos.y, persistedPlayerPos.z)
+ yawRef.current = persistedPlayerPos.yaw
+ pitchRef.current = persistedPlayerPos.pitch
+ positionSet = true
+ } else if (location.type === 'indoor' && persistedPlayerPos.locationType === 'indoor') {
+ camera.position.set(persistedPlayerPos.x, persistedPlayerPos.y, persistedPlayerPos.z)
+ yawRef.current = persistedPlayerPos.yaw
+ pitchRef.current = persistedPlayerPos.pitch
+ positionSet = true
+ }
+ }
+
+ if (!positionSet) {
+ if (location.type === 'outdoor') {
+ if (savedCameraPos) {
+ camera.position.set(savedCameraPos.x, PLAYER_HEIGHT, savedCameraPos.z)
+ yawRef.current = savedCameraPos.yaw
+ } else if (!initializedRef.current) {
+ camera.position.set(0, PLAYER_HEIGHT, 20)
+ yawRef.current = 0
+ }
+ pitchRef.current = 0
+ autoEnteredRef.current = null
+ } else {
+ let doorX = camera.position.x, doorZ = camera.position.z, doorYaw = yawRef.current
+ for (const n of neighborhoods) {
+ for (const h of n.houses) {
+ if (h.id === location.boardId) {
+ const cos = Math.cos(h.worldRotation)
+ const sin = Math.sin(h.worldRotation)
+ const standoff = DOOR_OFFSET_Z + 2
+ doorX = h.worldPosition[0] + sin * standoff
+ doorZ = h.worldPosition[2] + cos * standoff
+ doorYaw = h.worldRotation + Math.PI
+ }
+ }
+ }
+ saveCameraPos({ x: doorX, y: PLAYER_HEIGHT, z: doorZ, yaw: doorYaw })
+ camera.position.set(0, PLAYER_HEIGHT, 9)
+ yawRef.current = 0
+ pitchRef.current = 0
+ }
+ }
+
+ initializedRef.current = true
+ setTimeout(() => tryRequestPointerLock(gl.domElement), 50)
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ keysRef.current.add(e.code)
+ if (e.code === 'Space' && !isDriving && isGroundedRef.current) {
+ velocityYRef.current = 6
+ isGroundedRef.current = false
+ }
+ if (e.code === 'KeyE') {
+ if (isDriving) {
+ const car = carStateRef.current
+ const right = new THREE.Vector3(Math.cos(car.rotation), 0, -Math.sin(car.rotation))
+ const backward = new THREE.Vector3(Math.sin(car.rotation), 0, Math.cos(car.rotation))
+ camera.position.set(
+ car.position[0] + right.x * CAR_EXIT_OFFSET + backward.x * 0.8,
+ PLAYER_HEIGHT,
+ car.position[2] + right.z * CAR_EXIT_OFFSET + backward.z * 0.8
+ )
+ yawRef.current = car.rotation
+ pitchRef.current = 0
+ velocityYRef.current = 0
+ isGroundedRef.current = true
+ exitCar()
+ setTimeout(() => tryRequestPointerLock(gl.domElement), 50)
+ return
+ }
+
+ const cam2D = new THREE.Vector2(camera.position.x, camera.position.z)
+ const pos = objectPositionsRef.current
+ let nearest: Interactable | null = null
+ let nearestDist = CAR_INTERACTION_DISTANCE
+ for (const ia of interactablesRef.current) {
+ const ov = pos[ia.id]
+ const px = ov ? ov.position[0] : ia.pos.x
+ const pz = ov ? ov.position[2] : ia.pos.z
+ const d = cam2D.distanceTo(new THREE.Vector2(px, pz))
+ const maxDist = ia.action === 'enter-car' ? CAR_INTERACTION_DISTANCE : INTERACTION_DISTANCE
+ if (d < nearestDist && d < maxDist) { nearestDist = d; nearest = ia }
+ }
+ if (nearest) {
+ if (nearest.action === 'enter-car') {
+ if (grabbedObjectIdRef.current) {
+ const forward = new THREE.Vector3(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current))
+ placeObjectRef.current([
+ camera.position.x + forward.x * GRAB_CARRY_DISTANCE,
+ 0,
+ camera.position.z + forward.z * GRAB_CARRY_DISTANCE
+ ], yawRef.current)
+ }
+ enterCar()
+ try { if (isLockedRef.current) document.exitPointerLock() } catch { /* ignore */ }
+ return
+ }
+ if (nearest.action === 'enter-house') enterHouse(nearest.boardId)
+ else if (nearest.action === 'exit-house') exitHouse()
+ else if (nearest.action === 'use-board') interact(nearest.id, 'board', nearest.boardId)
+ }
+ try { if (isLockedRef.current) document.exitPointerLock() } catch { /* ignore */ }
+ }
+ // G key: grab/place objects
+ if (e.code === 'KeyG') {
+ if (isDriving) return
+ if (grabbedObjectIdRef.current) {
+ // Place the object
+ const forward = new THREE.Vector3(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current))
+ const placePos: [number, number, number] = [
+ camera.position.x + forward.x * GRAB_CARRY_DISTANCE,
+ 0,
+ camera.position.z + forward.z * GRAB_CARRY_DISTANCE
+ ]
+ placeObjectRef.current(placePos, yawRef.current)
+ } else {
+ // Find nearest grabbable object
+ const cam2D = new THREE.Vector2(camera.position.x, camera.position.z)
+ let nearestId: string | null = null
+ let nearestDist = GRAB_DISTANCE
+ const loc = locationRef.current
+ const positions = objectPositionsRef.current
+ const scn = sceneryRef.current
+
+ if (loc.type === 'indoor') {
+ for (const ia of interactablesRef.current) {
+ if (!ia.id.startsWith('desk-')) continue
+ const override = positions[ia.id]
+ const pos = override ? new THREE.Vector2(override.position[0], override.position[2]) : new THREE.Vector2(ia.pos.x, ia.pos.z)
+ const d = cam2D.distanceTo(pos)
+ if (d < nearestDist) { nearestDist = d; nearestId = ia.id }
+ }
+ } else {
+ for (let i = 0; i < scn.length; i++) {
+ const id = `scenery-${i}`
+ const override = positions[id]
+ const pos = override
+ ? new THREE.Vector2(override.position[0], override.position[2])
+ : new THREE.Vector2(scn[i].position[0], scn[i].position[2])
+ const d = cam2D.distanceTo(pos)
+ if (d < nearestDist) { nearestDist = d; nearestId = id }
+ }
+ }
+
+ if (nearestId) grabObjectRef.current(nearestId)
+ }
+ }
+ }
+ const handleKeyUp = (e: KeyboardEvent) => keysRef.current.delete(e.code)
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isLockedRef.current) return
+ yawRef.current -= e.movementX * LOOK_SPEED
+ pitchRef.current -= e.movementY * LOOK_SPEED
+ pitchRef.current = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, pitchRef.current))
+ }
+ const handlePointerLockChange = () => {
+ isLockedRef.current = document.pointerLockElement === gl.domElement
+ }
+ const handleClick = () => {
+ if (!isDriving && !isLockedRef.current) tryRequestPointerLock(gl.domElement)
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ window.addEventListener('keyup', handleKeyUp)
+ document.addEventListener('mousemove', handleMouseMove)
+ document.addEventListener('pointerlockchange', handlePointerLockChange)
+ gl.domElement.addEventListener('click', handleClick)
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ window.removeEventListener('keyup', handleKeyUp)
+ document.removeEventListener('mousemove', handleMouseMove)
+ document.removeEventListener('pointerlockchange', handlePointerLockChange)
+ gl.domElement.removeEventListener('click', handleClick)
+ try { if (document.pointerLockElement === gl.domElement) document.exitPointerLock() } catch { /* ignore */ }
+ }
+ }, [camera, carStateRef, enterCar, enterHouse, exitCar, exitHouse, gl, interact, isDriving, location])
+
+ useFrame((_, delta) => {
+ const keys = keysRef.current
+ const isRunning = keys.has('ShiftLeft') || keys.has('ShiftRight')
+ const forward = new THREE.Vector3(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current))
+
+ if (carInteractableRef.current) {
+ const carPos = carStateRef.current.position
+ carInteractableRef.current.pos.set(carPos[0], 0, carPos[2])
+ }
+
+ if (isDriving) {
+ const car = carStateRef.current
+ const throttle = (keys.has('KeyW') || keys.has('ArrowUp') ? 1 : 0) + (keys.has('KeyS') || keys.has('ArrowDown') ? -1 : 0)
+ const steer = (keys.has('KeyA') || keys.has('ArrowLeft') ? 1 : 0) + (keys.has('KeyD') || keys.has('ArrowRight') ? -1 : 0)
+ const boost = keys.has('ShiftLeft') || keys.has('ShiftRight')
+
+ if (throttle > 0) car.speed += throttle * CAR_ACCELERATION * delta
+ else if (throttle < 0) car.speed += throttle * CAR_REVERSE_ACCELERATION * delta
+ else if (car.speed !== 0) {
+ const drag = Math.min(Math.abs(car.speed), CAR_DRAG * delta)
+ car.speed -= Math.sign(car.speed) * drag
+ }
+
+ if (keys.has('Space') && car.speed !== 0) {
+ const brake = Math.min(Math.abs(car.speed), CAR_BRAKE_POWER * delta)
+ car.speed -= Math.sign(car.speed) * brake
+ }
+
+ car.speed = Math.max(
+ -CAR_MAX_REVERSE_SPEED,
+ Math.min(boost ? CAR_MAX_SPEED * 1.35 : CAR_MAX_SPEED, car.speed)
+ )
+
+ const speedRatio = Math.min(1, Math.abs(car.speed) / CAR_MAX_SPEED)
+ if (steer !== 0 && speedRatio > 0.02) {
+ const direction = car.speed >= 0 ? 1 : -1
+ car.rotation += steer * direction * CAR_STEER_SPEED * delta * (0.35 + speedRatio)
+ }
+
+ const carForward = new THREE.Vector3(-Math.sin(car.rotation), 0, -Math.cos(car.rotation))
+ car.position[0] += carForward.x * car.speed * delta
+ car.position[2] += carForward.z * car.speed * delta
+
+ const followTarget = new THREE.Vector3(
+ car.position[0] - carForward.x * CAR_CAMERA_DISTANCE,
+ CAR_CAMERA_HEIGHT,
+ car.position[2] - carForward.z * CAR_CAMERA_DISTANCE
+ )
+ camera.position.lerp(followTarget, 1 - Math.exp(-CAMERA_FOLLOW_STIFFNESS * delta))
+ camera.lookAt(
+ car.position[0] + carForward.x * CAR_CAMERA_LOOK_AHEAD,
+ 1.15,
+ car.position[2] + carForward.z * CAR_CAMERA_LOOK_AHEAD
+ )
+ setHoveredId(TOWN_CAR_ID)
+ } else {
+ const speed = isRunning ? RUN_SPEED : WALK_SPEED
+ const right = new THREE.Vector3(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current))
+ const velocity = new THREE.Vector3()
+ if (keys.has('KeyW') || keys.has('ArrowUp')) velocity.add(forward)
+ if (keys.has('KeyS') || keys.has('ArrowDown')) velocity.sub(forward)
+ if (keys.has('KeyA') || keys.has('ArrowLeft')) velocity.sub(right)
+ if (keys.has('KeyD') || keys.has('ArrowRight')) velocity.add(right)
+ if (velocity.lengthSq() > 0) {
+ velocity.normalize().multiplyScalar(speed * delta)
+ camera.position.add(velocity)
+ }
+
+ if (location.type === 'indoor') {
+ camera.position.x = Math.max(-11.5, Math.min(11.5, camera.position.x))
+ camera.position.z = Math.max(-11.5, Math.min(11.5, camera.position.z))
+ }
+
+ velocityYRef.current -= 15 * delta
+ camera.position.y += velocityYRef.current * delta
+ if (camera.position.y <= PLAYER_HEIGHT) {
+ camera.position.y = PLAYER_HEIGHT
+ velocityYRef.current = 0
+ isGroundedRef.current = true
+ }
+ camera.quaternion.setFromEuler(new THREE.Euler(pitchRef.current, yawRef.current, 0, 'YXZ'))
+
+ // Update grabbed object position to follow player
+ if (grabbedObjectId) {
+ const carryPos: [number, number, number] = [
+ camera.position.x + forward.x * GRAB_CARRY_DISTANCE,
+ 0,
+ camera.position.z + forward.z * GRAB_CARRY_DISTANCE
+ ]
+ updateGrabbedPosition(carryPos)
+ }
+
+ // Highlight nearest interactable (use overridden positions for moved objects)
+ const cam2D = new THREE.Vector2(camera.position.x, camera.position.z)
+ const positions = objectPositionsRef.current
+ let nearestId: string | null = null
+ let nearestDist = CAR_INTERACTION_DISTANCE
+ for (const ia of interactablesRef.current) {
+ const override = positions[ia.id]
+ const px = override ? override.position[0] : ia.pos.x
+ const pz = override ? override.position[2] : ia.pos.z
+ const d = cam2D.distanceTo(new THREE.Vector2(px, pz))
+ const maxDist = ia.action === 'enter-car' ? CAR_INTERACTION_DISTANCE : INTERACTION_DISTANCE
+ if (d < nearestDist && d < maxDist) {
+ nearestDist = d
+ nearestId = ia.id
+ }
+ }
+ setHoveredId(nearestId)
+
+ // Auto-enter house when walking into the door circle
+ if (location.type === 'outdoor') {
+ for (const door of doorPositionsRef.current) {
+ const d = cam2D.distanceTo(new THREE.Vector2(door.pos.x, door.pos.z))
+ if (d < AUTO_ENTER_DISTANCE && autoEnteredRef.current !== door.boardId) {
+ autoEnteredRef.current = door.boardId
+ enterHouse(door.boardId)
+ break
+ }
+ }
+ }
+
+ // Auto-exit house when walking into the exit door circle
+ if (location.type === 'indoor') {
+ const exitDoorPos = new THREE.Vector2(0, 12)
+ const d = cam2D.distanceTo(exitDoorPos)
+ if (d < AUTO_ENTER_DISTANCE) {
+ exitHouse()
+ }
+ }
+ }
+
+ // Persist player position every ~60 frames (~1s at 60fps)
+ persistFrameCounter.current++
+ if (persistFrameCounter.current >= 60) {
+ persistFrameCounter.current = 0
+ const persistedCar = carStateRef.current
+ const persistedRight = new THREE.Vector3(Math.cos(persistedCar.rotation), 0, -Math.sin(persistedCar.rotation))
+ const persistedBackward = new THREE.Vector3(Math.sin(persistedCar.rotation), 0, Math.cos(persistedCar.rotation))
+ const persistedX = isDriving
+ ? persistedCar.position[0] + persistedRight.x * CAR_EXIT_OFFSET + persistedBackward.x * 0.8
+ : camera.position.x
+ const persistedY = isDriving ? PLAYER_HEIGHT : camera.position.y
+ const persistedZ = isDriving
+ ? persistedCar.position[2] + persistedRight.z * CAR_EXIT_OFFSET + persistedBackward.z * 0.8
+ : camera.position.z
+ persistPlayerPos({
+ x: persistedX,
+ y: persistedY,
+ z: persistedZ,
+ yaw: isDriving ? persistedCar.rotation : yawRef.current,
+ pitch: isDriving ? 0 : pitchRef.current,
+ locationType: location.type,
+ boardId: location.type === 'indoor' ? location.boardId : undefined
+ })
+ if (isDriving) persistCarTransform()
+ }
+ })
+
+ return null
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/house-interior.tsx b/apps/desktop/src/renderer/src/components/hub/house-interior.tsx
new file mode 100644
index 0000000..6b95e01
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/house-interior.tsx
@@ -0,0 +1,677 @@
+import { useRef, useState, useEffect, useMemo } from 'react'
+import { useFrame, useLoader } from '@react-three/fiber'
+import { Text, Billboard } from '@react-three/drei'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
+import * as THREE from 'three'
+import { useVillage } from './village-context'
+import { GlbModel, type AssetDef } from './hub-assets'
+
+// Desk dimensions (base, scaled by FURNITURE_SCALE on the group)
+const FURNITURE_SCALE = 1.35
+const DESK_W = 1.6
+const DESK_D = 0.8
+const DESK_H = 0.82
+const DESK_TOP_T = 0.04
+const LEG_T = 0.05
+
+const PLANT_ASSETS: AssetDef[] = [
+ { file: 'potted_bush.glb', scale: 3.5 },
+]
+
+function hashStr(s: string): number {
+ let h = 0
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0
+ return Math.abs(h)
+}
+
+const ROOM_SIZE = 24
+const WALL_HEIGHT = 5
+const HS = ROOM_SIZE / 2
+const WT = 0.15
+const DOOR_WIDTH = 3
+const DOOR_HEIGHT = 3.8
+const BASEBOARD_H = 0.25
+const TRIM_DEPTH = 0.04
+
+/* ------------------------------------------------------------------ */
+/* Fetch board items on demand */
+/* ------------------------------------------------------------------ */
+
+interface BoardItem {
+ id: string
+ kind: 'browser' | 'terminal' | 'agent'
+ label: string
+ screenshot?: string | null
+}
+
+function useBoardItems(boardId: string, version: number): BoardItem[] {
+ const [items, setItems] = useState([])
+
+ useEffect(() => {
+ let cancelled = false
+ async function load() {
+ try {
+ const tabs = await window.api.browserTabs.list(boardId)
+ const nodes: { id: string; nodeType: string; label: string; config?: string }[] = await window.api.graphNodes.list(boardId)
+
+ if (cancelled) return
+
+ const result: BoardItem[] = []
+ for (const tab of tabs) {
+ result.push({ id: tab.id, kind: 'browser', label: tab.title || tab.url || 'Tab', screenshot: tab.screenshot })
+ }
+ for (const node of nodes) {
+ if (node.nodeType === 'terminal') {
+ let termScreenshot: string | null = null
+ try {
+ const cfg = node.config ? JSON.parse(node.config) : {}
+ if (cfg.lastScreenshot) termScreenshot = cfg.lastScreenshot
+ } catch { /* ignore */ }
+ result.push({ id: node.id, kind: 'terminal', label: node.label || 'Terminal', screenshot: termScreenshot })
+ }
+ }
+ setItems(result)
+ } catch {
+ // Board may not have tabs/nodes
+ }
+ }
+ load()
+ return () => { cancelled = true }
+ }, [boardId, version])
+
+ return items
+}
+
+/* ------------------------------------------------------------------ */
+/* Room shell — procedural white modern room */
+/* ------------------------------------------------------------------ */
+
+
+function Wall({ position, size }: { position: [number, number, number]; size: [number, number, number] }) {
+ return (
+
+
+
+
+ )
+}
+
+function Baseboard({ position, size }: { position: [number, number, number]; size: [number, number, number] }) {
+ return (
+
+
+
+
+ )
+}
+
+function CeilingLight({ position }: { position: [number, number, number] }) {
+ return (
+
+ {/* Fixture disc */}
+
+
+
+
+ {/* Glow bulb */}
+
+
+
+
+
+
+ )
+}
+
+function RoomShell() {
+ const floorColor = '#d4c4a8' // light oak
+ const ceilingColor = '#faf8f5'
+ const sideWidth = (ROOM_SIZE - DOOR_WIDTH) / 2
+ const doorGapLeft = -(HS - sideWidth / 2)
+ const doorGapRight = (HS - sideWidth / 2)
+
+ return (
+
+ {/* ── Floor ── */}
+
+
+
+
+
+ {/* ── Ceiling ── */}
+
+
+
+
+
+ {/* ── Back wall ── */}
+
+
+
+ {/* ── Left wall ── */}
+
+
+
+ {/* ── Right wall ── */}
+
+
+
+ {/* ── Front wall with door gap ── */}
+
+
+
+ {/* Door frame trim */}
+
+
+
+ {/* Front baseboards (beside door) */}
+
+
+
+ {/* ── Crown moulding (top trim) ── */}
+
+
+
+
+ {/* ── Ceiling lights ── */}
+
+
+
+
+ {/* ── Corner plants ── */}
+
+
+
+
+
+ {/* ── Wall shelf on back wall ── */}
+
+
+
+
+
+
+
+
+ {/* Small plants on shelves */}
+
+
+
+ {/* ── Rug in center ── */}
+
+
+
+
+ {/* Rug border */}
+
+
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Exit door indicator */
+/* ------------------------------------------------------------------ */
+
+function ExitDoor() {
+ const { hoveredId } = useVillage()
+ const isHovered = hoveredId === 'exit-door'
+
+ return (
+
+
+
+
+
+
+
+ {isHovered ? '[E] Exit' : 'Exit'}
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Screenshot texture hook */
+/* ------------------------------------------------------------------ */
+
+function useScreenshotTexture(screenshot?: string | null): THREE.Texture | null {
+ const [texture, setTexture] = useState(null)
+
+ useEffect(() => {
+ if (!screenshot) { setTexture(null); return }
+ const img = new Image()
+ img.onload = () => {
+ const tex = new THREE.Texture(img)
+ tex.needsUpdate = true
+ tex.colorSpace = THREE.SRGBColorSpace
+ tex.minFilter = THREE.LinearFilter
+ tex.magFilter = THREE.LinearFilter
+ tex.flipY = false
+ // UVs are 0,0 to 1,1 — fill width, top-align, clip overflow
+ const imgAspect = img.width / img.height
+ const screenAspect = 1920 / 1080
+ const scaleY = screenAspect / imgAspect
+ tex.repeat.set(1, Math.min(1, scaleY))
+ tex.offset.set(0, Math.max(0, 1 - Math.min(1, scaleY)))
+ tex.wrapS = THREE.ClampToEdgeWrapping
+ tex.wrapT = THREE.ClampToEdgeWrapping
+ setTexture(tex)
+ }
+ img.src = screenshot
+ return () => { img.onload = null }
+ }, [screenshot])
+
+ return texture
+}
+
+/* ------------------------------------------------------------------ */
+/* Computer with screenshot on ScreenMaterial */
+/* ------------------------------------------------------------------ */
+
+const COMPUTER_NEW_PATH = '/hub-assets/computer_new.glb'
+
+function ComputerWithScreen({ item }: { item: BoardItem }) {
+ const gltf = useLoader(GLTFLoader, COMPUTER_NEW_PATH)
+ const screenshotTex = useScreenshotTexture(item.screenshot)
+
+ const scene = useMemo(() => {
+ const cloned = gltf.scene.clone(true)
+ cloned.traverse((child) => {
+ if (!(child as THREE.Mesh).isMesh) return
+ const mesh = child as THREE.Mesh
+ // Check original material name before any conversion
+ const origMat = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material
+ const matName = origMat?.name ?? ''
+
+ if (matName === 'ScreenMaterial') {
+ // Apply screenshot or fallback
+ if (screenshotTex) {
+ mesh.material = new THREE.MeshBasicMaterial({
+ map: screenshotTex,
+ toneMapped: false,
+ })
+ } else {
+ mesh.material = new THREE.MeshStandardMaterial({
+ color: '#0a0a12',
+ emissive: new THREE.Color(item.kind === 'terminal' ? '#1a1a2e' : '#0d1a2e'),
+ emissiveIntensity: 0.3,
+ })
+ }
+ } else if (origMat?.type === 'MeshPhysicalMaterial') {
+ // Downgrade physical -> standard to avoid Three.js crash
+ const phys = origMat as THREE.MeshPhysicalMaterial
+ const std = new THREE.MeshStandardMaterial()
+ std.name = phys.name
+ std.color.copy(phys.color)
+ std.map = phys.map
+ std.normalMap = phys.normalMap
+ std.roughness = phys.roughness
+ std.roughnessMap = phys.roughnessMap
+ std.metalness = phys.metalness
+ std.metalnessMap = phys.metalnessMap
+ std.aoMap = phys.aoMap
+ std.emissive.copy(phys.emissive)
+ std.emissiveMap = phys.emissiveMap
+ std.emissiveIntensity = phys.emissiveIntensity
+ std.side = phys.side
+ std.transparent = phys.transparent
+ std.opacity = phys.opacity
+ mesh.material = std
+ }
+ mesh.castShadow = true
+ mesh.receiveShadow = true
+ })
+ // Shift up so bottom of model sits at y=0 (on desk surface)
+ const box = new THREE.Box3().setFromObject(cloned)
+ cloned.position.y = -box.min.y
+
+ return cloned
+ }, [gltf.scene, screenshotTex, item.kind])
+
+ return
+}
+
+/* ------------------------------------------------------------------ */
+/* Procedural desk */
+/* ------------------------------------------------------------------ */
+
+function ProceduralDesk() {
+ const topY = DESK_H - DESK_TOP_T / 2
+ const legH = DESK_H - DESK_TOP_T
+ const legY = legH / 2
+ const insetW = DESK_W / 2 - LEG_T / 2 - 0.03
+ const insetD = DESK_D / 2 - LEG_T / 2 - 0.03
+ const woodColor = '#8b6f4e'
+ const topColor = '#f0ebe3'
+
+ return (
+
+ {/* Tabletop — white laminate */}
+
+
+
+
+ {/* Thin edge band */}
+
+
+
+
+ {/* Four straight legs — warm wood */}
+ {[[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([sx, sz], i) => (
+
+
+
+
+ ))}
+ {/* Cross bar under top for structure */}
+
+
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Computer desk (represents a real web tab or terminal) */
+/* ------------------------------------------------------------------ */
+
+interface ComputerDeskProps {
+ position: [number, number, number]
+ rotation: number
+ item: BoardItem
+ id: string
+}
+
+function ComputerDesk({ position, rotation, item, id }: ComputerDeskProps) {
+ const { hoveredId, objectPositions, grabbedObjectId } = useVillage()
+ const isHovered = hoveredId === id
+ const isGrabbed = grabbedObjectId === id
+ const override = objectPositions[id]
+ const finalPosition = override?.position ?? position
+ const finalRotation = override?.rotation ?? rotation
+
+ return (
+
+
+
+
+
+
+
+
+ {/* Grab indicator ring */}
+ {isGrabbed && (
+
+
+
+
+ )}
+
+
+
+ {isGrabbed ? 'Carrying...' : item.label}
+
+ {isHovered && !isGrabbed && (
+
+ [E] Open · [G] Grab
+
+ )}
+ {isGrabbed && (
+
+ [G] Place
+
+ )}
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Agent character — walks around the room */
+/* ------------------------------------------------------------------ */
+
+interface AgentCharacterProps {
+ startPosition: [number, number, number]
+ label: string
+ id: string
+ seed: number
+}
+
+function AgentCharacter({ startPosition, label, id, seed }: AgentCharacterProps) {
+ const { hoveredId } = useVillage()
+ const isHovered = hoveredId === id
+ const ref = useRef(null)
+
+ // Generate a patrol path (random waypoints in the room)
+ const waypoints = useMemo(() => {
+ const rng = () => {
+ seed = (seed * 16807) % 2147483647
+ return (seed - 1) / 2147483646
+ }
+ const pts: THREE.Vector3[] = []
+ for (let i = 0; i < 5; i++) {
+ pts.push(new THREE.Vector3(
+ (rng() - 0.5) * (ROOM_SIZE - 6),
+ 0,
+ (rng() - 0.5) * (ROOM_SIZE - 6)
+ ))
+ }
+ // Add start position as first waypoint
+ pts.unshift(new THREE.Vector3(...startPosition))
+ return pts
+ }, [startPosition, seed])
+
+ const waypointIdx = useRef(0)
+ const currentPos = useRef(new THREE.Vector3(...startPosition))
+ const walkSpeed = 1.5
+
+ useFrame((state, delta) => {
+ if (!ref.current) return
+
+ const target = waypoints[waypointIdx.current]
+ const dir = new THREE.Vector3().subVectors(target, currentPos.current)
+ const dist = dir.length()
+
+ if (dist < 0.3) {
+ // Move to next waypoint
+ waypointIdx.current = (waypointIdx.current + 1) % waypoints.length
+ } else {
+ dir.normalize().multiplyScalar(Math.min(walkSpeed * delta, dist))
+ currentPos.current.add(dir)
+ // Face walk direction
+ ref.current.rotation.y = Math.atan2(dir.x, dir.z)
+ }
+
+ ref.current.position.copy(currentPos.current)
+
+ // Bobbing while walking
+ const bob = Math.sin(state.clock.elapsedTime * 8) * 0.03
+ ref.current.position.y = bob
+ })
+
+ const bodyColor = isHovered ? '#4488ff' : '#3366aa'
+ const skinColor = isHovered ? '#ffcc88' : '#ffddaa'
+ const shirtColor = isHovered ? '#5599ff' : '#446699'
+
+ return (
+
+ {/* Legs */}
+
+
+
+
+
+
+
+
+
+ {/* Torso */}
+
+
+
+
+
+ {/* Arms */}
+
+
+
+
+
+
+
+
+
+ {/* Head */}
+
+
+
+
+
+ {/* Hair */}
+
+
+
+
+
+ {/* Name label */}
+
+
+ {label}
+
+ {isHovered && (
+
+ [E] Talk
+
+ )}
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* House Interior */
+/* ------------------------------------------------------------------ */
+
+interface HouseInteriorProps {
+ boardId: string
+}
+
+export function HouseInterior({ boardId }: HouseInteriorProps) {
+ const { boards, boardItemsVersion } = useVillage()
+ const board = boards.find((b) => b.id === boardId)
+ const boardItems = useBoardItems(boardId, boardItemsVersion)
+
+ // Randomly place desks around the room using seeded positions
+ const desks = useMemo(() => {
+ const result: { item: BoardItem; position: [number, number, number]; rotation: number; id: string }[] = []
+ let seed = hashStr(boardId)
+ const rng = () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646 }
+ const margin = 2.5
+ const placed: [number, number][] = []
+
+ boardItems.forEach((item) => {
+ // Try random positions, avoiding overlap
+ for (let attempt = 0; attempt < 20; attempt++) {
+ const x = (rng() - 0.5) * (ROOM_SIZE - margin * 2 - 2)
+ const z = -HS + margin + rng() * (ROOM_SIZE - margin * 2 - 4) // avoid door area at +Z
+ const tooClose = placed.some(([px, pz]) => Math.hypot(x - px, z - pz) < 2.8)
+ if (!tooClose) {
+ placed.push([x, z])
+ const rot = rng() * Math.PI * 2
+ result.push({ item, position: [x, 0, z], rotation: rot, id: `desk-${item.id}` })
+ break
+ }
+ }
+ })
+ return result
+ }, [boardItems, boardId])
+
+ return (
+
+
+
+
+ {/* Board title on back wall */}
+
+ {board?.name ?? 'Board'}
+
+
+ {/* Actual board tab/terminal computers */}
+ {desks.map((desk) => (
+
+ ))}
+
+ {/* Agent character that patrols the room */}
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/hub-assets.tsx b/apps/desktop/src/renderer/src/components/hub/hub-assets.tsx
new file mode 100644
index 0000000..e8b166c
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/hub-assets.tsx
@@ -0,0 +1,135 @@
+import { useMemo } from 'react'
+import { useLoader } from '@react-three/fiber'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
+import * as THREE from 'three'
+
+/* ------------------------------------------------------------------ */
+/* Asset catalog – maps logical names to GLB files and default scale */
+/* ------------------------------------------------------------------ */
+
+export interface AssetDef {
+ file: string
+ scale: number
+ yawOffset?: number // extra Y rotation to correct model facing direction
+ keepMaterial?: boolean // don't override with village texture
+}
+
+// Houses (folders)
+export const HOUSE_ASSETS: AssetDef[] = [
+ { file: 'house.glb', scale: 3.5 },
+ { file: 'house_blue.glb', scale: 3.5 },
+ { file: 'house_red.glb', scale: 3.5 },
+ { file: 'house_purple.glb', scale: 3.5 },
+ { file: 'house_2story_purple.glb', scale: 3.0, yawOffset: Math.PI / 2 },
+ { file: 'barn.glb', scale: 2.5 },
+ { file: 'chapel.glb', scale: 2.5 },
+ { file: 'windmill.glb', scale: 1.5 },
+]
+
+// Trees
+export const TREE_ASSETS: AssetDef[] = [
+ { file: 'tree_pine.glb', scale: 4.0 },
+ { file: 'treetall.glb', scale: 4.5 },
+ { file: 'tree_square.glb', scale: 4.0 },
+]
+
+// Town decorations (scattered around outdoors)
+export const OUTDOOR_PROPS: AssetDef[] = [
+ { file: 'bench.glb', scale: 3.0 },
+ { file: 'barrell.glb', scale: 3.0 },
+ { file: 'boulder.glb', scale: 2.5 },
+ { file: 'crate.glb', scale: 3.0 },
+ { file: 'streetlight.glb', scale: 3.0 },
+ { file: 'well.glb', scale: 3.0 },
+ { file: 'hay_bale.glb', scale: 3.0 },
+ { file: 'hay_cart.glb', scale: 2.5 },
+ { file: 'potted_bush.glb', scale: 3.0 },
+ { file: 'fence.glb', scale: 3.0 },
+ { file: 'pillar.glb', scale: 3.5 },
+]
+
+// Small decorative items
+export const SMALL_PROPS: AssetDef[] = [
+ { file: 'daisy.glb', scale: 4.0 },
+ { file: 'shroom.glb', scale: 3.0 },
+]
+
+// Indoor furniture (for sims rooms)
+export const INDOOR_PROPS: AssetDef[] = [
+ { file: 'bench.glb', scale: 2.5 },
+ { file: 'barrell.glb', scale: 2.5 },
+ { file: 'crate.glb', scale: 2.5 },
+ { file: 'potted_bush.glb', scale: 2.5 },
+ { file: 'picnic_table.glb', scale: 3.0 },
+]
+
+// Computer (for house interiors - tabs/terminals)
+export const COMPUTER_ASSET: AssetDef = { file: 'computer.glb', scale: 1.5, keepMaterial: true }
+
+/* ------------------------------------------------------------------ */
+/* GLB model component */
+/* ------------------------------------------------------------------ */
+
+const BASE_PATH = '/hub-assets/'
+
+interface GlbModelProps {
+ asset: AssetDef
+ position?: [number, number, number]
+ rotation?: [number, number, number]
+ scale?: number
+}
+
+export function GlbModel({ asset, position = [0, 0, 0], rotation = [0, 0, 0], scale }: GlbModelProps) {
+ const gltf = useLoader(GLTFLoader, BASE_PATH + asset.file)
+ const texture = useLoader(THREE.TextureLoader, BASE_PATH + 'texture.png')
+
+ const scene = useMemo(() => {
+ const cloned = gltf.scene.clone()
+ texture.flipY = false
+ texture.colorSpace = THREE.SRGBColorSpace
+ cloned.traverse((child) => {
+ if ((child as THREE.Mesh).isMesh) {
+ const mesh = child as THREE.Mesh
+ mesh.castShadow = true
+ mesh.receiveShadow = true
+ if (!asset.keepMaterial) {
+ mesh.material = new THREE.MeshStandardMaterial({
+ map: texture,
+ metalness: 0,
+ roughness: 0.82,
+ })
+ }
+ }
+ })
+ return cloned
+ }, [gltf.scene, texture, asset.keepMaterial])
+
+ const s = scale ?? asset.scale
+ const yaw = asset.yawOffset ?? 0
+ const finalRotation: [number, number, number] = [rotation[0], rotation[1] + yaw, rotation[2]]
+
+ return (
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Seeded random helpers for deterministic placement */
+/* ------------------------------------------------------------------ */
+
+export function seededRandom(seed: number): () => number {
+ let s = seed
+ return () => {
+ s = (s * 16807 + 0) % 2147483647
+ return (s - 1) / 2147483646
+ }
+}
+
+export function pickAsset(assets: T[], rng: () => number): T {
+ return assets[Math.floor(rng() * assets.length)]
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/hub-fps-view.tsx b/apps/desktop/src/renderer/src/components/hub/hub-fps-view.tsx
new file mode 100644
index 0000000..c604f2e
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/hub-fps-view.tsx
@@ -0,0 +1,498 @@
+import { Suspense, useRef, useState, useEffect, useCallback, useMemo } from 'react'
+import { Canvas, useFrame, useThree } from '@react-three/fiber'
+import { Text, Sky, ContactShadows } from '@react-three/drei'
+import * as THREE from 'three'
+import type { WorkspaceFolder, WorkspaceBoard } from '@/hooks/use-workspace'
+import { useHubWorldLighting } from '@/hooks/use-hub-world-lighting'
+import {
+ GlbModel, HOUSE_ASSETS, TREE_ASSETS, OUTDOOR_PROPS, SMALL_PROPS,
+ seededRandom, pickAsset, type AssetDef
+} from './hub-assets'
+
+/* ------------------------------------------------------------------ */
+/* Types & Constants */
+/* ------------------------------------------------------------------ */
+
+interface HouseData {
+ folder: WorkspaceFolder
+ boards: WorkspaceBoard[]
+ position: [number, number, number]
+ houseAsset: AssetDef
+}
+
+interface PersonWorldPos {
+ boardId: string
+ worldPos: THREE.Vector3
+}
+
+interface SceneProp {
+ asset: AssetDef
+ position: [number, number, number]
+ rotation: [number, number, number]
+}
+
+const MOVE_SPEED = 8
+const LOOK_SPEED = 0.002
+const HOUSE_SPACING = 18
+const HOUSES_PER_ROW = 4
+const PERSON_RADIUS = 0.3
+const PERSON_HEIGHT = 1.6
+const INTERACTION_DISTANCE = 3.5
+const PLAYER_HEIGHT = 1.7
+const PERSON_SPREAD = 4
+
+/* ------------------------------------------------------------------ */
+/* Ground & Road */
+/* ------------------------------------------------------------------ */
+
+function Ground() {
+ return (
+
+
+
+
+ )
+}
+
+function Road({ houseCount }: { houseCount: number }) {
+ const cols = Math.min(houseCount, HOUSES_PER_ROW)
+ const roadLength = Math.max(cols * HOUSE_SPACING, 20)
+
+ return (
+
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Person (board) - keeps proxy geo for clarity + labels */
+/* ------------------------------------------------------------------ */
+
+interface PersonProps {
+ position: [number, number, number]
+ board: WorkspaceBoard
+ isHighlighted: boolean
+ onHover: (boardId: string | null) => void
+}
+
+function Person({ position, board, isHighlighted, onHover }: PersonProps) {
+ const meshRef = useRef(null)
+
+ useFrame((state) => {
+ if (meshRef.current) {
+ meshRef.current.position.y = position[1] + Math.sin(state.clock.elapsedTime * 2 + position[0]) * 0.05
+ }
+ })
+
+ const bodyColor = isHighlighted ? '#4488ff' : '#6699cc'
+ const headColor = isHighlighted ? '#ffcc88' : '#ffddaa'
+
+ return (
+ { e.stopPropagation(); onHover(board.id) }}
+ onPointerOut={(e) => { e.stopPropagation(); onHover(null) }}
+ >
+
+
+
+
+
+
+
+
+
+ {board.name}
+
+ {isHighlighted && (
+
+ [E] Interact
+
+ )}
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* House (folder) - GLB model + people in front */
+/* ------------------------------------------------------------------ */
+
+interface HouseProps {
+ data: HouseData
+ hoveredBoard: string | null
+ onHoverBoard: (boardId: string | null) => void
+}
+
+function House({ data, hoveredBoard, onHoverBoard }: HouseProps) {
+ const { folder, boards, position, houseAsset } = data
+
+ return (
+
+
+
+ {/* Folder name sign above house */}
+
+ {folder.name}
+
+
+ {/* People (boards) spread in front of house */}
+ {boards.map((board, i) => {
+ const spread = Math.min(boards.length, 5)
+ const px = ((i % spread) - (spread - 1) / 2) * 1.8
+ const pz = PERSON_SPREAD + Math.floor(i / spread) * 2
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* FPS Camera Controller */
+/* ------------------------------------------------------------------ */
+
+interface FPSControllerProps {
+ personPositions: PersonWorldPos[]
+ onInteract: (boardId: string) => void
+}
+
+function FPSController({ personPositions, onInteract }: FPSControllerProps) {
+ const { camera, gl } = useThree()
+ const keysRef = useRef(new Set())
+ const yawRef = useRef(0)
+ const pitchRef = useRef(0)
+ const isLockedRef = useRef(false)
+ const nearestBoardRef = useRef(null)
+ const velocityYRef = useRef(0)
+ const isGroundedRef = useRef(true)
+
+ useEffect(() => {
+ camera.position.set(0, PLAYER_HEIGHT, 20)
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ keysRef.current.add(e.code)
+ if (e.code === 'Space' && isGroundedRef.current) {
+ velocityYRef.current = 6
+ isGroundedRef.current = false
+ }
+ if (e.code === 'KeyE' && isLockedRef.current) {
+ if (nearestBoardRef.current) onInteract(nearestBoardRef.current)
+ document.exitPointerLock()
+ }
+ }
+ const handleKeyUp = (e: KeyboardEvent) => keysRef.current.delete(e.code)
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isLockedRef.current) return
+ yawRef.current -= e.movementX * LOOK_SPEED
+ pitchRef.current -= e.movementY * LOOK_SPEED
+ pitchRef.current = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, pitchRef.current))
+ }
+ const handlePointerLockChange = () => {
+ isLockedRef.current = document.pointerLockElement === gl.domElement
+ }
+ const handleClick = () => {
+ if (!isLockedRef.current) gl.domElement.requestPointerLock()
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ window.addEventListener('keyup', handleKeyUp)
+ document.addEventListener('mousemove', handleMouseMove)
+ document.addEventListener('pointerlockchange', handlePointerLockChange)
+ gl.domElement.addEventListener('click', handleClick)
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ window.removeEventListener('keyup', handleKeyUp)
+ document.removeEventListener('mousemove', handleMouseMove)
+ document.removeEventListener('pointerlockchange', handlePointerLockChange)
+ gl.domElement.removeEventListener('click', handleClick)
+ if (document.pointerLockElement === gl.domElement) document.exitPointerLock()
+ }
+ }, [camera, gl, onInteract])
+
+ useFrame((_, delta) => {
+ const keys = keysRef.current
+ const forward = new THREE.Vector3(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current))
+ const right = new THREE.Vector3(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current))
+ const velocity = new THREE.Vector3()
+ if (keys.has('KeyW') || keys.has('ArrowUp')) velocity.add(forward)
+ if (keys.has('KeyS') || keys.has('ArrowDown')) velocity.sub(forward)
+ if (keys.has('KeyA') || keys.has('ArrowLeft')) velocity.sub(right)
+ if (keys.has('KeyD') || keys.has('ArrowRight')) velocity.add(right)
+ if (velocity.lengthSq() > 0) {
+ velocity.normalize().multiplyScalar(MOVE_SPEED * delta)
+ camera.position.add(velocity)
+ }
+ velocityYRef.current -= 15 * delta
+ camera.position.y += velocityYRef.current * delta
+ if (camera.position.y <= PLAYER_HEIGHT) {
+ camera.position.y = PLAYER_HEIGHT
+ velocityYRef.current = 0
+ isGroundedRef.current = true
+ }
+ camera.quaternion.setFromEuler(new THREE.Euler(pitchRef.current, yawRef.current, 0, 'YXZ'))
+
+ let nearest: string | null = null
+ let nearestDist = INTERACTION_DISTANCE
+ const cam2D = new THREE.Vector2(camera.position.x, camera.position.z)
+ for (const pp of personPositions) {
+ const d = cam2D.distanceTo(new THREE.Vector2(pp.worldPos.x, pp.worldPos.z))
+ if (d < nearestDist) { nearestDist = d; nearest = pp.boardId }
+ }
+ nearestBoardRef.current = nearest
+ })
+
+ return null
+}
+
+/* ------------------------------------------------------------------ */
+/* Main FPS View */
+/* ------------------------------------------------------------------ */
+
+interface HubFpsViewProps {
+ folders: WorkspaceFolder[]
+ boards: WorkspaceBoard[]
+ onSelectBoard: (boardId: string) => void
+}
+
+export function HubFpsView({ folders, boards, onSelectBoard }: HubFpsViewProps) {
+ const [hoveredBoard, setHoveredBoard] = useState(null)
+ const lighting = useHubWorldLighting()
+
+ // Build houses with assigned GLB models
+ const houses = useMemo(() => {
+ const boardsByFolder = new Map()
+ for (const b of boards) {
+ const key = b.folderId ?? null
+ if (!boardsByFolder.has(key)) boardsByFolder.set(key, [])
+ boardsByFolder.get(key)!.push(b)
+ }
+
+ const result: HouseData[] = []
+ const sortedFolders = [...folders].sort((a, b) => a.sortOrder - b.sortOrder)
+ const ungrouped = boardsByFolder.get(null)
+ if (ungrouped && ungrouped.length > 0) {
+ result.push({
+ folder: { id: '__ungrouped__', name: 'Ungrouped', sortOrder: -1, createdAt: '', updatedAt: '' },
+ boards: ungrouped,
+ position: [0, 0, 0],
+ houseAsset: HOUSE_ASSETS[0]
+ })
+ }
+ for (const folder of sortedFolders) {
+ result.push({
+ folder,
+ boards: boardsByFolder.get(folder.id) ?? [],
+ position: [0, 0, 0],
+ houseAsset: HOUSE_ASSETS[0]
+ })
+ }
+ // Assign house models and layout
+ result.forEach((house, i) => {
+ const col = i % HOUSES_PER_ROW
+ const row = Math.floor(i / HOUSES_PER_ROW)
+ house.position = [col * HOUSE_SPACING, 0, -row * HOUSE_SPACING]
+ house.houseAsset = HOUSE_ASSETS[i % HOUSE_ASSETS.length]
+ })
+ return result
+ }, [folders, boards])
+
+ // Person world positions for proximity detection
+ const personPositions = useMemo(() => {
+ const result: PersonWorldPos[] = []
+ for (const house of houses) {
+ house.boards.forEach((board, i) => {
+ const spread = Math.min(house.boards.length, 5)
+ const px = house.position[0] + ((i % spread) - (spread - 1) / 2) * 1.8
+ const pz = house.position[2] + PERSON_SPREAD + Math.floor(i / spread) * 2
+ result.push({ boardId: board.id, worldPos: new THREE.Vector3(px, 0, pz) })
+ })
+ }
+ return result
+ }, [houses])
+
+ // Procedural scene decorations using seeded random
+ const sceneProps = useMemo(() => {
+ const rng = seededRandom(42)
+ const props: SceneProp[] = []
+ const cols = Math.min(houses.length, HOUSES_PER_ROW)
+ const rows = Math.ceil(houses.length / HOUSES_PER_ROW)
+ const extentX = cols * HOUSE_SPACING
+ const extentZ = Math.max(rows, 1) * HOUSE_SPACING
+
+ // Trees around the town
+ for (let i = 0; i < 30; i++) {
+ const x = (rng() - 0.3) * extentX * 1.8
+ const z = (rng() - 0.5) * extentZ * 2
+ const tooClose = houses.some((h) => {
+ return Math.abs(x - h.position[0]) < 7 && Math.abs(z - h.position[2]) < 7
+ })
+ if (!tooClose) {
+ props.push({
+ asset: pickAsset(TREE_ASSETS, rng),
+ position: [x, 0, z],
+ rotation: [0, rng() * Math.PI * 2, 0]
+ })
+ }
+ }
+
+ // Outdoor props near houses
+ for (const house of houses) {
+ const count = 2 + Math.floor(rng() * 3)
+ for (let j = 0; j < count; j++) {
+ const ox = house.position[0] + (rng() - 0.5) * 12
+ const oz = house.position[2] + (rng() - 0.5) * 10
+ props.push({
+ asset: pickAsset(OUTDOOR_PROPS, rng),
+ position: [ox, 0, oz],
+ rotation: [0, rng() * Math.PI * 2, 0]
+ })
+ }
+ }
+
+ // Small decorations (flowers, mushrooms) scattered
+ for (let i = 0; i < 40; i++) {
+ const x = (rng() - 0.3) * extentX * 1.5
+ const z = (rng() - 0.5) * extentZ * 1.5
+ props.push({
+ asset: pickAsset(SMALL_PROPS, rng),
+ position: [x, 0, z],
+ rotation: [0, rng() * Math.PI * 2, 0]
+ })
+ }
+
+ // Streetlights along the road
+ const streetlight = OUTDOOR_PROPS.find((p) => p.file === 'streetlight.glb')!
+ for (let i = 0; i < cols; i++) {
+ props.push({ asset: streetlight, position: [i * HOUSE_SPACING - 4, 0, 6], rotation: [0, 0, 0] })
+ props.push({ asset: streetlight, position: [i * HOUSE_SPACING + 4, 0, 6], rotation: [0, Math.PI, 0] })
+ }
+
+ return props
+ }, [houses])
+
+ const handleInteract = useCallback((boardId: string) => {
+ onSelectBoard(boardId)
+ }, [onSelectBoard])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {houses.map((house) => (
+
+ ))}
+
+ {sceneProps.map((prop, i) => (
+
+ ))}
+
+
+
+
+
+
+
Click to lock mouse · WASD to move · Space to jump · Mouse to look
+
Press E near a person to interact · E also releases mouse
+
+
+ {hoveredBoard && (
+
+ {boards.find((b) => b.id === hoveredBoard)?.name}
+
+ )}
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/hub-reactflow-view.tsx b/apps/desktop/src/renderer/src/components/hub/hub-reactflow-view.tsx
new file mode 100644
index 0000000..21ae0d7
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/hub-reactflow-view.tsx
@@ -0,0 +1,185 @@
+import { useMemo, useCallback } from 'react'
+import {
+ ReactFlow,
+ ReactFlowProvider,
+ Background,
+ Handle,
+ Position,
+ type Node,
+ type Edge,
+ type NodeTypes,
+ type NodeMouseHandler
+} from '@xyflow/react'
+import '@xyflow/react/dist/style.css'
+import type { WorkspaceFolder, WorkspaceBoard } from '@/hooks/use-workspace'
+
+/* ------------------------------------------------------------------ */
+/* Custom node components */
+/* ------------------------------------------------------------------ */
+
+function FolderNode({ data }: { data: { label: string } }) {
+ return (
+
+ )
+}
+
+function BoardNode({ data }: { data: { label: string } }) {
+ return (
+
+ )
+}
+
+const nodeTypes: NodeTypes = {
+ folder: FolderNode as unknown as NodeTypes[string],
+ board: BoardNode as unknown as NodeTypes[string]
+}
+
+/* ------------------------------------------------------------------ */
+/* Layout helpers */
+/* ------------------------------------------------------------------ */
+
+const FOLDER_GAP_X = 320
+const BOARD_GAP_Y = 60
+const BOARD_OFFSET_X = 40
+const BOARD_OFFSET_Y = 50
+
+function buildNodesAndEdges(
+ folders: WorkspaceFolder[],
+ boards: WorkspaceBoard[]
+): { nodes: Node[]; edges: Edge[] } {
+ const nodes: Node[] = []
+ const edges: Edge[] = []
+
+ const boardsByFolder = new Map()
+ for (const b of boards) {
+ const key = b.folderId ?? null
+ if (!boardsByFolder.has(key)) boardsByFolder.set(key, [])
+ boardsByFolder.get(key)!.push(b)
+ }
+
+ // Sort folders by sortOrder
+ const sortedFolders = [...folders].sort((a, b) => a.sortOrder - b.sortOrder)
+
+ // Add ungrouped boards as a virtual folder
+ const ungrouped = boardsByFolder.get(null) ?? []
+
+ let colIndex = 0
+
+ // Ungrouped boards first
+ if (ungrouped.length > 0) {
+ const fx = colIndex * FOLDER_GAP_X
+ nodes.push({
+ id: '__ungrouped__',
+ type: 'folder',
+ position: { x: fx, y: 0 },
+ data: { label: 'Ungrouped' },
+ draggable: true
+ })
+ ungrouped.forEach((board, bi) => {
+ const bid = `board-${board.id}`
+ nodes.push({
+ id: bid,
+ type: 'board',
+ position: { x: fx + BOARD_OFFSET_X, y: BOARD_OFFSET_Y + bi * BOARD_GAP_Y },
+ data: { label: board.name, boardId: board.id }
+ })
+ edges.push({
+ id: `e-ungrouped-${board.id}`,
+ source: '__ungrouped__',
+ target: bid,
+ type: 'smoothstep',
+ animated: true
+ })
+ })
+ colIndex++
+ }
+
+ // Each folder
+ for (const folder of sortedFolders) {
+ const fx = colIndex * FOLDER_GAP_X
+ const fid = `folder-${folder.id}`
+ nodes.push({
+ id: fid,
+ type: 'folder',
+ position: { x: fx, y: 0 },
+ data: { label: folder.name }
+ })
+
+ const folderBoards = boardsByFolder.get(folder.id) ?? []
+ folderBoards.sort((a, b) => a.sortOrder - b.sortOrder)
+ folderBoards.forEach((board, bi) => {
+ const bid = `board-${board.id}`
+ nodes.push({
+ id: bid,
+ type: 'board',
+ position: { x: fx + BOARD_OFFSET_X, y: BOARD_OFFSET_Y + bi * BOARD_GAP_Y },
+ data: { label: board.name, boardId: board.id }
+ })
+ edges.push({
+ id: `e-${folder.id}-${board.id}`,
+ source: fid,
+ target: bid,
+ type: 'smoothstep',
+ animated: true
+ })
+ })
+ colIndex++
+ }
+
+ return { nodes, edges }
+}
+
+/* ------------------------------------------------------------------ */
+/* Component */
+/* ------------------------------------------------------------------ */
+
+interface HubReactFlowViewProps {
+ folders: WorkspaceFolder[]
+ boards: WorkspaceBoard[]
+ onSelectBoard: (boardId: string) => void
+}
+
+function HubReactFlowViewInner({ folders, boards, onSelectBoard }: HubReactFlowViewProps) {
+ const { nodes, edges } = useMemo(() => buildNodesAndEdges(folders, boards), [folders, boards])
+
+ const handleNodeClick: NodeMouseHandler = useCallback(
+ (_event, node) => {
+ if (node.type === 'board' && node.data.boardId) {
+ onSelectBoard(node.data.boardId as string)
+ }
+ },
+ [onSelectBoard]
+ )
+
+ return (
+
+
+
+ )
+}
+
+export function HubReactFlowView(props: HubReactFlowViewProps) {
+ return (
+
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/hub-sims-view.tsx b/apps/desktop/src/renderer/src/components/hub/hub-sims-view.tsx
new file mode 100644
index 0000000..6e971ac
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/hub-sims-view.tsx
@@ -0,0 +1,585 @@
+import { Suspense, useRef, useState, useEffect, useCallback, useMemo } from 'react'
+import { Canvas, useFrame, useThree } from '@react-three/fiber'
+import { Text, ContactShadows } from '@react-three/drei'
+import * as THREE from 'three'
+import type { WorkspaceFolder, WorkspaceBoard } from '@/hooks/use-workspace'
+import { useHubWorldLighting } from '@/hooks/use-hub-world-lighting'
+import { GlbModel, INDOOR_PROPS, SMALL_PROPS, seededRandom, pickAsset, type AssetDef } from './hub-assets'
+
+/* ------------------------------------------------------------------ */
+/* Constants */
+/* ------------------------------------------------------------------ */
+
+const ROOM_SIZE = 8
+const ROOM_GAP = 0.4 // wall thickness
+const WALL_HEIGHT = 3
+const ROOMS_PER_ROW = 3
+const PERSON_RADIUS = 0.25
+const PERSON_HEIGHT = 1.2
+const PAN_SPEED = 0.02 // greatly reduced from 0.5
+const EDGE_PAN_ZONE = 60
+const EDGE_PAN_SPEED = 8
+const FLOOR_Y = 0.05 // raised to avoid z-fighting with ground
+
+/* ------------------------------------------------------------------ */
+/* Wall & Floor colors */
+/* ------------------------------------------------------------------ */
+
+const WALL_COLORS = [
+ '#e8dcc8', '#d4c4a8', '#c9b896', '#f0e6d3', '#ddd5c0',
+ '#b8c4d0', '#c4d4c0', '#d8c8c8', '#c8c8d8', '#d0d4c4'
+]
+
+const FLOOR_COLORS = [
+ '#c4a882', '#b89a70', '#d4b892', '#c0a478', '#b8946a',
+ '#8b7355', '#a0896b', '#cdb89c', '#bca888', '#d0c0a0'
+]
+
+/* ------------------------------------------------------------------ */
+/* Room decorations (GLB-based) */
+/* ------------------------------------------------------------------ */
+
+interface RoomDeco {
+ asset: AssetDef
+ position: [number, number, number]
+ rotation: [number, number, number]
+}
+
+function getRoomDecorations(roomIndex: number, roomSize: number): RoomDeco[] {
+ const rng = seededRandom(roomIndex * 137 + 42)
+ const hs = roomSize / 2 - 1.2
+ const decos: RoomDeco[] = []
+
+ // 3-5 random indoor props
+ const count = 3 + Math.floor(rng() * 3)
+ for (let i = 0; i < count; i++) {
+ const asset = pickAsset(INDOOR_PROPS, rng)
+ const x = (rng() - 0.5) * hs * 2
+ const z = (rng() - 0.5) * hs * 2
+ decos.push({
+ asset,
+ position: [x, 0, z],
+ rotation: [0, rng() * Math.PI * 2, 0]
+ })
+ }
+
+ // A small decoration in a corner
+ const corner = Math.floor(rng() * 4)
+ const cx = corner < 2 ? -hs : hs
+ const cz = corner % 2 === 0 ? -hs : hs
+ decos.push({
+ asset: pickAsset(SMALL_PROPS, rng),
+ position: [cx, 0, cz],
+ rotation: [0, rng() * Math.PI * 2, 0]
+ })
+
+ return decos
+}
+
+/* ------------------------------------------------------------------ */
+/* Room wall with optional door */
+/* ------------------------------------------------------------------ */
+
+interface WallProps {
+ position: [number, number, number]
+ size: [number, number, number]
+ color: string
+ hasDoor?: boolean
+ doorSide?: 'center'
+ onClick?: () => void
+ isSelected?: boolean
+}
+
+function Wall({ position, size, color, hasDoor, onClick, isSelected }: WallProps) {
+ if (hasDoor) {
+ const doorW = 1.5
+ const doorH = 2.2
+ const wallW = size[0]
+ const wallH = size[1]
+ const sideW = (wallW - doorW) / 2
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Person in Sims view */
+/* ------------------------------------------------------------------ */
+
+interface SimsPersonProps {
+ position: [number, number, number]
+ board: WorkspaceBoard
+ isHighlighted: boolean
+ onHover: (id: string | null) => void
+ onClick: (id: string) => void
+}
+
+function SimsPerson({ position, board, isHighlighted, onHover, onClick }: SimsPersonProps) {
+ const ref = useRef(null)
+
+ useFrame((state) => {
+ if (ref.current) {
+ ref.current.rotation.y = Math.sin(state.clock.elapsedTime * 1.5 + position[0] * 10) * 0.1
+ }
+ })
+
+ const bodyColor = isHighlighted ? '#4488ff' : '#6699cc'
+ const headColor = isHighlighted ? '#ffcc88' : '#ffddaa'
+
+ return (
+ { e.stopPropagation(); onHover(board.id) }}
+ onPointerOut={(e) => { e.stopPropagation(); onHover(null) }}
+ onClick={(e) => { e.stopPropagation(); onClick(board.id) }}
+ >
+
+
+
+
+
+
+
+
+
+ {board.name}
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Room component */
+/* ------------------------------------------------------------------ */
+
+interface RoomData {
+ folder: WorkspaceFolder
+ boards: WorkspaceBoard[]
+ gridX: number
+ gridZ: number
+}
+
+interface RoomProps {
+ data: RoomData
+ roomIndex: number
+ hoveredBoard: string | null
+ selectedWall: string | null
+ onHoverBoard: (id: string | null) => void
+ onClickBoard: (id: string) => void
+ onClickWall: (wallId: string) => void
+ wallColors: Record
+}
+
+function Room({ data, roomIndex, hoveredBoard, selectedWall, onHoverBoard, onClickBoard, onClickWall, wallColors }: RoomProps) {
+ const { folder, boards, gridX, gridZ } = data
+ const ox = gridX * (ROOM_SIZE + ROOM_GAP)
+ const oz = gridZ * (ROOM_SIZE + ROOM_GAP)
+ const hs = ROOM_SIZE / 2
+ const wh = WALL_HEIGHT / 2
+ const wt = ROOM_GAP
+
+ const decos = useMemo(() => getRoomDecorations(roomIndex, ROOM_SIZE), [roomIndex])
+
+ const wallId = (side: string) => `wall-${folder.id}-${side}`
+ const getWallColor = (side: string) => wallColors[wallId(side)] ?? WALL_COLORS[roomIndex % WALL_COLORS.length]
+ const floorColor = FLOOR_COLORS[roomIndex % FLOOR_COLORS.length]
+
+ return (
+
+ {/* Floor - raised above ground to avoid z-fighting */}
+
+
+
+
+
+ onClickWall(wallId('north'))}
+ isSelected={selectedWall === wallId('north')}
+ />
+ onClickWall(wallId('south'))}
+ isSelected={selectedWall === wallId('south')}
+ />
+ onClickWall(wallId('west'))}
+ isSelected={selectedWall === wallId('west')}
+ />
+ onClickWall(wallId('east'))}
+ isSelected={selectedWall === wallId('east')}
+ />
+
+
+ {folder.name}
+
+
+ {decos.map((d, i) => (
+
+ ))}
+
+ {boards.map((board, i) => {
+ const angle = (i / Math.max(boards.length, 1)) * Math.PI * 2
+ const radius = ROOM_SIZE * 0.25
+ const px = Math.cos(angle) * radius
+ const pz = Math.sin(angle) * radius
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Top-down camera controller */
+/* ------------------------------------------------------------------ */
+
+function SimsCameraController({ roomCount }: { roomCount: number }) {
+ const { camera, gl } = useThree()
+ const isDraggingRef = useRef(false)
+ const lastMouseRef = useRef({ x: 0, y: 0 })
+ const mouseScreenRef = useRef({ x: 0, y: 0 })
+ const targetRef = useRef(new THREE.Vector3()) // the point camera looks at on the ground
+ const distRef = useRef(20) // distance from target
+
+ useEffect(() => {
+ const cols = Math.min(roomCount, ROOMS_PER_ROW)
+ const rows = Math.ceil(Math.max(roomCount, 1) / ROOMS_PER_ROW)
+ const centerX = ((cols - 1) * (ROOM_SIZE + ROOM_GAP)) / 2
+ const centerZ = ((rows - 1) * (ROOM_SIZE + ROOM_GAP)) / 2
+ targetRef.current.set(centerX, 0, centerZ)
+
+ // Position camera looking down at ~45 degrees
+ const d = distRef.current
+ camera.position.set(centerX, d, centerZ + d * 0.6)
+ camera.lookAt(targetRef.current)
+
+ const handleMouseDown = (e: MouseEvent) => {
+ if (e.button === 0 || e.button === 1) {
+ isDraggingRef.current = true
+ lastMouseRef.current = { x: e.clientX, y: e.clientY }
+ }
+ }
+ const handleMouseUp = () => {
+ isDraggingRef.current = false
+ }
+ const handleMouseMove = (e: MouseEvent) => {
+ mouseScreenRef.current = { x: e.clientX, y: e.clientY }
+ if (isDraggingRef.current) {
+ const dx = e.clientX - lastMouseRef.current.x
+ const dy = e.clientY - lastMouseRef.current.y
+ // Scale drag speed by distance so it feels consistent at all zoom levels
+ const scale = PAN_SPEED * distRef.current
+ targetRef.current.x -= dx * scale
+ targetRef.current.z -= dy * scale
+ lastMouseRef.current = { x: e.clientX, y: e.clientY }
+ }
+ }
+ const handleWheel = (e: WheelEvent) => {
+ // Zoom toward/away from look target by changing distance
+ const zoomDelta = e.deltaY * 0.002 * distRef.current
+ distRef.current = Math.max(6, Math.min(60, distRef.current + zoomDelta))
+ }
+
+ const el = gl.domElement
+ el.addEventListener('mousedown', handleMouseDown)
+ window.addEventListener('mouseup', handleMouseUp)
+ window.addEventListener('mousemove', handleMouseMove)
+ el.addEventListener('wheel', handleWheel, { passive: true })
+
+ return () => {
+ el.removeEventListener('mousedown', handleMouseDown)
+ window.removeEventListener('mouseup', handleMouseUp)
+ window.removeEventListener('mousemove', handleMouseMove)
+ el.removeEventListener('wheel', handleWheel)
+ }
+ }, [camera, gl, roomCount])
+
+ useFrame((_, delta) => {
+ const rect = gl.domElement.getBoundingClientRect()
+ const mx = mouseScreenRef.current.x - rect.left
+ const my = mouseScreenRef.current.y - rect.top
+ const w = rect.width
+ const h = rect.height
+
+ // Edge-of-screen panning
+ if (!isDraggingRef.current) {
+ const speed = EDGE_PAN_SPEED * delta * (distRef.current / 20)
+ if (mx < EDGE_PAN_ZONE) targetRef.current.x -= speed * (1 - mx / EDGE_PAN_ZONE)
+ if (mx > w - EDGE_PAN_ZONE) targetRef.current.x += speed * (1 - (w - mx) / EDGE_PAN_ZONE)
+ if (my < EDGE_PAN_ZONE) targetRef.current.z -= speed * (1 - my / EDGE_PAN_ZONE)
+ if (my > h - EDGE_PAN_ZONE) targetRef.current.z += speed * (1 - (h - my) / EDGE_PAN_ZONE)
+ }
+
+ // Orbit camera at fixed angle around look target
+ const d = distRef.current
+ camera.position.set(
+ targetRef.current.x,
+ d,
+ targetRef.current.z + d * 0.6
+ )
+ camera.lookAt(targetRef.current)
+ })
+
+ return null
+}
+
+/* ------------------------------------------------------------------ */
+/* Wall color picker */
+/* ------------------------------------------------------------------ */
+
+const PALETTE = [
+ '#e8dcc8', '#d4c4a8', '#c9b896', '#f0e6d3', '#ddd5c0',
+ '#b8c4d0', '#c4d4c0', '#d8c8c8', '#c8c8d8', '#d0d4c4',
+ '#ff9999', '#99ccff', '#99ff99', '#ffcc99', '#cc99ff',
+ '#ffffff', '#333333', '#8B4513', '#FFD700', '#FF69B4'
+]
+
+interface WallPickerProps {
+ wallId: string
+ currentColor: string
+ onPickColor: (wallId: string, color: string) => void
+ onClose: () => void
+}
+
+function WallColorPicker({ wallId, currentColor, onPickColor, onClose }: WallPickerProps) {
+ return (
+
+
Wall Color
+
+ {PALETTE.map((color) => (
+ { onPickColor(wallId, color); onClose() }}
+ />
+ ))}
+
+
+ )
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Main Sims View */
+/* ------------------------------------------------------------------ */
+
+interface HubSimsViewProps {
+ folders: WorkspaceFolder[]
+ boards: WorkspaceBoard[]
+ onSelectBoard: (boardId: string) => void
+}
+
+export function HubSimsView({ folders, boards, onSelectBoard }: HubSimsViewProps) {
+ const [hoveredBoard, setHoveredBoard] = useState(null)
+ const [selectedWall, setSelectedWall] = useState(null)
+ const [wallColors, setWallColors] = useState>({})
+ const lighting = useHubWorldLighting()
+
+ const rooms = useMemo(() => {
+ const boardsByFolder = new Map()
+ for (const b of boards) {
+ const key = b.folderId ?? null
+ if (!boardsByFolder.has(key)) boardsByFolder.set(key, [])
+ boardsByFolder.get(key)!.push(b)
+ }
+
+ const result: RoomData[] = []
+ const sortedFolders = [...folders].sort((a, b) => a.sortOrder - b.sortOrder)
+
+ const ungrouped = boardsByFolder.get(null)
+ if (ungrouped && ungrouped.length > 0) {
+ result.push({
+ folder: { id: '__ungrouped__', name: 'Ungrouped', sortOrder: -1, createdAt: '', updatedAt: '' },
+ boards: ungrouped,
+ gridX: 0,
+ gridZ: 0
+ })
+ }
+
+ for (const folder of sortedFolders) {
+ result.push({
+ folder,
+ boards: boardsByFolder.get(folder.id) ?? [],
+ gridX: 0,
+ gridZ: 0
+ })
+ }
+
+ result.forEach((room, i) => {
+ room.gridX = i % ROOMS_PER_ROW
+ room.gridZ = Math.floor(i / ROOMS_PER_ROW)
+ })
+
+ return result
+ }, [folders, boards])
+
+ const handleClickBoard = useCallback((boardId: string) => {
+ onSelectBoard(boardId)
+ }, [onSelectBoard])
+
+ const handleClickWall = useCallback((wallId: string) => {
+ setSelectedWall((prev) => prev === wallId ? null : wallId)
+ }, [])
+
+ const handlePickColor = useCallback((wallId: string, color: string) => {
+ setWallColors((prev) => ({ ...prev, [wallId]: color }))
+ }, [])
+
+ const selectedWallColor = selectedWall ? (wallColors[selectedWall] ?? WALL_COLORS[0]) : ''
+
+ return (
+
+
+
+
+
+
+
+
+
+ {/* Ground plane - at y=0 */}
+
+
+
+
+
+ {/* Contact shadows for nice soft AO on the ground */}
+
+
+ {rooms.map((room, i) => (
+
+ ))}
+
+
+
+
+
+
Click + drag to pan · Scroll to zoom · Move mouse to edges to pan
+
Click walls to customize · Click people to open boards
+
+
+ {hoveredBoard && (
+
+ {boards.find((b) => b.id === hoveredBoard)?.name}
+
+ )}
+
+ {selectedWall && (
+
setSelectedWall(null)}
+ />
+ )}
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/hub-view.tsx b/apps/desktop/src/renderer/src/components/hub/hub-view.tsx
new file mode 100644
index 0000000..8e79511
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/hub-view.tsx
@@ -0,0 +1,132 @@
+import { useState } from 'react'
+import { LayoutGrid, Footprints, Home } from 'lucide-react'
+import { HubReactFlowView } from './hub-reactflow-view'
+import { VillageProvider, useVillage } from './village-context'
+import { VillageScene } from './village-scene'
+import { VillageDialog } from './village-dialog'
+import type { WorkspaceFolder, WorkspaceBoard } from '@/hooks/use-workspace'
+import type { CameraMode } from './village-types'
+
+type HubMode = 'canvas' | 'fps' | 'sims'
+
+interface HubViewProps {
+ folders: WorkspaceFolder[]
+ boards: WorkspaceBoard[]
+ onSelectBoard: (boardId: string) => void
+}
+
+function FPSHud() {
+ const { isDriving } = useVillage()
+
+ return (
+
+ {isDriving ? (
+ <>
+
W/S to accelerate and reverse · A/D to steer · Shift to boost · Space to brake
+
E to hop back out of the car
+ >
+ ) : (
+ <>
+
Click to lock mouse · WASD to move · Shift to run · Space to jump
+
Walk into door circles to enter · E to interact · G to grab/place · Esc to exit house
+
Find the town car and press E to drive it
+ >
+ )}
+
+ )
+}
+
+export function HubView({ folders, boards, onSelectBoard }: HubViewProps) {
+ const [mode, setMode] = useState('fps')
+
+ const is3D = mode === 'fps' || mode === 'sims'
+ const cameraMode: CameraMode = mode === 'sims' ? 'sims' : 'fps'
+
+ return (
+
+ {/* Mode switcher bar */}
+
+ setMode('canvas')}
+ title="Canvas view"
+ >
+
+ Canvas
+
+ setMode('fps')}
+ title="FPS town view"
+ >
+
+ Town
+
+ setMode('sims')}
+ title="Top-down view"
+ >
+
+ Overhead
+
+
+
+ {/* View content */}
+
+ {mode === 'canvas' && (
+
+ )}
+ {is3D && (
+
+
+
+ {/* FPS HUD */}
+ {mode === 'fps' && (
+ <>
+
+
+ >
+ )}
+
+ {/* Sims HUD */}
+ {mode === 'sims' && (
+
+
Click + drag to pan · Scroll to zoom · Move mouse to edges to pan
+
+ )}
+
+ {/* Interaction dialog overlay */}
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/procedural-clouds.tsx b/apps/desktop/src/renderer/src/components/hub/procedural-clouds.tsx
new file mode 100644
index 0000000..53eded9
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/procedural-clouds.tsx
@@ -0,0 +1,202 @@
+import { useRef, useMemo } from 'react'
+import { useFrame } from '@react-three/fiber'
+import * as THREE from 'three'
+
+const cloudVertexShader = /* glsl */ `
+varying vec3 vWorldPos;
+void main() {
+ vec4 worldPos = modelMatrix * vec4(position, 1.0);
+ vWorldPos = worldPos.xyz;
+ gl_Position = projectionMatrix * viewMatrix * worldPos;
+}
+`
+
+const cloudFragmentShader = /* glsl */ `
+uniform float uTime;
+uniform float uDaylight;
+uniform vec3 uSunDir;
+varying vec3 vWorldPos;
+
+// Simplex-style 3D noise
+vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
+vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
+vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
+vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
+
+float snoise(vec3 v) {
+ const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
+ const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
+ vec3 i = floor(v + dot(v, C.yyy));
+ vec3 x0 = v - i + dot(i, C.xxx);
+ vec3 g = step(x0.yzx, x0.xyz);
+ vec3 l = 1.0 - g;
+ vec3 i1 = min(g.xyz, l.zxy);
+ vec3 i2 = max(g.xyz, l.zxy);
+ vec3 x1 = x0 - i1 + C.xxx;
+ vec3 x2 = x0 - i2 + C.yyy;
+ vec3 x3 = x0 - D.yyy;
+ i = mod289(i);
+ vec4 p = permute(permute(permute(
+ i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ + i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ + i.x + vec4(0.0, i1.x, i2.x, 1.0));
+ float n_ = 0.142857142857;
+ vec3 ns = n_ * D.wyz - D.xzx;
+ vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
+ vec4 x_ = floor(j * ns.z);
+ vec4 y_ = floor(j - 7.0 * x_);
+ vec4 x = x_ * ns.x + ns.yyyy;
+ vec4 y = y_ * ns.x + ns.yyyy;
+ vec4 h = 1.0 - abs(x) - abs(y);
+ vec4 b0 = vec4(x.xy, y.xy);
+ vec4 b1 = vec4(x.zw, y.zw);
+ vec4 s0 = floor(b0) * 2.0 + 1.0;
+ vec4 s1 = floor(b1) * 2.0 + 1.0;
+ vec4 sh = -step(h, vec4(0.0));
+ vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
+ vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
+ vec3 p0 = vec3(a0.xy, h.x);
+ vec3 p1 = vec3(a0.zw, h.y);
+ vec3 p2 = vec3(a1.xy, h.z);
+ vec3 p3 = vec3(a1.zw, h.w);
+ vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
+ p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
+ vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
+ m = m * m;
+ return 42.0 * dot(m * m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
+}
+
+float fbm(vec3 p) {
+ float v = 0.0;
+ float a = 0.5;
+ vec3 shift = vec3(100.0);
+ for (int i = 0; i < 5; i++) {
+ v += a * snoise(p);
+ p = p * 2.0 + shift;
+ a *= 0.5;
+ }
+ return v;
+}
+
+float hash21(vec2 p) {
+ p = fract(p * vec2(123.34, 456.21));
+ p += dot(p, p + 45.32);
+ return fract(p.x * p.y);
+}
+
+float starField(vec3 dir) {
+ vec2 starUv = vec2(
+ atan(dir.z, dir.x) / 6.28318530718 + 0.5,
+ acos(clamp(dir.y, -1.0, 1.0)) / 3.14159265359
+ );
+
+ vec2 grid = vec2(260.0, 140.0);
+ vec2 cell = starUv * grid;
+ vec2 id = floor(cell);
+ vec2 local = fract(cell) - 0.5;
+ float rnd = hash21(id);
+
+ float starMask = step(0.9972, rnd);
+ float core = smoothstep(0.085, 0.0, length(local));
+ float twinkle = 0.72 + 0.28 * sin(uTime * 1.7 + rnd * 130.0);
+ float star = starMask * core * twinkle;
+
+ float bigMask = step(0.99935, rnd);
+ float cross = max(
+ smoothstep(0.02, 0.0, abs(local.x)) * smoothstep(0.18, 0.0, abs(local.y)),
+ smoothstep(0.02, 0.0, abs(local.y)) * smoothstep(0.18, 0.0, abs(local.x))
+ );
+ star += bigMask * cross * 0.9 * twinkle;
+
+ return star * smoothstep(0.08, 0.35, dir.y);
+}
+
+void main() {
+ // Normalize the direction from origin to the dome vertex
+ vec3 dir = normalize(vWorldPos);
+
+ // Only render clouds above the horizon
+ float horizonFade = smoothstep(0.0, 0.15, dir.y);
+ if (horizonFade <= 0.0) discard;
+
+ // Project onto a flat plane for cloud UVs (dome -> flat mapping)
+ vec2 uv = dir.xz / (dir.y + 0.001) * 0.04;
+
+ // Animate wind drift
+ float t = uTime * 0.012;
+ vec3 pos = vec3(uv.x + t, uv.y + t * 0.3, t * 0.5);
+
+ // Layer noise for cloud shapes
+ float n = fbm(pos * 1.5);
+ float n2 = fbm(pos * 3.0 + vec3(5.3, 1.2, 3.7));
+
+ // Cloud density with sharp cutoff for puffy shapes
+ float density = smoothstep(0.1, 0.55, n * 0.5 + 0.5);
+ density *= smoothstep(0.05, 0.4, n2 * 0.5 + 0.5);
+
+ // Sun-facing brightness
+ float sunDot = max(dot(dir, normalize(uSunDir)), 0.0);
+ float sunGlow = pow(sunDot, 8.0) * 0.3;
+
+ // Shift cloud color and opacity toward a dimmer moonlit look at night.
+ vec3 cloudBase = mix(vec3(0.24, 0.29, 0.38), vec3(1.0, 1.0, 1.0), uDaylight);
+ vec3 cloudShadow = mix(vec3(0.07, 0.10, 0.16), vec3(0.75, 0.78, 0.85), uDaylight);
+ vec3 cloudColor = mix(cloudShadow, cloudBase, density * mix(0.4, 0.7, uDaylight) + sunGlow);
+ cloudColor += vec3(1.0, 0.9, 0.7) * sunGlow * uDaylight;
+
+ float night = 1.0 - uDaylight;
+ float cloudAlpha = density * horizonFade * mix(0.25, 0.85, uDaylight);
+ float stars = starField(dir) * night * (1.0 - density * 0.92);
+ vec3 starColor = vec3(0.88, 0.92, 1.0);
+ float starAlpha = stars * 0.95;
+
+ vec3 premultiplied = starColor * starAlpha * (1.0 - cloudAlpha) + cloudColor * cloudAlpha;
+ float alpha = starAlpha * (1.0 - cloudAlpha) + cloudAlpha;
+ if (alpha < 0.001) discard;
+
+ gl_FragColor = vec4(premultiplied / max(alpha, 0.0001), alpha);
+}
+`
+
+interface ProceduralCloudsProps {
+ sunPosition?: [number, number, number]
+ daylight?: number
+}
+
+export function ProceduralClouds({
+ sunPosition = [100, 20, 100],
+ daylight = 1
+}: ProceduralCloudsProps) {
+ const meshRef = useRef(null)
+
+ const uniforms = useMemo(() => ({
+ uTime: { value: 0 },
+ uDaylight: { value: daylight },
+ uSunDir: { value: new THREE.Vector3(...sunPosition).normalize() }
+ }), [])
+
+ useFrame((state) => {
+ uniforms.uTime.value = state.clock.elapsedTime
+ uniforms.uDaylight.value = daylight
+ uniforms.uSunDir.value.set(...sunPosition).normalize()
+ // Clouds follow the camera horizontally so they never run out
+ if (meshRef.current) {
+ const cam = state.camera.position
+ meshRef.current.position.set(cam.x, 0, cam.z)
+ }
+ })
+
+ return (
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/sims-camera-controller.tsx b/apps/desktop/src/renderer/src/components/hub/sims-camera-controller.tsx
new file mode 100644
index 0000000..f687707
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/sims-camera-controller.tsx
@@ -0,0 +1,205 @@
+import { useRef, useEffect } from 'react'
+import { useFrame, useThree } from '@react-three/fiber'
+import * as THREE from 'three'
+import { useVillage } from './village-context'
+
+const PAN_SPEED = 0.004
+const EDGE_PAN_ZONE = 60
+const EDGE_PAN_SPEED = 8
+const CLICK_DISTANCE_THRESHOLD = 4 // mouse must not move more than 4px to count as click
+const DOOR_OFFSET_Z = 5
+
+type ClickTarget = {
+ id: string
+ pos: THREE.Vector3
+ action: 'enter-house' | 'exit-house' | 'use-board'
+ boardId: string
+}
+
+export function SimsCameraController() {
+ const { camera, gl } = useThree()
+ const { neighborhoods, location, enterHouse, exitHouse, interact } = useVillage()
+ const isDraggingRef = useRef(false)
+ const lastMouseRef = useRef({ x: 0, y: 0 })
+ const mouseDownRef = useRef({ x: 0, y: 0 })
+ const mouseScreenRef = useRef({ x: 0, y: 0 })
+ const targetRef = useRef(new THREE.Vector3())
+ const distRef = useRef(30)
+
+ // Click targets for raycast-based interaction
+ const clickTargetsRef = useRef([])
+ useEffect(() => {
+ if (location.type === 'outdoor') {
+ clickTargetsRef.current = neighborhoods.flatMap((n) =>
+ n.houses.map((h) => {
+ const cos = Math.cos(h.worldRotation)
+ const sin = Math.sin(h.worldRotation)
+ const dx = DOOR_OFFSET_Z * sin
+ const dz = DOOR_OFFSET_Z * cos
+ return {
+ id: `door-${h.id}`,
+ pos: new THREE.Vector3(h.worldPosition[0] + dx, 0, h.worldPosition[2] + dz),
+ action: 'enter-house' as const,
+ boardId: h.id
+ }
+ })
+ )
+ } else {
+ const items: ClickTarget[] = [
+ { id: 'exit-door', pos: new THREE.Vector3(0, 0, 6.5), action: 'exit-house', boardId: '' },
+ { id: 'interior-agent-0', pos: new THREE.Vector3(2, 0, 2), action: 'use-board', boardId: location.boardId },
+ ]
+ clickTargetsRef.current = items
+ // Load desk targets from board data
+ Promise.all([
+ window.api.browserTabs.list(location.boardId) as Promise<{ id: string }[]>,
+ window.api.graphNodes.list(location.boardId) as Promise<{ id: string; nodeType: string }[]>
+ ]).then(([tabs, nodes]) => {
+ const allIds = [
+ ...tabs.map((t) => t.id),
+ ...nodes.filter((n) => n.nodeType === 'terminal').map((n) => n.id)
+ ]
+ // Reproduce the same seeded random placement as house-interior.tsx
+ let seed = 0
+ for (let ci = 0; ci < location.boardId.length; ci++) seed = ((seed << 5) - seed + location.boardId.charCodeAt(ci)) | 0
+ seed = Math.abs(seed)
+ const rng = () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646 }
+ const placed: [number, number][] = []
+ const hs = 8, margin = 2.5
+ const updated = [...items]
+ allIds.forEach((itemId) => {
+ for (let attempt = 0; attempt < 20; attempt++) {
+ const x = (rng() - 0.5) * (16 - margin * 2 - 2)
+ const z = -hs + margin + rng() * (16 - margin * 2 - 4)
+ const tooClose = placed.some(([px, pz]) => Math.hypot(x - px, z - pz) < 2.8)
+ if (!tooClose) {
+ placed.push([x, z])
+ updated.push({ id: `desk-${itemId}`, pos: new THREE.Vector3(x, 0, z), action: 'use-board', boardId: location.boardId })
+ break
+ }
+ }
+ rng() // consume rotation to stay in sync with house-interior.tsx
+ })
+ clickTargetsRef.current = updated
+ }).catch(() => {})
+ }
+ }, [neighborhoods, location])
+
+ useEffect(() => {
+ if (location.type === 'indoor') {
+ targetRef.current.set(0, 0, 0)
+ distRef.current = 12
+ } else if (neighborhoods.length > 0) {
+ let cx = 0, cz = 0, count = 0
+ for (const n of neighborhoods) { cx += n.position[0]; cz += n.position[2]; count++ }
+ targetRef.current.set(cx / count, 0, cz / count)
+ distRef.current = 30 + neighborhoods.length * 3
+ }
+
+ const d = distRef.current
+ camera.position.set(targetRef.current.x, d, targetRef.current.z + d * 0.6)
+ camera.lookAt(targetRef.current)
+
+ const handleMouseDown = (e: MouseEvent) => {
+ if (e.button === 0 || e.button === 1) {
+ isDraggingRef.current = true
+ lastMouseRef.current = { x: e.clientX, y: e.clientY }
+ mouseDownRef.current = { x: e.clientX, y: e.clientY }
+ }
+ }
+ const handleMouseUp = (e: MouseEvent) => {
+ const wasDrag = isDraggingRef.current
+ isDraggingRef.current = false
+
+ // Check if this was a click (not a drag)
+ if (wasDrag) {
+ const dx = e.clientX - mouseDownRef.current.x
+ const dy = e.clientY - mouseDownRef.current.y
+ const dist = Math.sqrt(dx * dx + dy * dy)
+ if (dist < CLICK_DISTANCE_THRESHOLD) {
+ handleClick(e)
+ }
+ }
+ }
+ const handleMouseMove = (e: MouseEvent) => {
+ mouseScreenRef.current = { x: e.clientX, y: e.clientY }
+ if (isDraggingRef.current) {
+ const dx = e.clientX - lastMouseRef.current.x
+ const dy = e.clientY - lastMouseRef.current.y
+ const scale = PAN_SPEED * distRef.current
+ targetRef.current.x -= dx * scale
+ targetRef.current.z -= dy * scale
+ lastMouseRef.current = { x: e.clientX, y: e.clientY }
+ }
+ }
+ const handleWheel = (e: WheelEvent) => {
+ const zoomDelta = e.deltaY * 0.002 * distRef.current
+ distRef.current = Math.max(8, Math.min(100, distRef.current + zoomDelta))
+ }
+
+ // Click: raycast to ground plane, find nearest click target
+ const handleClick = (e: MouseEvent) => {
+ const rect = gl.domElement.getBoundingClientRect()
+ const mouse = new THREE.Vector2(
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
+ -((e.clientY - rect.top) / rect.height) * 2 + 1
+ )
+ const raycaster = new THREE.Raycaster()
+ raycaster.setFromCamera(mouse, camera)
+
+ // Intersect with ground plane (y=0)
+ const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
+ const hitPoint = new THREE.Vector3()
+ raycaster.ray.intersectPlane(groundPlane, hitPoint)
+
+ if (hitPoint) {
+ // Find nearest click target to the hit point
+ let nearest: ClickTarget | null = null
+ let nearestDist = 6 // max click range on ground
+ for (const ct of clickTargetsRef.current) {
+ const d = hitPoint.distanceTo(ct.pos)
+ if (d < nearestDist) { nearestDist = d; nearest = ct }
+ }
+ if (nearest) {
+ if (nearest.action === 'enter-house') enterHouse(nearest.boardId)
+ else if (nearest.action === 'exit-house') exitHouse()
+ else if (nearest.action === 'use-board') interact(nearest.id, 'board', nearest.boardId)
+ }
+ }
+ }
+
+ const el = gl.domElement
+ el.addEventListener('mousedown', handleMouseDown)
+ window.addEventListener('mouseup', handleMouseUp)
+ window.addEventListener('mousemove', handleMouseMove)
+ el.addEventListener('wheel', handleWheel, { passive: true })
+ return () => {
+ el.removeEventListener('mousedown', handleMouseDown)
+ window.removeEventListener('mouseup', handleMouseUp)
+ window.removeEventListener('mousemove', handleMouseMove)
+ el.removeEventListener('wheel', handleWheel)
+ }
+ }, [camera, gl, neighborhoods, location, enterHouse, exitHouse, interact])
+
+ useFrame((_, delta) => {
+ const rect = gl.domElement.getBoundingClientRect()
+ const mx = mouseScreenRef.current.x - rect.left
+ const my = mouseScreenRef.current.y - rect.top
+ const w = rect.width
+ const h = rect.height
+
+ if (!isDraggingRef.current) {
+ const speed = EDGE_PAN_SPEED * delta * (distRef.current / 20)
+ if (mx < EDGE_PAN_ZONE) targetRef.current.x -= speed * (1 - mx / EDGE_PAN_ZONE)
+ if (mx > w - EDGE_PAN_ZONE) targetRef.current.x += speed * (1 - (w - mx) / EDGE_PAN_ZONE)
+ if (my < EDGE_PAN_ZONE) targetRef.current.z -= speed * (1 - my / EDGE_PAN_ZONE)
+ if (my > h - EDGE_PAN_ZONE) targetRef.current.z += speed * (1 - (h - my) / EDGE_PAN_ZONE)
+ }
+
+ const d = distRef.current
+ camera.position.set(targetRef.current.x, d, targetRef.current.z + d * 0.6)
+ camera.lookAt(targetRef.current)
+ })
+
+ return null
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/use-village-layout.ts b/apps/desktop/src/renderer/src/components/hub/use-village-layout.ts
new file mode 100644
index 0000000..e67bf47
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/use-village-layout.ts
@@ -0,0 +1,263 @@
+import { useMemo } from 'react'
+import type { WorkspaceFolder, WorkspaceBoard } from '@/hooks/use-workspace'
+import type { VillageNeighborhood, VillageHouse, SceneProp } from './village-types'
+import {
+ HOUSE_ASSETS, TREE_ASSETS, OUTDOOR_PROPS, SMALL_PROPS,
+ seededRandom, pickAsset
+} from './hub-assets'
+
+/* ------------------------------------------------------------------ */
+/* Layout constants */
+/* ------------------------------------------------------------------ */
+
+const CUL_DE_SAC_RADIUS = 12
+const STREET_SPACING_Z = 28 // tighter spacing between side streets
+const SIDE_STREET_LENGTH = 18
+const MAIN_ROAD_WIDTH = 5
+const SIDE_ROAD_WIDTH = 3.5
+const HOUSES_PER_RING = 6
+const MEANDER_AMP = 6 // how much the main road wobbles side to side
+const MEANDER_FREQ = 0.08 // how fast the wobble oscillates
+
+/* ------------------------------------------------------------------ */
+/* Road segment — now supports curved roads via multiple segments */
+/* ------------------------------------------------------------------ */
+
+export interface RoadSegment {
+ cx: number
+ cz: number
+ width: number
+ length: number
+ angle: number
+}
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+function hashStr(s: string): number {
+ let h = 0
+ for (let i = 0; i < s.length; i++) {
+ h = ((h << 5) - h + s.charCodeAt(i)) | 0
+ }
+ return Math.abs(h)
+}
+
+/** Get the main road X offset at a given Z (meandering curve) */
+function mainRoadX(z: number): number {
+ return Math.sin(z * MEANDER_FREQ) * MEANDER_AMP + Math.sin(z * MEANDER_FREQ * 2.3 + 1.5) * MEANDER_AMP * 0.4
+}
+
+/* ------------------------------------------------------------------ */
+/* Layout */
+/* ------------------------------------------------------------------ */
+
+function layoutNeighborhoods(
+ folders: WorkspaceFolder[],
+ boards: WorkspaceBoard[]
+): { neighborhoods: VillageNeighborhood[]; roads: RoadSegment[] } {
+ const boardsByFolder = new Map()
+ for (const b of boards) {
+ const key = b.folderId ?? null
+ if (!boardsByFolder.has(key)) boardsByFolder.set(key, [])
+ boardsByFolder.get(key)!.push(b)
+ }
+
+ const sorted = [...folders].sort((a, b) => a.sortOrder - b.sortOrder)
+ const folderEntries: { folder: WorkspaceFolder; boards: WorkspaceBoard[] }[] = []
+ const ungrouped = boardsByFolder.get(null)
+ if (ungrouped && ungrouped.length > 0) {
+ folderEntries.push({
+ folder: { id: '__ungrouped__', name: 'Town Square', sortOrder: -1, createdAt: '', updatedAt: '' },
+ boards: ungrouped
+ })
+ }
+ for (const f of sorted) {
+ folderEntries.push({ folder: f, boards: boardsByFolder.get(f.id) ?? [] })
+ }
+
+ const neighborhoods: VillageNeighborhood[] = []
+ const roads: RoadSegment[] = []
+
+ const totalStreets = folderEntries.length
+ const mainRoadStartZ = 15
+ const mainRoadEndZ = mainRoadStartZ - Math.max(totalStreets, 1) * STREET_SPACING_Z - 10
+
+ // Build meandering main road from segments
+ const segLen = 4
+ for (let z = mainRoadStartZ; z > mainRoadEndZ; z -= segLen) {
+ const z1 = z
+ const z2 = z - segLen
+ const x1 = mainRoadX(z1)
+ const x2 = mainRoadX(z2)
+ const dx = x2 - x1
+ const dz = z2 - z1
+ const len = Math.sqrt(dx * dx + dz * dz)
+ const angle = Math.atan2(dx, dz)
+ roads.push({
+ cx: (x1 + x2) / 2,
+ cz: (z1 + z2) / 2,
+ width: MAIN_ROAD_WIDTH,
+ length: len + 0.5, // slight overlap to avoid gaps
+ angle
+ })
+ }
+
+ // Place neighborhoods
+ for (let i = 0; i < folderEntries.length; i++) {
+ const { folder, boards: folderBoards } = folderEntries[i]
+
+ const side = i % 2 === 0 ? -1 : 1
+ const streetZ = mainRoadStartZ - (i + 0.5) * STREET_SPACING_Z
+ const roadX = mainRoadX(streetZ)
+
+ // Cul-de-sac center offset from the road
+ const culX = roadX + side * (SIDE_STREET_LENGTH + CUL_DE_SAC_RADIUS * 0.5)
+ const culZ = streetZ
+
+ // Side street connecting main road to cul-de-sac
+ const sideStartX = roadX + side * (MAIN_ROAD_WIDTH / 2)
+ const sideEndX = culX - side * (CUL_DE_SAC_RADIUS * 0.3)
+ const sideCx = (sideStartX + sideEndX) / 2
+ const sideLen = Math.abs(sideEndX - sideStartX)
+ roads.push({
+ cx: sideCx,
+ cz: streetZ,
+ width: SIDE_ROAD_WIDTH,
+ length: sideLen,
+ angle: Math.PI / 2
+ })
+
+ // Cul-de-sac opening faces back toward main road
+ const nRot = side > 0 ? Math.PI : 0
+
+ const houseCount = folderBoards.length
+ const radius = CUL_DE_SAC_RADIUS + Math.max(0, Math.floor((houseCount - HOUSES_PER_RING) / HOUSES_PER_RING)) * 5
+ const rng = seededRandom(hashStr(folder.id))
+
+ const houses: VillageHouse[] = folderBoards
+ .sort((a, b) => a.sortOrder - b.sortOrder)
+ .map((board, hi) => {
+ // Arc on the back half only (away from the road entrance)
+ // Spans ~140° centered on the back, leaving the front wide open for the road
+ const arcSpan = Math.PI * 0.75
+ const arcStart = -arcSpan / 2
+ const angle = houseCount === 1 ? 0 : arcStart + (hi / (houseCount - 1)) * arcSpan
+ // Houses sit on the far side: localZ is negative (back of cul-de-sac)
+ const localX = Math.sin(angle) * radius
+ const localZ = -Math.cos(angle) * radius
+ const cosR = Math.cos(nRot)
+ const sinR = Math.sin(nRot)
+ const wx = culX + localX * cosR - localZ * sinR
+ const wz = culZ + localX * sinR + localZ * cosR
+
+ // Face toward the cul-de-sac center
+ const toCenterX = culX - wx
+ const toCenterZ = culZ - wz
+ const faceAngle = Math.atan2(toCenterX, toCenterZ)
+
+ return {
+ id: board.id,
+ board,
+ neighborhoodId: folder.id,
+ worldPosition: [wx, 0, wz] as [number, number, number],
+ worldRotation: faceAngle,
+ houseAsset: pickAsset(HOUSE_ASSETS, rng)
+ }
+ })
+
+ // Arch at the main road junction — where side street starts
+ const archX = sideStartX + side * 2
+ const archZ = streetZ
+ // Arch spans across the side street which runs along X
+ const archRot = Math.PI / 2
+
+ neighborhoods.push({
+ id: folder.id,
+ folder,
+ position: [culX, 0, culZ],
+ rotation: nRot,
+ archPosition: [archX, 0, archZ],
+ archRotation: archRot,
+ houses
+ })
+ }
+
+ return { neighborhoods, roads }
+}
+
+/* ------------------------------------------------------------------ */
+/* Procedural scenery */
+/* ------------------------------------------------------------------ */
+
+function generateScenery(neighborhoods: VillageNeighborhood[]): SceneProp[] {
+ const rng = seededRandom(12345)
+ const props: SceneProp[] = []
+
+ let minX = -40, maxX = 40, minZ = -40, maxZ = 40
+ for (const n of neighborhoods) {
+ for (const h of n.houses) {
+ minX = Math.min(minX, h.worldPosition[0] - 12)
+ maxX = Math.max(maxX, h.worldPosition[0] + 12)
+ minZ = Math.min(minZ, h.worldPosition[2] - 12)
+ maxZ = Math.max(maxZ, h.worldPosition[2] + 12)
+ }
+ }
+
+ const housePositions = neighborhoods.flatMap((n) => n.houses.map((h) => h.worldPosition))
+ const neighborhoodCenters = neighborhoods.map((n) => n.position)
+
+ function isTooClose(x: number, z: number, minDist: number): boolean {
+ if (housePositions.some((hp) => Math.hypot(x - hp[0], z - hp[2]) < minDist)) return true
+ if (neighborhoodCenters.some((nc) => Math.hypot(x - nc[0], z - nc[2]) < minDist * 1.2)) return true
+ return false
+ }
+
+ for (let i = 0; i < 80; i++) {
+ const x = minX + rng() * (maxX - minX)
+ const z = minZ + rng() * (maxZ - minZ)
+ if (!isTooClose(x, z, 7)) {
+ props.push({ asset: pickAsset(TREE_ASSETS, rng), position: [x, 0, z], rotation: [0, rng() * Math.PI * 2, 0] })
+ }
+ }
+
+ for (const n of neighborhoods) {
+ const count = 2 + Math.floor(rng() * 3)
+ for (let j = 0; j < count; j++) {
+ const ox = n.position[0] + (rng() - 0.5) * 22
+ const oz = n.position[2] + (rng() - 0.5) * 16
+ if (!isTooClose(ox, oz, 5)) {
+ props.push({ asset: pickAsset(OUTDOOR_PROPS, rng), position: [ox, 0, oz], rotation: [0, rng() * Math.PI * 2, 0] })
+ }
+ }
+ }
+
+ for (let i = 0; i < 80; i++) {
+ const x = minX + rng() * (maxX - minX)
+ const z = minZ + rng() * (maxZ - minZ)
+ if (!isTooClose(x, z, 3)) {
+ props.push({ asset: pickAsset(SMALL_PROPS, rng), position: [x, 0, z], rotation: [0, rng() * Math.PI * 2, 0] })
+ }
+ }
+
+ const streetlight = OUTDOOR_PROPS.find((p) => p.file === 'streetlight.glb')!
+ for (const n of neighborhoods) {
+ const cos = Math.cos(n.rotation)
+ const sin = Math.sin(n.rotation)
+ const d = CUL_DE_SAC_RADIUS + 3
+ props.push({ asset: streetlight, position: [n.position[0] + sin * d + cos * 2.5, 0, n.position[2] + cos * d - sin * 2.5], rotation: [0, n.rotation, 0] })
+ props.push({ asset: streetlight, position: [n.position[0] + sin * d - cos * 2.5, 0, n.position[2] + cos * d + sin * 2.5], rotation: [0, n.rotation + Math.PI, 0] })
+ }
+
+ return props
+}
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+export function useVillageLayout(folders: WorkspaceFolder[], boards: WorkspaceBoard[]) {
+ const { neighborhoods, roads } = useMemo(() => layoutNeighborhoods(folders, boards), [folders, boards])
+ const scenery = useMemo(() => generateScenery(neighborhoods), [neighborhoods])
+ return { neighborhoods, scenery, roads }
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/village-context.tsx b/apps/desktop/src/renderer/src/components/hub/village-context.tsx
new file mode 100644
index 0000000..0812efc
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/village-context.tsx
@@ -0,0 +1,407 @@
+import { createContext, useContext, useState, useCallback, useMemo, useEffect, useRef, type MutableRefObject } from 'react'
+import type { WorkspaceFolder, WorkspaceBoard } from '@/hooks/use-workspace'
+import type {
+ VillageNeighborhood, VillageLocation, CameraMode,
+ ActiveDialog, DecorationPlacement, SceneProp,
+ ObjectPositionOverride, VillageData, DrivableCarData, DrivableCarState
+} from './village-types'
+import { useVillageLayout, type RoadSegment } from './use-village-layout'
+
+/* ------------------------------------------------------------------ */
+/* Persistent village data (workspace JSON file) */
+/* ------------------------------------------------------------------ */
+
+const VILLAGE_DATA_FILE = '.village-data.json'
+
+async function getVillageDataPath(): Promise {
+ const state = await window.api.workspace.getState()
+ return state.rootDir + '/' + VILLAGE_DATA_FILE
+}
+
+async function loadVillageData(): Promise {
+ try {
+ const path = await getVillageDataPath()
+ const raw = await window.api.graphNodes.readFile(path)
+ return JSON.parse(raw) as VillageData
+ } catch {
+ return { objectPositions: {} }
+ }
+}
+
+async function saveVillageData(data: VillageData): Promise {
+ try {
+ const path = await getVillageDataPath()
+ await window.api.graphNodes.writeFile(path, JSON.stringify(data, null, 2), 'overwrite')
+ } catch (e) {
+ console.warn('Failed to save village data:', e)
+ }
+}
+
+function getDefaultCarData(roads: RoadSegment[]): DrivableCarData {
+ const firstRoad = roads[0]
+ if (!firstRoad) {
+ return { position: [2, 0, 12], rotation: 0 }
+ }
+
+ const rotation = firstRoad.angle - Math.PI
+ const rightX = Math.cos(rotation)
+ const rightZ = -Math.sin(rotation)
+
+ return {
+ position: [
+ firstRoad.cx + rightX * 1.7,
+ 0,
+ firstRoad.cz + rightZ * 1.7
+ ],
+ rotation
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Persistent player position (Electron settings) */
+/* ------------------------------------------------------------------ */
+
+const PLAYER_POS_KEY = 'hub-player-position'
+
+export interface PersistedPlayerPos {
+ x: number
+ y: number
+ z: number
+ yaw: number
+ pitch: number
+ locationType: 'outdoor' | 'indoor'
+ boardId?: string
+}
+
+async function loadPlayerPosition(): Promise {
+ try {
+ const val = await window.api.settings.get(PLAYER_POS_KEY)
+ return val as PersistedPlayerPos | null
+ } catch {
+ return null
+ }
+}
+
+async function savePlayerPosition(pos: PersistedPlayerPos): Promise {
+ try {
+ await window.api.settings.set(PLAYER_POS_KEY, pos)
+ } catch {
+ // ignore
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Context shape */
+/* ------------------------------------------------------------------ */
+
+interface VillageState {
+ neighborhoods: VillageNeighborhood[]
+ scenery: SceneProp[]
+ roads: RoadSegment[]
+ location: VillageLocation
+ cameraMode: CameraMode
+ hoveredId: string | null
+ activeDialog: ActiveDialog | null
+ decorations: DecorationPlacement[]
+ isDecoMode: boolean
+
+ enterHouse: (boardId: string) => void
+ exitHouse: () => void
+ setHoveredId: (id: string | null) => void
+ interact: (id: string, kind: ActiveDialog['kind'], boardId: string) => void
+ closeDialog: () => void
+ toggleDecoMode: () => void
+ addDecoration: (d: DecorationPlacement) => void
+ moveDecoration: (id: string, pos: [number, number, number]) => void
+ rotateDecoration: (id: string, rot: [number, number, number]) => void
+ deleteDecoration: (id: string) => void
+
+ savedCameraPos: { x: number; y: number; z: number; yaw: number } | null
+ saveCameraPos: (pos: { x: number; y: number; z: number; yaw: number }) => void
+
+ // Grab system
+ grabbedObjectId: string | null
+ objectPositions: Record
+ grabObject: (id: string) => void
+ placeObject: (position: [number, number, number], rotation: number) => void
+ updateGrabbedPosition: (position: [number, number, number]) => void
+
+ // Persistent player position
+ persistedPlayerPos: PersistedPlayerPos | null
+ persistPlayerPos: (pos: PersistedPlayerPos) => void
+
+ // Drivable car
+ carStateRef: MutableRefObject
+ isDriving: boolean
+ enterCar: () => void
+ exitCar: () => void
+ persistCarTransform: () => void
+
+ // Board items refresh (bumped when dialog closes to re-fetch screenshots)
+ boardItemsVersion: number
+
+ folders: WorkspaceFolder[]
+ boards: WorkspaceBoard[]
+ onSelectBoard: (boardId: string) => void
+}
+
+const VillageContext = createContext(null)
+
+export function useVillage(): VillageState {
+ const ctx = useContext(VillageContext)
+ if (!ctx) throw new Error('useVillage must be inside VillageProvider')
+ return ctx
+}
+
+const DECO_KEY = 'hub-village-decorations'
+
+function loadDecorations(): DecorationPlacement[] {
+ try {
+ const raw = localStorage.getItem(DECO_KEY)
+ return raw ? JSON.parse(raw) : []
+ } catch {
+ return []
+ }
+}
+
+function saveDecorations(decos: DecorationPlacement[]) {
+ localStorage.setItem(DECO_KEY, JSON.stringify(decos))
+}
+
+interface VillageProviderProps {
+ folders: WorkspaceFolder[]
+ boards: WorkspaceBoard[]
+ cameraMode: CameraMode
+ onSelectBoard: (boardId: string) => void
+ children: React.ReactNode
+}
+
+export function VillageProvider({ folders, boards, cameraMode: cameraModeFromProps, onSelectBoard, children }: VillageProviderProps) {
+ const { neighborhoods, scenery, roads } = useVillageLayout(folders, boards)
+ const [location, setLocation] = useState({ type: 'outdoor' })
+ const cameraMode = cameraModeFromProps
+ const [hoveredId, setHoveredId] = useState(null)
+ const [activeDialog, setActiveDialog] = useState(null)
+ const [decorations, setDecorations] = useState(loadDecorations)
+ const [isDecoMode, setIsDecoMode] = useState(false)
+ const [savedCameraPos, setSavedCameraPos] = useState<{ x: number; y: number; z: number; yaw: number } | null>(null)
+
+ // Object position overrides
+ const [objectPositions, setObjectPositions] = useState>({})
+ const [grabbedObjectId, setGrabbedObjectId] = useState(null)
+ const villageDataRef = useRef({ objectPositions: {} })
+
+ // Board items version — bumped when dialog closes to trigger re-fetch of screenshots
+ const [boardItemsVersion, setBoardItemsVersion] = useState(0)
+
+ // Persistent player position
+ const [persistedPlayerPos, setPersistedPlayerPos] = useState(null)
+ const playerPosSaveTimer = useRef | null>(null)
+
+ // Drivable car
+ const defaultCarData = useMemo(() => getDefaultCarData(roads), [roads])
+ const carStateRef = useRef({ ...defaultCarData, speed: 0 })
+ const [isDriving, setIsDriving] = useState(false)
+ const hasLoadedSavedCar = useRef(false)
+ const carSaveTimer = useRef | null>(null)
+
+ // Load village data on mount
+ useEffect(() => {
+ loadVillageData().then((data) => {
+ villageDataRef.current = data
+ setObjectPositions(data.objectPositions)
+ if (data.car) {
+ hasLoadedSavedCar.current = true
+ carStateRef.current.position = [...data.car.position] as [number, number, number]
+ carStateRef.current.rotation = data.car.rotation
+ carStateRef.current.speed = 0
+ }
+ })
+ loadPlayerPosition().then((pos) => {
+ if (pos) {
+ setPersistedPlayerPos(pos)
+ // Restore location if player was indoors
+ if (pos.locationType === 'indoor' && pos.boardId) {
+ setLocation({ type: 'indoor', boardId: pos.boardId, houseWorldPos: [0, 0, 0] })
+ }
+ }
+ })
+ }, [])
+
+ useEffect(() => {
+ if (hasLoadedSavedCar.current) return
+ carStateRef.current.position = [...defaultCarData.position] as [number, number, number]
+ carStateRef.current.rotation = defaultCarData.rotation
+ carStateRef.current.speed = 0
+ }, [defaultCarData])
+
+ useEffect(() => () => {
+ if (playerPosSaveTimer.current) clearTimeout(playerPosSaveTimer.current)
+ if (carSaveTimer.current) clearTimeout(carSaveTimer.current)
+ }, [])
+
+ const saveCameraPos = useCallback((pos: { x: number; y: number; z: number; yaw: number }) => {
+ setSavedCameraPos(pos)
+ }, [])
+
+ const enterHouse = useCallback((boardId: string) => {
+ for (const n of neighborhoods) {
+ for (const h of n.houses) {
+ if (h.id === boardId) {
+ setLocation({ type: 'indoor', boardId, houseWorldPos: h.worldPosition })
+ return
+ }
+ }
+ }
+ }, [neighborhoods])
+
+ const exitHouse = useCallback(() => {
+ setLocation({ type: 'outdoor' })
+ }, [])
+
+ const enterCar = useCallback(() => {
+ if (location.type !== 'outdoor') return
+ carStateRef.current.speed = 0
+ setIsDriving(true)
+ }, [location.type])
+
+ const exitCar = useCallback(() => {
+ carStateRef.current.speed = 0
+ setIsDriving(false)
+ if (carSaveTimer.current) clearTimeout(carSaveTimer.current)
+ villageDataRef.current = {
+ ...villageDataRef.current,
+ car: {
+ position: [...carStateRef.current.position] as [number, number, number],
+ rotation: carStateRef.current.rotation
+ }
+ }
+ saveVillageData(villageDataRef.current)
+ }, [])
+
+ const interact = useCallback((id: string, kind: ActiveDialog['kind'], boardId: string) => {
+ setActiveDialog({ kind, id, boardId })
+ }, [])
+
+ const closeDialog = useCallback(() => {
+ setActiveDialog(null)
+ // Bump version so useBoardItems re-fetches with updated screenshots
+ setBoardItemsVersion((v) => v + 1)
+ }, [])
+ const toggleDecoMode = useCallback(() => setIsDecoMode((v) => !v), [])
+
+ const addDecoration = useCallback((d: DecorationPlacement) => {
+ setDecorations((prev) => {
+ const next = [...prev, d]
+ saveDecorations(next)
+ return next
+ })
+ }, [])
+
+ const moveDecoration = useCallback((id: string, pos: [number, number, number]) => {
+ setDecorations((prev) => {
+ const next = prev.map((d) => d.id === id ? { ...d, position: pos } : d)
+ saveDecorations(next)
+ return next
+ })
+ }, [])
+
+ const rotateDecoration = useCallback((id: string, rot: [number, number, number]) => {
+ setDecorations((prev) => {
+ const next = prev.map((d) => d.id === id ? { ...d, rotation: rot } : d)
+ saveDecorations(next)
+ return next
+ })
+ }, [])
+
+ const deleteDecoration = useCallback((id: string) => {
+ setDecorations((prev) => {
+ const next = prev.filter((d) => d.id !== id)
+ saveDecorations(next)
+ return next
+ })
+ }, [])
+
+ // Grab system
+ const grabObject = useCallback((id: string) => {
+ setGrabbedObjectId(id)
+ }, [])
+
+ const placeObject = useCallback((position: [number, number, number], rotation: number) => {
+ setGrabbedObjectId((currentId) => {
+ if (!currentId) return null
+ setObjectPositions((prev) => {
+ const next = { ...prev, [currentId]: { position, rotation } }
+ // Save to file
+ villageDataRef.current = { ...villageDataRef.current, objectPositions: next }
+ saveVillageData(villageDataRef.current)
+ return next
+ })
+ return null
+ })
+ }, [])
+
+ const updateGrabbedPosition = useCallback((position: [number, number, number]) => {
+ setGrabbedObjectId((currentId) => {
+ if (!currentId) return null
+ setObjectPositions((prev) => ({
+ ...prev,
+ [currentId]: { position, rotation: prev[currentId]?.rotation ?? 0 }
+ }))
+ return currentId
+ })
+ }, [])
+
+ // Persist player position (debounced)
+ const persistPlayerPos = useCallback((pos: PersistedPlayerPos) => {
+ setPersistedPlayerPos(pos)
+ if (playerPosSaveTimer.current) clearTimeout(playerPosSaveTimer.current)
+ playerPosSaveTimer.current = setTimeout(() => {
+ savePlayerPosition(pos)
+ }, 2000)
+ }, [])
+
+ const persistCarTransform = useCallback(() => {
+ if (carSaveTimer.current) clearTimeout(carSaveTimer.current)
+ carSaveTimer.current = setTimeout(() => {
+ villageDataRef.current = {
+ ...villageDataRef.current,
+ car: {
+ position: [...carStateRef.current.position] as [number, number, number],
+ rotation: carStateRef.current.rotation
+ }
+ }
+ saveVillageData(villageDataRef.current)
+ }, 300)
+ }, [])
+
+ useEffect(() => {
+ if (location.type === 'outdoor' || !isDriving) return
+ carStateRef.current.speed = 0
+ setIsDriving(false)
+ }, [isDriving, location.type])
+
+ const value = useMemo(() => ({
+ neighborhoods, scenery, roads, location, cameraMode, hoveredId, activeDialog,
+ decorations, isDecoMode, savedCameraPos, saveCameraPos,
+ enterHouse, exitHouse, setHoveredId, interact, closeDialog,
+ toggleDecoMode, addDecoration, moveDecoration, rotateDecoration, deleteDecoration,
+ grabbedObjectId, objectPositions, grabObject, placeObject, updateGrabbedPosition,
+ persistedPlayerPos, persistPlayerPos,
+ carStateRef, isDriving, enterCar, exitCar, persistCarTransform,
+ boardItemsVersion,
+ folders, boards, onSelectBoard
+ }), [
+ neighborhoods, scenery, roads, location, cameraMode, hoveredId, activeDialog,
+ decorations, isDecoMode, savedCameraPos, saveCameraPos,
+ enterHouse, exitHouse, interact, closeDialog, setHoveredId,
+ toggleDecoMode, addDecoration, moveDecoration, rotateDecoration, deleteDecoration,
+ grabbedObjectId, objectPositions, grabObject, placeObject, updateGrabbedPosition,
+ persistedPlayerPos, persistPlayerPos,
+ carStateRef, isDriving, enterCar, exitCar, persistCarTransform,
+ boardItemsVersion,
+ folders, boards, onSelectBoard
+ ])
+
+ return {children}
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/village-dialog.tsx b/apps/desktop/src/renderer/src/components/hub/village-dialog.tsx
new file mode 100644
index 0000000..dc0f72c
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/village-dialog.tsx
@@ -0,0 +1,187 @@
+import { useState, useEffect, useCallback } from 'react'
+import { X } from 'lucide-react'
+import { useVillage } from './village-context'
+import { BrowserTabDialog } from '@/components/browser/browser-tab-dialog'
+import { TerminalDialog } from '@/components/browser/terminal-dialog'
+import type { BrowserTab } from '@/hooks/use-browser-tabs'
+import type { GraphNode } from '@/hooks/use-graph-nodes'
+
+interface TerminalNodeConfig {
+ command?: string
+ cwd?: string
+ shell?: string
+ timeout?: number
+ lastScrollback?: string
+ lastScreenshot?: string
+}
+
+export function VillageDialog() {
+ const { activeDialog, closeDialog } = useVillage()
+ const [tab, setTab] = useState(null)
+ const [terminalNode, setTerminalNode] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [workspaceRootDir, setWorkspaceRootDir] = useState(undefined)
+ const [boardRootDir, setBoardRootDir] = useState(null)
+ const [portalContainer, setPortalContainer] = useState(null)
+
+ useEffect(() => {
+ if (!activeDialog) {
+ setTab(null)
+ setTerminalNode(null)
+ return
+ }
+
+ const itemId = activeDialog.id
+ const realId = itemId.replace(/^desk-/, '').replace(/^interior-/, '')
+ const boardId = activeDialog.boardId
+
+ setLoading(true)
+
+ window.api.workspace.getState().then((state: { rootDir: string }) => {
+ setWorkspaceRootDir(state.rootDir)
+ }).catch(() => {})
+ window.api.workspace.getBoardRootDir(boardId).then((dir: unknown) => {
+ setBoardRootDir((dir as string) || null)
+ }).catch(() => {})
+
+ Promise.all([
+ window.api.browserTabs.list(boardId) as Promise,
+ window.api.graphNodes.list(boardId) as Promise
+ ]).then(([tabs, nodes]) => {
+ const foundTab = tabs.find((t) => t.id === realId)
+ if (foundTab) {
+ setTab(foundTab)
+ setTerminalNode(null)
+ setLoading(false)
+ return
+ }
+
+ const foundNode = nodes.find((n) => n.id === realId && n.nodeType === 'terminal')
+ if (foundNode) {
+ setTerminalNode(foundNode)
+ setTab(null)
+ setLoading(false)
+ return
+ }
+
+ // Agent: show first tab
+ if (itemId.includes('agent') && tabs.length > 0) {
+ setTab(tabs[0])
+ setLoading(false)
+ return
+ }
+
+ setLoading(false)
+ }).catch(() => {
+ setLoading(false)
+ })
+ }, [activeDialog])
+
+ const handleClose = useCallback(() => {
+ closeDialog()
+ // Re-acquire pointer lock for FPS after a short delay
+ setTimeout(() => {
+ const canvas = document.querySelector('canvas')
+ if (canvas) try { canvas.requestPointerLock() } catch { /* ignore */ }
+ }, 100)
+ }, [closeDialog])
+
+ if (!activeDialog) return null
+
+ const backButton = (
+
+
+ Back to Village
+
+ )
+
+ // Browser tab — use the same BrowserTabDialog from whiteboard view
+ if (tab) {
+ return (
+ <>
+ {
+ if (!open) {
+ // Small delay to ensure screenshot persist completes before re-fetch
+ await new Promise((r) => setTimeout(r, 200))
+ handleClose()
+ }
+ }}
+ onTabUpdate={async (id, data) => {
+ const result = await window.api.browserTabs.update(id, data, activeDialog.boardId)
+ setTab((prev) => prev ? { ...prev, ...data } as BrowserTab : prev)
+ return result
+ }}
+ />
+ {backButton}
+ >
+ )
+ }
+
+ // Terminal — use TerminalDialog with portal contained inside our layout
+ if (terminalNode) {
+ const config: TerminalNodeConfig = terminalNode.config ? JSON.parse(terminalNode.config) : {}
+ const latestConfigRef = { current: config }
+ return (
+ <>
+
+
+ {portalContainer && (
+ {
+ if (!open) {
+ // Persist the latest config (including screenshot) before unmounting
+ await window.api.graphNodes.update(terminalNode.id, { config: JSON.stringify(latestConfigRef.current) })
+ handleClose()
+ }
+ }}
+ sessionId={`pty-${terminalNode.id}`}
+ label={terminalNode.label || 'Terminal'}
+ config={config}
+ onUpdateConfig={async (nextConfig) => {
+ latestConfigRef.current = nextConfig as TerminalNodeConfig
+ await window.api.graphNodes.update(terminalNode.id, { config: JSON.stringify(nextConfig) })
+ }}
+ workspaceRootDir={workspaceRootDir}
+ boardRootDir={boardRootDir}
+ portalContainer={portalContainer}
+ />
+ )}
+ {backButton}
+ >
+ )
+ }
+
+ // Loading or no match
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return null
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/village-scene.tsx b/apps/desktop/src/renderer/src/components/hub/village-scene.tsx
new file mode 100644
index 0000000..e61c45c
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/village-scene.tsx
@@ -0,0 +1,145 @@
+import { Suspense, useEffect, Component, type ReactNode } from 'react'
+import { Canvas, useThree } from '@react-three/fiber'
+import { Sky, ContactShadows, Environment } from '@react-three/drei'
+import { useVillage } from './village-context'
+import { VillageWorld } from './village-world'
+import { HouseInterior } from './house-interior'
+import { FPSCameraController } from './fps-camera-controller'
+import { SimsCameraController } from './sims-camera-controller'
+import { ProceduralClouds } from './procedural-clouds'
+import { useHubWorldLighting } from '@/hooks/use-hub-world-lighting'
+
+/* ------------------------------------------------------------------ */
+/* Error boundary */
+/* ------------------------------------------------------------------ */
+
+interface ErrorBoundaryProps { fallback?: ReactNode; children: ReactNode }
+interface ErrorBoundaryState { hasError: boolean }
+
+class SceneErrorBoundary extends Component {
+ state: ErrorBoundaryState = { hasError: false }
+ static getDerivedStateFromError() { return { hasError: true } }
+ componentDidCatch(error: Error) { console.warn('[VillageScene] caught:', error.message) }
+ render() {
+ if (this.state.hasError) return this.props.fallback ?? null
+ return this.props.children
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Indoor helpers */
+/* ------------------------------------------------------------------ */
+
+function IndoorEnvironmentClear({ active }: { active: boolean }) {
+ const { scene } = useThree()
+ useEffect(() => {
+ if (active) { scene.environment = null }
+ }, [active, scene])
+ return null
+}
+
+/* ------------------------------------------------------------------ */
+/* Scene content (lives inside Canvas) */
+/* ------------------------------------------------------------------ */
+
+function SceneContent() {
+ const { location, cameraMode } = useVillage()
+ const lighting = useHubWorldLighting()
+ const isIndoor = location.type === 'indoor'
+
+ return (
+ <>
+
+
+ {/* Outdoor lighting — hidden when indoors */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Outdoor village */}
+
+
+
+
+ {/* Indoor house */}
+ {isIndoor && (
+
+ )}
+
+ {/* Camera controller */}
+ {cameraMode === 'fps' && }
+ {cameraMode === 'sims' && }
+ >
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Exported scene */
+/* ------------------------------------------------------------------ */
+
+export function VillageScene() {
+ const lighting = useHubWorldLighting()
+
+ return (
+ 3D scene failed to load. Try refreshing. }>
+
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/village-types.ts b/apps/desktop/src/renderer/src/components/hub/village-types.ts
new file mode 100644
index 0000000..eb9c5d7
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/village-types.ts
@@ -0,0 +1,102 @@
+import type { WorkspaceFolder, WorkspaceBoard } from '@/hooks/use-workspace'
+import type { AssetDef } from './hub-assets'
+
+/* ------------------------------------------------------------------ */
+/* Village world model */
+/* ------------------------------------------------------------------ */
+
+export const TOWN_CAR_ID = 'town-car'
+
+export interface VillageNeighborhood {
+ id: string
+ folder: WorkspaceFolder
+ position: [number, number, number]
+ rotation: number
+ houses: VillageHouse[]
+ archPosition: [number, number, number]
+ archRotation: number
+}
+
+export interface VillageHouse {
+ id: string
+ board: WorkspaceBoard
+ neighborhoodId: string
+ worldPosition: [number, number, number]
+ worldRotation: number
+ houseAsset: AssetDef
+}
+
+/* ------------------------------------------------------------------ */
+/* Interior objects */
+/* ------------------------------------------------------------------ */
+
+export interface InteriorObject {
+ id: string
+ kind: 'computer' | 'terminal' | 'agent'
+ label: string
+ localPosition: [number, number, number]
+ localRotation: number
+}
+
+/* ------------------------------------------------------------------ */
+/* Decoration */
+/* ------------------------------------------------------------------ */
+
+export interface DecorationPlacement {
+ id: string
+ assetFile: string
+ position: [number, number, number]
+ rotation: [number, number, number]
+ scale: number
+ scope: 'outdoor' | 'indoor'
+ scopeId: string
+}
+
+/* ------------------------------------------------------------------ */
+/* Scene prop (procedural, non-editable) */
+/* ------------------------------------------------------------------ */
+
+export interface SceneProp {
+ asset: AssetDef
+ position: [number, number, number]
+ rotation: [number, number, number]
+}
+
+/* ------------------------------------------------------------------ */
+/* Object position overrides (persisted to workspace JSON) */
+/* ------------------------------------------------------------------ */
+
+export interface ObjectPositionOverride {
+ position: [number, number, number]
+ rotation: number
+}
+
+export interface DrivableCarData {
+ position: [number, number, number]
+ rotation: number
+}
+
+export interface DrivableCarState extends DrivableCarData {
+ speed: number
+}
+
+export interface VillageData {
+ objectPositions: Record
+ car?: DrivableCarData
+}
+
+/* ------------------------------------------------------------------ */
+/* State */
+/* ------------------------------------------------------------------ */
+
+export type VillageLocation =
+ | { type: 'outdoor' }
+ | { type: 'indoor'; boardId: string; houseWorldPos: [number, number, number] }
+
+export type CameraMode = 'fps' | 'sims'
+
+export interface ActiveDialog {
+ kind: 'browser' | 'terminal' | 'board'
+ id: string
+ boardId: string
+}
diff --git a/apps/desktop/src/renderer/src/components/hub/village-world.tsx b/apps/desktop/src/renderer/src/components/hub/village-world.tsx
new file mode 100644
index 0000000..7758a06
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/hub/village-world.tsx
@@ -0,0 +1,552 @@
+import { useRef, useMemo } from 'react'
+import { useFrame } from '@react-three/fiber'
+import { Text, Billboard } from '@react-three/drei'
+import * as THREE from 'three'
+import { useVillage } from './village-context'
+import { GlbModel, seededRandom, type AssetDef } from './hub-assets'
+import { TOWN_CAR_ID, type VillageNeighborhood, type VillageHouse, type SceneProp } from './village-types'
+import type { RoadSegment } from './use-village-layout'
+import { FluffyGrass } from './fluffy-grass'
+
+const ARCH_ASSET: AssetDef = { file: 'arch.glb', scale: 5.0 }
+const FOUNTAIN_ASSET: AssetDef = { file: 'fountain.glb', scale: 3.5 }
+const HOUSE_LIGHT_COLOR = '#ffd39a'
+
+/* ------------------------------------------------------------------ */
+/* Ground */
+/* ------------------------------------------------------------------ */
+
+function Ground() {
+ return (
+
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Road segment */
+/* ------------------------------------------------------------------ */
+
+function RoadMesh({ segment }: { segment: RoadSegment }) {
+ return (
+
+
+
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Cul-de-sac road circle */
+/* ------------------------------------------------------------------ */
+
+function CulDeSacCircle({ position }: { position: [number, number, number] }) {
+ return (
+
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Archway entrance with folder name (non-billboarded) */
+/* ------------------------------------------------------------------ */
+
+function NeighborhoodArch({ archPosition, archRotation, name }: { archPosition: [number, number, number]; archRotation: number; name: string }) {
+ // Arch straddles the side street. Side streets run along X axis.
+ // The arch GLB model's opening should face along X so people walk through it.
+ return (
+
+
+ {/* Text readable when walking along the side street (+X and -X directions) */}
+
+ {name}
+
+
+ {name}
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* House exterior with door zone */
+/* ------------------------------------------------------------------ */
+
+const DOOR_OFFSET_Z = 5
+
+function HouseWindowGlow({
+ position,
+ rotation = [0, 0, 0],
+ size,
+ opacity
+}: {
+ position: [number, number, number]
+ rotation?: [number, number, number]
+ size: [number, number]
+ opacity: number
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function HouseExterior({ house, nightFactor }: { house: VillageHouse; nightFactor: number }) {
+ const { hoveredId } = useVillage()
+ const isHovered = hoveredId === `door-${house.id}`
+ const scaleFactor = house.houseAsset.scale / 3.5
+ const houseGlow = nightFactor > 0.02 ? 0.18 + nightFactor * 0.72 : 0
+ const frontWindowY = 2.1 * scaleFactor + 0.9
+ const sideWindowY = 2.0 * scaleFactor + 0.9
+ const frontWindowZ = 4.05 * scaleFactor
+ const sideWindowX = 3.7 * scaleFactor
+ const sideWindowZ = 0.75 * scaleFactor
+
+ return (
+
+
+
+ {houseGlow > 0 && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ {house.board.name}
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Neighborhood group */
+/* ------------------------------------------------------------------ */
+
+function Neighborhood({ data, nightFactor }: { data: VillageNeighborhood; nightFactor: number }) {
+ return (
+
+
+
+ {/* Fountain in the center of the cul-de-sac */}
+
+
+ {/* Archway at the main road junction */}
+
+
+ {data.houses.map((h) => (
+
+ ))}
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Meandering road helper (must match use-village-layout.ts) */
+/* ------------------------------------------------------------------ */
+
+function mainRoadX(z: number): number {
+ return Math.sin(z * 0.08) * 6 + Math.sin(z * 0.184 + 1.5) * 2.4
+}
+
+/* ------------------------------------------------------------------ */
+/* Wandering villager NPC */
+/* ------------------------------------------------------------------ */
+
+const NPC_COLORS = [
+ { shirt: '#cc4444', pants: '#334455' },
+ { shirt: '#44aa44', pants: '#443322' },
+ { shirt: '#4466cc', pants: '#333344' },
+ { shirt: '#cc8844', pants: '#2a3a2a' },
+ { shirt: '#aa44aa', pants: '#3a3a3a' },
+ { shirt: '#44aaaa', pants: '#443344' },
+ { shirt: '#ddaa33', pants: '#334433' },
+ { shirt: '#886644', pants: '#222233' },
+]
+
+const SKIN_TONES = ['#ffddaa', '#e8c89a', '#d4a574', '#c49060', '#8d5524']
+
+interface VillagerProps {
+ startZ: number
+ speed: number
+ minZ: number
+ maxZ: number
+ colorIdx: number
+ skinIdx: number
+ xOffset: number
+}
+
+function Villager({ startZ, speed, minZ, maxZ, colorIdx, skinIdx, xOffset }: VillagerProps) {
+ const ref = useRef(null)
+ const zRef = useRef(startZ)
+ const dirRef = useRef(speed > 0 ? 1 : -1)
+ const colors = NPC_COLORS[colorIdx % NPC_COLORS.length]
+ const skin = SKIN_TONES[skinIdx % SKIN_TONES.length]
+
+ useFrame((state, delta) => {
+ if (!ref.current) return
+ zRef.current += dirRef.current * Math.abs(speed) * delta
+ if (zRef.current > maxZ) { zRef.current = maxZ; dirRef.current = -1 }
+ if (zRef.current < minZ) { zRef.current = minZ; dirRef.current = 1 }
+ const x = mainRoadX(zRef.current) + xOffset
+ ref.current.position.set(x, 0, zRef.current)
+ ref.current.rotation.y = dirRef.current > 0 ? 0 : Math.PI
+ ref.current.position.y = Math.sin(state.clock.elapsedTime * 6 * Math.abs(speed)) * 0.04
+ })
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function VillagerCrowd({ minZ, maxZ }: { minZ: number; maxZ: number }) {
+ const villagers = useMemo(() => {
+ const rng = seededRandom(777)
+ const count = Math.max(6, Math.min(20, Math.floor((maxZ - minZ) / 15)))
+ const result: VillagerProps[] = []
+ for (let i = 0; i < count; i++) {
+ result.push({
+ startZ: minZ + rng() * (maxZ - minZ),
+ speed: 1.5 + rng() * 2,
+ minZ: minZ + 5,
+ maxZ: maxZ - 5,
+ colorIdx: Math.floor(rng() * NPC_COLORS.length),
+ skinIdx: Math.floor(rng() * SKIN_TONES.length),
+ xOffset: (rng() - 0.5) * 3,
+ })
+ }
+ return result
+ }, [minZ, maxZ])
+
+ return (
+ <>
+ {villagers.map((v, i) => (
+
+ ))}
+ >
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Full outdoor world */
+/* ------------------------------------------------------------------ */
+
+function GrabbableScenery({
+ prop,
+ index
+}: {
+ prop: SceneProp
+ index: number
+}) {
+ const { objectPositions, grabbedObjectId } = useVillage()
+ const id = `scenery-${index}`
+ const override = objectPositions[id]
+ const isGrabbed = grabbedObjectId === id
+ const position = override?.position ?? prop.position
+
+ return (
+
+
+ {isGrabbed && (
+
+
+
+
+ )}
+
+ )
+}
+
+const CAR_BODY_COLOR = '#c74b33'
+const CAR_TRIM_COLOR = '#f6d481'
+const CAR_WINDOW_COLOR = '#b8dcf2'
+const CAR_WHEEL_COLOR = '#1d1d24'
+const CAR_WHEEL_RADIUS = 0.42
+const CAR_WHEEL_POSITIONS: [number, number, number][] = [
+ [-1.15, CAR_WHEEL_RADIUS, -1.45],
+ [1.15, CAR_WHEEL_RADIUS, -1.45],
+ [-1.15, CAR_WHEEL_RADIUS, 1.45],
+ [1.15, CAR_WHEEL_RADIUS, 1.45],
+]
+
+function TownCar() {
+ const { hoveredId, isDriving, carStateRef } = useVillage()
+ const groupRef = useRef(null)
+ const wheelRefs = useRef<(THREE.Group | null)[]>([])
+ const isHighlighted = hoveredId === TOWN_CAR_ID
+ const showPrompt = isHighlighted || isDriving
+
+ useFrame((_, delta) => {
+ if (!groupRef.current) return
+ const car = carStateRef.current
+ groupRef.current.position.set(car.position[0], car.position[1], car.position[2])
+ groupRef.current.rotation.y = car.rotation
+
+ const wheelSpin = (car.speed * delta) / CAR_WHEEL_RADIUS
+ wheelRefs.current.forEach((wheel) => {
+ if (!wheel) return
+ wheel.rotation.x -= wheelSpin
+ })
+ })
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {CAR_WHEEL_POSITIONS.map(([x, y, z], index) => (
+ {
+ wheelRefs.current[index] = node
+ }}
+ position={[x, y, z]}
+ >
+
+
+
+
+
+
+
+
+
+ ))}
+
+ {showPrompt && (
+ <>
+
+
+
+
+
+
+
+
+ Town Car
+
+
+ {isDriving ? '[E] Exit car' : '[E] Drive'}
+
+
+
+ >
+ )}
+
+ )
+}
+
+export function VillageWorld({ nightFactor }: { nightFactor: number }) {
+ const { neighborhoods, scenery, roads } = useVillage()
+
+ const roadExtents = useMemo(() => {
+ if (roads.length === 0) return { minZ: -20, maxZ: 20 }
+ let minZ = Infinity, maxZ = -Infinity
+ for (const r of roads) {
+ minZ = Math.min(minZ, r.cz - r.length / 2)
+ maxZ = Math.max(maxZ, r.cz + r.length / 2)
+ }
+ return { minZ, maxZ }
+ }, [roads])
+
+ return (
+ <>
+
+
+
+ {roads.map((seg, i) => (
+
+ ))}
+
+
+
+
+ {neighborhoods.map((n) => (
+
+ ))}
+
+ {scenery.map((prop, i) => (
+
+ ))}
+ >
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/ui/dialog.tsx b/apps/desktop/src/renderer/src/components/ui/dialog.tsx
index 3cdcd4e..81cb97d 100644
--- a/apps/desktop/src/renderer/src/components/ui/dialog.tsx
+++ b/apps/desktop/src/renderer/src/components/ui/dialog.tsx
@@ -25,9 +25,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, forceMount, ...props }, ref) => (
-
+ React.ComponentPropsWithoutRef & { container?: HTMLElement | null }
+>(({ className, children, forceMount, container, ...props }, ref) => (
+
{forceMount ? null : }
23 || minutes < 0 || minutes > 59) return null
+
+ return hours * 60 + minutes
+}
+
+export function sanitizeHubWorldOverrideTime(value: unknown): string {
+ return typeof value === 'string' && parseHubWorldTime(value) !== null
+ ? value
+ : DEFAULT_HUB_WORLD_OVERRIDE_TIME
+}
+
+export function getSystemClockMinutes(now: Date = new Date()): number {
+ return now.getHours() * 60 + now.getMinutes()
+}
+
+export function resolveHubWorldMinutes(
+ timeMode: HubWorldTimeMode,
+ overrideTime: string,
+ now: Date = new Date()
+): number {
+ if (timeMode === 'override') {
+ return parseHubWorldTime(overrideTime) ?? parseHubWorldTime(DEFAULT_HUB_WORLD_OVERRIDE_TIME) ?? 12 * 60
+ }
+ return getSystemClockMinutes(now)
+}
+
+export function formatHubWorldTimeLabel(totalMinutes: number): string {
+ const normalized = ((Math.round(totalMinutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
+ const hours24 = Math.floor(normalized / 60)
+ const minutes = normalized % 60
+ const suffix = hours24 >= 12 ? 'PM' : 'AM'
+ const hours12 = hours24 % 12 || 12
+
+ return `${hours12}:${minutes.toString().padStart(2, '0')} ${suffix}`
+}
+
+export function buildHubWorldLighting(totalMinutes: number): HubWorldLighting {
+ const normalizedMinutes = ((Math.round(totalMinutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
+ const orbitAngle = (normalizedMinutes / MINUTES_PER_DAY) * Math.PI * 2 - Math.PI / 2
+ const rawSunHeight = Math.sin(orbitAngle)
+ const daylight = smoothstep(-0.12, 0.22, rawSunHeight)
+ const fullDay = smoothstep(0.08, 0.72, rawSunHeight)
+ const twilight = clamp01(1 - Math.abs(rawSunHeight) / 0.24) * (1 - fullDay)
+ const nightFactor = 1 - daylight
+
+ const backgroundColor = blendHex(
+ blendHex('#07111f', '#f59f6c', twilight),
+ '#87ceeb',
+ fullDay
+ )
+ const fogColor = blendHex(
+ blendHex('#09172b', '#efb27c', twilight * 0.85),
+ '#d9eefb',
+ fullDay * 0.9
+ )
+ const hemisphereSkyColor = blendHex(
+ blendHex('#14284b', '#ffba78', twilight * 0.9),
+ '#9fd8ff',
+ fullDay
+ )
+ const hemisphereGroundColor = blendHex('#15273a', '#4a7c59', daylight)
+ const directionalColor = blendHex(
+ blendHex('#a6bbff', '#ffd199', twilight),
+ '#fff7df',
+ fullDay
+ )
+
+ const sunPosition: [number, number, number] = [
+ Math.cos(orbitAngle) * 120,
+ rawSunHeight * 90,
+ Math.sin(orbitAngle * 0.7) * 48 + 28
+ ]
+
+ const lightHeight = mix(10, 82, clamp01((rawSunHeight + 0.2) / 1.2))
+ const directionalPosition: [number, number, number] = [
+ Math.cos(orbitAngle) * 68,
+ lightHeight,
+ Math.sin(orbitAngle * 0.7) * 26 + 20
+ ]
+
+ return {
+ totalMinutes: normalizedMinutes,
+ timeLabel: formatHubWorldTimeLabel(normalizedMinutes),
+ daylightFactor: daylight,
+ nightFactor,
+ sunPosition,
+ directionalPosition,
+ backgroundColor,
+ fogColor,
+ hemisphereSkyColor,
+ hemisphereGroundColor,
+ directionalColor,
+ ambientIntensity: mix(0.18, 0.24, daylight) + twilight * 0.05,
+ directionalIntensity: mix(0.24, 1.12, daylight) + twilight * 0.1,
+ hemisphereIntensity: mix(0.17, 0.22, daylight) + twilight * 0.04,
+ environmentIntensity: mix(0.1, 0.6, daylight),
+ shadowOpacity: mix(0.1, 0.34, daylight),
+ skyTurbidity: mix(2, 8, daylight) + twilight * 2,
+ skyRayleigh: mix(0.15, 1.4, daylight) + twilight * 0.25,
+ skyMieCoefficient: mix(0.005, 0.03, twilight) + fullDay * 0.002,
+ skyMieDirectionalG: mix(0.97, 0.8, daylight)
+ }
+}
+
+export function useHubWorldLighting(): HubWorldLighting & {
+ timeMode: HubWorldTimeMode
+ overrideTime: string
+} {
+ const { getSetting } = useSettings()
+ const [clockTick, setClockTick] = useState(() => Date.now())
+
+ const timeMode = getHubWorldTimeMode(getSetting(HUB_WORLD_TIME_MODE_KEY))
+ const overrideTime = sanitizeHubWorldOverrideTime(getSetting(HUB_WORLD_OVERRIDE_TIME_KEY))
+
+ useEffect(() => {
+ if (timeMode === 'override') return
+
+ let timeoutId: ReturnType | null = null
+
+ const scheduleNextTick = () => {
+ const now = new Date()
+ const msUntilNextMinute =
+ (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 50
+
+ timeoutId = setTimeout(() => {
+ setClockTick(Date.now())
+ scheduleNextTick()
+ }, Math.max(msUntilNextMinute, 1000))
+ }
+
+ scheduleNextTick()
+
+ return () => {
+ if (timeoutId) clearTimeout(timeoutId)
+ }
+ }, [timeMode])
+
+ const totalMinutes = useMemo(
+ () => resolveHubWorldMinutes(timeMode, overrideTime, new Date(clockTick)),
+ [clockTick, overrideTime, timeMode]
+ )
+
+ return useMemo(
+ () => ({
+ timeMode,
+ overrideTime,
+ ...buildHubWorldLighting(totalMinutes)
+ }),
+ [overrideTime, timeMode, totalMinutes]
+ )
+}
diff --git a/apps/desktop/src/renderer/src/pages/browser.tsx b/apps/desktop/src/renderer/src/pages/browser.tsx
index 96e22d0..c23c928 100644
--- a/apps/desktop/src/renderer/src/pages/browser.tsx
+++ b/apps/desktop/src/renderer/src/pages/browser.tsx
@@ -21,7 +21,7 @@ import {
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
-import { Globe, MessageSquare, Radio, Trash2, Copy, Play, Bug, Bell, Sparkles, Timer, NotebookPen, File, FileText, FolderOpen, ChevronDown, ChevronRight, Code, Search, GitCompare, CalendarClock, FormInput, Folder, Terminal, Presentation, PanelTop, Settings, ScrollText, PanelLeftClose, PanelLeftOpen, ArrowLeft, Check, FolderPlus, Archive, Menu } from 'lucide-react'
+import { Globe, MessageSquare, Radio, Trash2, Copy, Play, Bug, Bell, Sparkles, Timer, NotebookPen, File, FileText, FolderOpen, ChevronDown, ChevronRight, Code, Search, GitCompare, CalendarClock, FormInput, Folder, Terminal, Presentation, PanelTop, Settings, ScrollText, PanelLeftClose, PanelLeftOpen, ArrowLeft, Check, FolderPlus, Archive, Menu, Waypoints } from 'lucide-react'
import { useAppActions } from '@/App'
import { UpdateBanner } from '@/components/update-banner'
import { Button } from '@/components/ui/button'
@@ -59,6 +59,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
import { DynamicIcon, IconPicker } from '@/components/ui/icon-picker'
import { SettingsPage, SettingsSidebar, type SettingsSectionId } from '@/pages/settings'
import { CLI_AGENTS, WORKSPACE_AGENT_COMMAND_OVERRIDES_KEY, getAgentCommand } from '@/lib/cli-agents'
+import { HubView } from '@/components/hub/hub-view'
import TurndownService from 'turndown'
const MONITOR_TAG = '[browser-monitor]'
@@ -399,6 +400,8 @@ function BrowserPageInner(): React.ReactElement {
const [settingsSection, setSettingsSection] = useState('appearance')
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null)
const [boardView, setBoardView] = useState<'whiteboard' | 'tabs' | 'document'>('whiteboard')
+ const [showHubView, setShowHubView] = useState(false)
+ const [cameFromHub, setCameFromHub] = useState(false)
const [boardDocHtml, setBoardDocHtmlState] = useState('
')
const [boardDocLoading, setBoardDocLoading] = useState(false)
const boardDocSaveTimerRef = useRef | null>(null)
@@ -3639,6 +3642,7 @@ function BrowserPageInner(): React.ReactElement {
const handleSelectBoard = useCallback(
async (boardId: string) => {
+ setShowHubView(false)
await setActiveBoard(boardId)
},
[setActiveBoard]
@@ -4225,6 +4229,18 @@ function BrowserPageInner(): React.ReactElement {
+
setShowHubView((v) => !v)}
+ className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs transition-colors ${
+ showHubView
+ ? 'bg-accent text-foreground font-medium'
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
+ }`}
+ >
+
+ Hub
+
)}
+ {cameFromHub && !showHubView && !isSettingsRoute && (
+
{ setCameFromHub(false); setShowHubView(true) }}
+ title="Back to Hub"
+ >
+
+
+ )}
- {isSettingsRoute ? 'Settings' : workspaceLoading ? 'Loading...' : activeBoard?.name ?? 'Select a board'}
+ {isSettingsRoute ? 'Settings' : showHubView ? 'Hub' : workspaceLoading ? 'Loading...' : activeBoard?.name ?? 'Select a board'}
- {!isSettingsRoute && activeBoardId && (
+ {!isSettingsRoute && !showHubView && activeBoardId && (
)}
- {!isSettingsRoute && boardView === 'whiteboard' && (
+ {!isSettingsRoute && showHubView && workspace && (
+
{
+ setShowHubView(false)
+ setCameFromHub(true)
+ void setActiveBoard(boardId).then(() => {
+ setBoardView('tabs')
+ })
+ }}
+ />
+ )}
+ {!isSettingsRoute && !showHubView && boardView === 'whiteboard' && (
)}
- {!isSettingsRoute && boardView === 'tabs' && (
+ {!isSettingsRoute && !showHubView && boardView === 'tabs' && (
)}
- {!isSettingsRoute && boardView === 'document' && (
+ {!isSettingsRoute && !showHubView && boardView === 'document' && (
([])
const [showResetConfirm, setShowResetConfirm] = useState(false)
const [restoringArchivedBoardId, setRestoringArchivedBoardId] = useState(null)
@@ -206,6 +224,7 @@ export function SettingsPage({
(getSetting('flowInteractionMode') as string) === 'map' ? 'map' : 'design'
const nodeOpenClick: NodeOpenClick =
(getSetting('nodeOpenClick') as string) === 'single' ? 'single' : 'double'
+ const hubWorldTimeMode: HubWorldTimeMode = getHubWorldTimeMode(getSetting(HUB_WORLD_TIME_MODE_KEY))
const workspaceRootDir = workspace?.rootDir ?? ''
const workspaceAgentCommandOverrides = useMemo(
() => getWorkspaceAgentCommandOverrides(getSetting(WORKSPACE_AGENT_COMMAND_OVERRIDES_KEY)),
@@ -221,6 +240,10 @@ export function SettingsPage({
setAnthropicKey(((getSetting('anthropicApiKey') as string) ?? '').trim())
}, [settings, getSetting])
+ useEffect(() => {
+ setHubWorldOverrideTime(sanitizeHubWorldOverrideTime(getSetting(HUB_WORLD_OVERRIDE_TIME_KEY)))
+ }, [settings, getSetting])
+
useEffect(() => {
const nextEntries = CLI_AGENTS.map((agent) => [
agent.id,
@@ -363,6 +386,15 @@ export function SettingsPage({
}
}
+ const hubWorldPreviewMinutes = useMemo(() => {
+ if (hubWorldTimeMode === 'override') {
+ return parseHubWorldTime(hubWorldOverrideTime)
+ ?? parseHubWorldTime(DEFAULT_HUB_WORLD_OVERRIDE_TIME)
+ ?? 12 * 60
+ }
+ return getSystemClockMinutes()
+ }, [hubWorldOverrideTime, hubWorldTimeMode])
+
return (
@@ -416,6 +448,80 @@ export function SettingsPage({
)}
+ {activeSection === 'hubWorld' && (
+
+
+ {[
+ {
+ id: 'system',
+ label: 'Use System Clock',
+ description: 'Keep the hub synced to the current local time on this computer.',
+ icon: Clock3,
+ selected: hubWorldTimeMode === 'system'
+ },
+ {
+ id: 'override',
+ label: 'Override Time',
+ description: 'Freeze the hub world to a specific time of day until you switch back.',
+ icon: Sun,
+ selected: hubWorldTimeMode === 'override'
+ }
+ ].map((option) => {
+ const Icon = option.icon
+ return (
+
void setSetting(HUB_WORLD_TIME_MODE_KEY, option.id)}
+ >
+
+ {option.label}
+
+ {option.description}
+
+
+ )
+ })}
+
+
+
+
Forced Time
+
{
+ const value = event.target.value
+ setHubWorldOverrideTime(value)
+ if (parseHubWorldTime(value) !== null) {
+ void setSetting(HUB_WORLD_OVERRIDE_TIME_KEY, value)
+ }
+ }}
+ onBlur={() => {
+ const nextValue = sanitizeHubWorldOverrideTime(hubWorldOverrideTime)
+ setHubWorldOverrideTime(nextValue)
+ void setSetting(HUB_WORLD_OVERRIDE_TIME_KEY, nextValue)
+ }}
+ />
+
+ {hubWorldTimeMode === 'override'
+ ? `The hub world is locked to ${formatHubWorldTimeLabel(hubWorldPreviewMinutes)}.`
+ : `Currently following your system clock: ${formatHubWorldTimeLabel(hubWorldPreviewMinutes)}.`}
+
+
+
+ )}
+
{activeSection === 'agents' && (
<>
=13.0.0" } }, "sha512-GaXHDhiT7KCvMJjXdp/QqpYinq69T/Pdl49Z1XLf8mKGf63dnsODMWyrmIjEQ0z/vG7dO8qF3fvmI6Eb2lUNZA=="],
@@ -251,8 +261,12 @@
"@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="],
+ "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="],
+
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
+ "@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="],
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -267,6 +281,8 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
+ "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
+
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
@@ -333,6 +349,22 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
+ "@react-spring/animated": ["@react-spring/animated@9.7.5", "", { "dependencies": { "@react-spring/shared": "~9.7.5", "@react-spring/types": "~9.7.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg=="],
+
+ "@react-spring/core": ["@react-spring/core@9.7.5", "", { "dependencies": { "@react-spring/animated": "~9.7.5", "@react-spring/shared": "~9.7.5", "@react-spring/types": "~9.7.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w=="],
+
+ "@react-spring/rafz": ["@react-spring/rafz@9.7.5", "", {}, "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw=="],
+
+ "@react-spring/shared": ["@react-spring/shared@9.7.5", "", { "dependencies": { "@react-spring/rafz": "~9.7.5", "@react-spring/types": "~9.7.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw=="],
+
+ "@react-spring/three": ["@react-spring/three@9.7.5", "", { "dependencies": { "@react-spring/animated": "~9.7.5", "@react-spring/core": "~9.7.5", "@react-spring/shared": "~9.7.5", "@react-spring/types": "~9.7.5" }, "peerDependencies": { "@react-three/fiber": ">=6.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "three": ">=0.126" } }, "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA=="],
+
+ "@react-spring/types": ["@react-spring/types@9.7.5", "", {}, "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g=="],
+
+ "@react-three/drei": ["@react-three/drei@9.117.0", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@react-spring/three": "~9.7.5", "@use-gesture/react": "^10.3.1", "camera-controls": "^2.9.0", "cross-env": "^7.0.3", "detect-gpu": "^5.0.56", "glsl-noise": "^0.0.0", "hls.js": "^1.5.17", "maath": "^0.10.8", "meshline": "^3.3.1", "react-composer": "^5.0.3", "stats-gl": "^2.2.8", "stats.js": "^0.17.0", "suspend-react": "^0.1.3", "three-mesh-bvh": "^0.7.8", "three-stdlib": "^2.34.0", "troika-three-text": "^0.52.0", "tunnel-rat": "^0.1.2", "utility-types": "^3.11.0", "uuid": "^9.0.1", "zustand": "^3.7.1" }, "peerDependencies": { "@react-three/fiber": ">=8.0", "react": ">=18.0", "react-dom": ">=18.0", "three": ">=0.137" }, "optionalPeers": ["react-dom"] }, "sha512-UuYnM/qNiP+37R2dVR68R9ufGrWI/OdtDxfJRxtHaXyHWWfj7muEy4vbLZRe95a4fGEfaKy0gdplbG2BFedHtg=="],
+
+ "@react-three/fiber": ["@react-three/fiber@8.17.10", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/debounce": "^1.2.1", "@types/react-reconciler": "^0.26.7", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "debounce": "^1.2.1", "its-fine": "^1.0.6", "react-reconciler": "^0.27.0", "scheduler": "^0.21.0", "suspend-react": "^0.1.3", "zustand": "^3.7.1" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=18.0", "react-dom": ">=18.0", "react-native": ">=0.64", "three": ">=0.133" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-S6bqa4DqUooEkInYv/W+Jklv2zjSYCXAhm6qKpAQyOXhTEt5gBXnA7W6aoJ0bjmp9pAeaSj/AZUoz1HCSof/uA=="],
+
"@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
@@ -393,6 +425,12 @@
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
+ "@takram/three-atmosphere": ["@takram/three-atmosphere@0.17.0", "", { "dependencies": { "@takram/three-geospatial": "0.7.0", "astronomy-engine": "^2.1.19", "react-merge-refs": "^3.0.2", "tiny-invariant": "^1.3.3", "url-join": "^5.0.0" }, "peerDependencies": { "@react-three/drei": ">=10.0.2", "@react-three/fiber": ">=9.0.4", "@react-three/postprocessing": ">=3.0.4", "postprocessing": ">=6.38.0", "react": ">=19.0", "three": ">=0.170.0" }, "optionalPeers": ["@react-three/drei", "@react-three/fiber", "@react-three/postprocessing", "react"] }, "sha512-Ai4dbxCjUkqPYCc4up+yevHERyVhT4m63qYUbX9wLFqooikdsEQhjeMUgSwXR2gMf9++9MbbKMjX66/giavUQg=="],
+
+ "@takram/three-clouds": ["@takram/three-clouds@0.7.1", "", { "dependencies": { "@takram/three-atmosphere": "0.17.0", "@takram/three-geospatial": "0.7.0", "tiny-invariant": "^1.3.3", "type-fest": "^5.4.4" }, "peerDependencies": { "@react-three/fiber": ">=9.0.4", "@react-three/postprocessing": ">=3.0.4", "postprocessing": ">=6.38.0", "react": ">=19.0", "three": ">=0.170.0" }, "optionalPeers": ["@react-three/fiber", "@react-three/postprocessing", "react"] }, "sha512-DptL622cMFWXK6sr0GiapyMoUyQwKlP81djlu6aOhqA/J3i0vj3Xs6LoefVnBG/6VwVjS5s7g8rdx+q0HqELyQ=="],
+
+ "@takram/three-geospatial": ["@takram/three-geospatial@0.7.0", "", { "dependencies": { "@petamoriken/float16": "^3.9.3", "tiny-invariant": "^1.3.3", "type-fest": "^5.4.4" }, "peerDependencies": { "@react-three/fiber": ">=9.0.4", "react": ">=19.0", "three": ">=0.170.0" } }, "sha512-5Ldtb3ESP8w/ij/MmYA00Z2XtptOs4YCC8RCDCSUoWeVG5/PXcVdmBeRUz8AYSwIdXZr7Ww0vn01SO9/msaK9A=="],
+
"@tiptap/core": ["@tiptap/core@2.27.2", "", { "peerDependencies": { "@tiptap/pm": "^2.7.0" } }, "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ=="],
"@tiptap/extension-bold": ["@tiptap/extension-bold@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A=="],
@@ -429,6 +467,8 @@
"@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="],
+ "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
+
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -453,8 +493,12 @@
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
+ "@types/debounce": ["@types/debounce@1.2.4", "", {}, "sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw=="],
+
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
+ "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
@@ -473,6 +517,8 @@
"@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="],
+ "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
+
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
@@ -481,18 +527,32 @@
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
+ "@types/react-reconciler": ["@types/react-reconciler@0.26.7", "", { "dependencies": { "@types/react": "*" } }, "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ=="],
+
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
+ "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
+
+ "@types/three": ["@types/three@0.160.0", "", { "dependencies": { "@types/stats.js": "*", "@types/webxr": "*", "fflate": "~0.6.10", "meshoptimizer": "~0.18.1" } }, "sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w=="],
+
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
+ "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
+
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
+ "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="],
+
+ "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="],
+
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
+ "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
+
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
@@ -539,6 +599,8 @@
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
+ "astronomy-engine": ["astronomy-engine@2.1.19", "", {}, "sha512-8yWKNf7UeNbH458h3sAJ6ZgAjE5jTXp/mNNRFoC20j2SHwZIjAQeEsBB2Q3uCFRaTCCJRv33K2XhkhZQMXoX6w=="],
+
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
@@ -557,6 +619,8 @@
"better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
+ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
+
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
@@ -571,7 +635,7 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
- "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
+ "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
@@ -593,6 +657,8 @@
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
+ "camera-controls": ["camera-controls@2.10.1", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w=="],
+
"caniuse-lite": ["caniuse-lite@1.0.30001775", "", {}, "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -649,6 +715,8 @@
"cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
+ "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="],
+
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
@@ -673,6 +741,8 @@
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
+ "debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="],
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
@@ -689,6 +759,8 @@
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
+ "detect-gpu": ["detect-gpu@5.0.70", "", { "dependencies": { "webgl-constants": "^1.1.1" } }, "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w=="],
+
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
@@ -709,6 +781,8 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
+ "draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="],
+
"drizzle-orm": ["drizzle-orm@0.39.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-EZ8ZpYvDIvKU9C56JYLOmUskazhad+uXZCTCRN4OnRMsL+xAJ05dv1eCpAG5xzhsm1hqiuC5kAZUCS924u2DTw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -783,6 +857,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+ "fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="],
+
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
@@ -829,6 +905,8 @@
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
+ "glsl-noise": ["glsl-noise@0.0.0", "", {}, "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w=="],
+
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="],
@@ -845,6 +923,8 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+ "hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
+
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
@@ -863,6 +943,8 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+ "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
+
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
@@ -893,12 +975,16 @@
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
+ "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="],
+
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+ "its-fine": ["its-fine@1.2.5", "", { "dependencies": { "@types/react-reconciler": "^0.28.0" }, "peerDependencies": { "react": ">=18.0" } }, "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA=="],
+
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
@@ -927,6 +1013,8 @@
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
+ "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
+
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -949,6 +1037,8 @@
"lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
+ "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="],
+
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"make-fetch-happen": ["make-fetch-happen@10.2.1", "", { "dependencies": { "agentkeepalive": "^4.2.1", "cacache": "^16.1.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-fetch": "^2.0.3", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "promise-retry": "^2.0.1", "socks-proxy-agent": "^7.0.0", "ssri": "^9.0.0" } }, "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w=="],
@@ -965,6 +1055,10 @@
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
+ "meshline": ["meshline@3.3.1", "", { "peerDependencies": { "three": ">=0.137" } }, "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ=="],
+
+ "meshoptimizer": ["meshoptimizer@0.18.1", "", {}, "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw=="],
+
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
@@ -1087,6 +1181,10 @@
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
+ "postprocessing": ["postprocessing@6.38.3", "", { "peerDependencies": { "three": ">= 0.157.0 < 0.184.0" } }, "sha512-5qCFp8j62nWL6sSVv/RKuHscQUIV+VMMgWeHLYZQEBpAk7G+r3jA3bSKON7gZjiuxdZ/F4PXj2Jc1oPh/7Eg+g=="],
+
+ "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="],
+
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"proc-log": ["proc-log@2.0.1", "", {}, "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw=="],
@@ -1097,6 +1195,10 @@
"promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
+ "promise-worker-transferable": ["promise-worker-transferable@1.0.4", "", { "dependencies": { "is-promise": "^2.1.0", "lie": "^3.0.2" } }, "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw=="],
+
+ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
+
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"prosemirror-changeset": ["prosemirror-changeset@2.4.0", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng=="],
@@ -1149,8 +1251,16 @@
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
+ "react-composer": ["react-composer@5.0.3", "", { "dependencies": { "prop-types": "^15.6.0" }, "peerDependencies": { "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA=="],
+
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
+ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+
+ "react-merge-refs": ["react-merge-refs@3.0.2", "", { "peerDependencies": { "react": ">=16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["react"] }, "sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw=="],
+
+ "react-reconciler": ["react-reconciler@0.29.2", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg=="],
+
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
@@ -1173,6 +1283,8 @@
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
+ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
+
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
@@ -1205,7 +1317,7 @@
"sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
- "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+ "scheduler": ["scheduler@0.21.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -1247,6 +1359,10 @@
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
+ "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="],
+
+ "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="],
+
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -1267,6 +1383,10 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+ "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="],
+
+ "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
+
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
@@ -1287,8 +1407,16 @@
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
+ "three": ["three@0.160.0", "", {}, "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng=="],
+
+ "three-mesh-bvh": ["three-mesh-bvh@0.7.8", "", { "peerDependencies": { "three": ">= 0.151.0" } }, "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw=="],
+
+ "three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="],
+
"tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
+ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
+
"tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@@ -1301,6 +1429,12 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
+ "troika-three-text": ["troika-three-text@0.52.4", "", { "dependencies": { "bidi-js": "^1.0.2", "troika-three-utils": "^0.52.4", "troika-worker-utils": "^0.52.0", "webgl-sdf-generator": "1.1.1" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg=="],
+
+ "troika-three-utils": ["troika-three-utils@0.52.4", "", { "peerDependencies": { "three": ">=0.125.0" } }, "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A=="],
+
+ "troika-worker-utils": ["troika-worker-utils@0.52.0", "", {}, "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw=="],
+
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
@@ -1309,9 +1443,11 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
+ "tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="],
+
"turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="],
- "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
+ "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@@ -1329,6 +1465,8 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+ "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="],
+
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
@@ -1339,6 +1477,10 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
+
+ "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
+
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
@@ -1347,6 +1489,10 @@
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
+ "webgl-constants": ["webgl-constants@1.1.1", "", {}, "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="],
+
+ "webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="],
+
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -1371,7 +1517,7 @@
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
- "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
+ "zustand": ["zustand@3.7.2", "", { "peerDependencies": { "react": ">=16.8" }, "optionalPeers": ["react"] }, "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -1423,6 +1569,16 @@
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "@react-three/fiber/react-reconciler": ["react-reconciler@0.27.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.21.0" }, "peerDependencies": { "react": "^18.0.0" } }, "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA=="],
+
+ "@takram/three-atmosphere/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="],
+
+ "@takram/three-clouds/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="],
+
+ "@takram/three-geospatial/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="],
+
+ "@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
+
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="],
@@ -1439,12 +1595,16 @@
"app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
+ "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
+
"cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
+ "crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
+
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -1465,6 +1625,8 @@
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
+ "its-fine/@types/react-reconciler": ["@types/react-reconciler@0.28.9", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg=="],
+
"make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="],
"make-fetch-happen/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
@@ -1501,12 +1663,22 @@
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
+ "react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+
+ "react-reconciler/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
+
"socks-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
+ "stats-gl/@types/three": ["@types/three@0.183.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~1.0.1" } }, "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw=="],
+
+ "stats-gl/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="],
+
"tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"temp/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
@@ -1517,6 +1689,8 @@
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
+
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
@@ -1589,6 +1763,10 @@
"rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
+ "stats-gl/@types/three/fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
+
+ "stats-gl/@types/three/meshoptimizer": ["meshoptimizer@1.0.1", "", {}, "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g=="],
+
"temp/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],