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 ( +
+ +

{data.label}

+
+ ) +} + +function BoardNode({ data }: { data: { label: string } }) { + return ( +
+ +

{data.label}

+
+ ) +} + +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) => ( +
+
+ ) +} + + +/* ------------------------------------------------------------------ */ +/* 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 */} +
+ + + +
+ + {/* 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 = ( + + ) + + // 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 ( +
+

Loading...

+
+ ) + } + + 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 {
+ )} + {cameFromHub && !showHubView && !isSettingsRoute && ( + + )}

- {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 && (
+ ) + })} +
+ +
+ + { + 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=="],