@principal-ai/file-city-react 0.5.42 → 0.5.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,6 +41,32 @@ declare module 'react' {
41
41
  export type { CityData, CityBuilding, CityDistrict, LayerItem, LayerRenderStrategy };
42
42
  export type HighlightLayer = BuilderHighlightLayer;
43
43
 
44
+ /** Visual style for the `selectedPath` ring on a directory. */
45
+ export interface SelectionStyle {
46
+ /** Ring color. Defaults to the theme accent. */
47
+ color?: string;
48
+ /** Ring border width in world units. Default: 2. */
49
+ borderWidth?: number;
50
+ }
51
+
52
+ /**
53
+ * Per-frame camera callback signature. Fires once per R3F render frame from
54
+ * inside the Canvas, so projection done in the callback is in lockstep with
55
+ * the city render — useful for HTML/SVG overlays that need to track world
56
+ * positions (e.g. leader lines anchored to buildings).
57
+ *
58
+ * The callback receives the live `THREE.Camera` and the current canvas
59
+ * size in CSS pixels. To project a world point to canvas-local pixels:
60
+ *
61
+ * const v = new THREE.Vector3(x, y, z).project(camera);
62
+ * const px = (v.x * 0.5 + 0.5) * size.width;
63
+ * const py = (v.y * -0.5 + 0.5) * size.height;
64
+ */
65
+ export type OnCameraFrame = (
66
+ camera: THREE.Camera,
67
+ size: { width: number; height: number },
68
+ ) => void;
69
+
44
70
  /** What to do with non-highlighted buildings */
45
71
  export type IsolationMode =
46
72
  | 'none' // Show all buildings normally
@@ -710,7 +736,7 @@ function BorderHighlights({
710
736
  return (
711
737
  <instancedMesh ref={meshRef} args={[undefined, undefined, borderEdgeData.length]} frustumCulled={false}>
712
738
  <boxGeometry args={[1, 1, 1]} />
713
- <meshBasicMaterial transparent opacity={0.9} vertexColors />
739
+ <meshBasicMaterial transparent opacity={0.9} />
714
740
  </instancedMesh>
715
741
  );
716
742
  }
@@ -735,6 +761,7 @@ interface InstancedBuildingsProps {
735
761
  focusDirectory: string | null;
736
762
  highlightLayers: HighlightLayer[];
737
763
  isolationMode: IsolationMode;
764
+ defaultBuildingColor?: string;
738
765
  }
739
766
 
740
767
  // Helper to check if a path is inside a directory
@@ -759,6 +786,7 @@ function InstancedBuildings({
759
786
  focusDirectory,
760
787
  highlightLayers,
761
788
  isolationMode,
789
+ defaultBuildingColor,
762
790
  }: InstancedBuildingsProps) {
763
791
  const meshRef = useRef<THREE.InstancedMesh>(null);
764
792
  const startTimeRef = useRef<number | null>(null);
@@ -844,7 +872,7 @@ function InstancedBuildings({
844
872
  // Get all layer matches and find first fill match for building color
845
873
  const matches = getLayerMatchesForPath(building.path, highlightLayers);
846
874
  const fillMatch = matches.find(m => m.renderStrategy === 'fill');
847
- const color = fillMatch?.color ?? getColorForFile(building);
875
+ const color = fillMatch?.color ?? defaultBuildingColor ?? getColorForFile(building);
848
876
 
849
877
  const x = building.position.x - centerOffset.x;
850
878
  const z = building.position.z - centerOffset.z;
@@ -873,6 +901,7 @@ function InstancedBuildings({
873
901
  staggerIndices,
874
902
  animationConfig.staggerDelay,
875
903
  highlightLayers,
904
+ defaultBuildingColor,
876
905
  ]);
877
906
 
878
907
  const minHeight = 0.3;
@@ -1529,6 +1558,19 @@ export function getCameraTilt() {
1529
1558
  return cameraApi?.getCurrentTilt() ?? null;
1530
1559
  }
1531
1560
 
1561
+ /**
1562
+ * Bridge for piping the live camera + canvas size out of the R3F Canvas on
1563
+ * every frame. Mounted as a child of `<Canvas>` so it has access to the R3F
1564
+ * render loop; runs zero work if no callback is provided.
1565
+ */
1566
+ function CameraFrameBridge({ onCameraFrame }: { onCameraFrame?: OnCameraFrame }) {
1567
+ const { camera, size } = useThree();
1568
+ useFrame(() => {
1569
+ onCameraFrame?.(camera, { width: size.width, height: size.height });
1570
+ });
1571
+ return null;
1572
+ }
1573
+
1532
1574
  const AnimatedCamera = React.memo(function AnimatedCamera({
1533
1575
  citySize,
1534
1576
  isFlat,
@@ -2394,6 +2436,279 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera, onLookDown }: Contro
2394
2436
  );
2395
2437
  }
2396
2438
 
2439
+ // Distance (world units) the panel lifts upward when dismissed. Reads as
2440
+ // "toward the camera" because the camera looks down in flat mode.
2441
+ const PANEL_DISMISS_LIFT = 60;
2442
+
2443
+ interface ElevatedScopePanelMeshProps {
2444
+ panel: ElevatedScopePanel;
2445
+ centerOffset: { x: number; z: number };
2446
+ dismissing: boolean;
2447
+ onDismissed?: (id: string) => void;
2448
+ }
2449
+
2450
+ // Dismiss animation duration in seconds.
2451
+ const PANEL_DISMISS_DURATION = 0.7;
2452
+
2453
+ function ElevatedScopePanelMesh({
2454
+ panel,
2455
+ centerOffset,
2456
+ dismissing,
2457
+ onDismissed,
2458
+ }: ElevatedScopePanelMeshProps) {
2459
+ const cx = (panel.bounds.minX + panel.bounds.maxX) / 2 - centerOffset.x;
2460
+ const cz = (panel.bounds.minZ + panel.bounds.maxZ) / 2 - centerOffset.z;
2461
+ const w = Math.max(1, panel.bounds.maxX - panel.bounds.minX);
2462
+ const d = Math.max(1, panel.bounds.maxZ - panel.bounds.minZ);
2463
+ const t = panel.thickness ?? 2;
2464
+ const y = (panel.height ?? 4) + t / 2;
2465
+ const baseOpacity = panel.opacity ?? 1;
2466
+ const isOpaqueStatic = baseOpacity >= 1;
2467
+ const topY = y + t / 2;
2468
+ const tileMax = Math.min(w, d) / 2;
2469
+ const requested = panel.labelSize ?? Math.min(w, d) / 6;
2470
+ const labelSize = Math.max(4, Math.min(tileMax, requested));
2471
+
2472
+ // Drive the dismiss animation directly via useFrame + Three.js refs
2473
+ // rather than react-spring. React re-renders (e.g., from FileCity3D's
2474
+ // hover state on mouse-move) were disturbing the spring-driven
2475
+ // animation; mutating the Three.js objects directly keeps the
2476
+ // animation isolated from React's render cycle.
2477
+ const groupRef = useRef<THREE.Group>(null);
2478
+ const slabMaterialRef = useRef<THREE.MeshBasicMaterial>(null);
2479
+ const labelMaterialRefs = useRef<THREE.MeshBasicMaterial[]>([]);
2480
+ const startTimeRef = useRef<number | null>(null);
2481
+ const finishedRef = useRef(false);
2482
+ const [fullyDismissed, setFullyDismissed] = useState(false);
2483
+
2484
+ useFrame(({ clock }) => {
2485
+ if (!dismissing || finishedRef.current) return;
2486
+ if (startTimeRef.current === null) {
2487
+ startTimeRef.current = clock.elapsedTime;
2488
+ }
2489
+ const elapsed = clock.elapsedTime - startTimeRef.current;
2490
+ const t = Math.min(elapsed / PANEL_DISMISS_DURATION, 1);
2491
+ // ease-out cubic
2492
+ const eased = 1 - Math.pow(1 - t, 3);
2493
+
2494
+ if (groupRef.current) {
2495
+ groupRef.current.position.y = eased * PANEL_DISMISS_LIFT;
2496
+ }
2497
+ const opacityNow = baseOpacity * (1 - eased);
2498
+ if (slabMaterialRef.current) {
2499
+ slabMaterialRef.current.opacity = opacityNow;
2500
+ }
2501
+ for (const mat of labelMaterialRefs.current) {
2502
+ if (mat) mat.opacity = opacityNow;
2503
+ }
2504
+
2505
+ if (t >= 1) {
2506
+ finishedRef.current = true;
2507
+ setFullyDismissed(true);
2508
+ onDismissed?.(panel.id);
2509
+ }
2510
+ });
2511
+
2512
+ if (fullyDismissed) return null;
2513
+
2514
+ // Always transparent on the slab so animated opacity actually blends.
2515
+ // (Three.js skips alpha blending entirely when transparent=false, which
2516
+ // makes the fade invisible until the prop flips mid-animation.)
2517
+ // depthWrite stays true while the panel is at full opacity so it still
2518
+ // occludes buildings beneath; we drop it once dismissal starts so the
2519
+ // fading panel doesn't punch a hole in the scene.
2520
+ const slabDepthWrite = isOpaqueStatic && !dismissing;
2521
+
2522
+ const interactive = Boolean(panel.onClick || panel.onDoubleClick);
2523
+ const handleClick = panel.onClick
2524
+ ? (e: ThreeEvent<MouseEvent>) => {
2525
+ e.stopPropagation();
2526
+ panel.onClick!(e.nativeEvent);
2527
+ }
2528
+ : undefined;
2529
+ const handleDoubleClick = panel.onDoubleClick
2530
+ ? (e: ThreeEvent<MouseEvent>) => {
2531
+ e.stopPropagation();
2532
+ panel.onDoubleClick!(e.nativeEvent);
2533
+ }
2534
+ : undefined;
2535
+ const handlePointerOver = interactive
2536
+ ? (e: ThreeEvent<PointerEvent>) => {
2537
+ e.stopPropagation();
2538
+ document.body.style.cursor = 'pointer';
2539
+ }
2540
+ : undefined;
2541
+ const handlePointerOut = interactive
2542
+ ? () => {
2543
+ document.body.style.cursor = '';
2544
+ }
2545
+ : undefined;
2546
+
2547
+ const labelColor = panel.labelColor ?? '#ffffff';
2548
+ const displayLabelColor = panel.displayLabelColor ?? panel.labelColor ?? '#ffffff';
2549
+
2550
+ // Reset the array each render — the ref callbacks below will repopulate
2551
+ // it. This avoids stale entries if labels come and go.
2552
+ labelMaterialRefs.current = [];
2553
+ const captureLabelMat = (mat: THREE.MeshBasicMaterial | null) => {
2554
+ if (mat) labelMaterialRefs.current.push(mat);
2555
+ };
2556
+
2557
+ return (
2558
+ <group ref={groupRef}>
2559
+ <mesh
2560
+ position={[cx, y, cz]}
2561
+ renderOrder={10}
2562
+ onClick={dismissing ? undefined : handleClick}
2563
+ onDoubleClick={dismissing ? undefined : handleDoubleClick}
2564
+ onPointerOver={dismissing ? undefined : handlePointerOver}
2565
+ onPointerOut={dismissing ? undefined : handlePointerOut}
2566
+ >
2567
+ <boxGeometry args={[w, t, d]} />
2568
+ <meshBasicMaterial
2569
+ ref={slabMaterialRef}
2570
+ color={panel.color}
2571
+ transparent
2572
+ opacity={baseOpacity}
2573
+ depthWrite={slabDepthWrite}
2574
+ />
2575
+ </mesh>
2576
+ {panel.displayLabel && (
2577
+ <>
2578
+ <Text
2579
+ position={[cx, topY + 0.05, cz - labelSize * 0.6]}
2580
+ rotation={[-Math.PI / 2, 0, 0]}
2581
+ fontSize={labelSize}
2582
+ color={displayLabelColor}
2583
+ anchorX="center"
2584
+ anchorY="middle"
2585
+ maxWidth={w * 0.9}
2586
+ textAlign="center"
2587
+ renderOrder={11}
2588
+ frustumCulled={false}
2589
+ >
2590
+ {panel.displayLabel}
2591
+ <meshBasicMaterial
2592
+ ref={captureLabelMat}
2593
+ attach="material"
2594
+ color={displayLabelColor}
2595
+ depthWrite={false}
2596
+ depthTest={false}
2597
+ transparent
2598
+ opacity={baseOpacity}
2599
+ />
2600
+ </Text>
2601
+ <mesh
2602
+ position={[cx, topY + 0.06, cz - labelSize * 0.05]}
2603
+ rotation={[-Math.PI / 2, 0, 0]}
2604
+ renderOrder={11}
2605
+ >
2606
+ <planeGeometry
2607
+ args={[
2608
+ Math.min(w * 0.9, panel.displayLabel.length * labelSize * 0.55),
2609
+ labelSize * 0.06,
2610
+ ]}
2611
+ />
2612
+ <meshBasicMaterial
2613
+ ref={captureLabelMat}
2614
+ color={displayLabelColor}
2615
+ depthWrite={false}
2616
+ depthTest={false}
2617
+ transparent
2618
+ opacity={baseOpacity}
2619
+ />
2620
+ </mesh>
2621
+ </>
2622
+ )}
2623
+ {panel.label && (
2624
+ <Text
2625
+ position={[cx, topY + 0.05, cz + (panel.displayLabel ? labelSize * 0.6 : 0)]}
2626
+ rotation={[-Math.PI / 2, 0, 0]}
2627
+ fontSize={labelSize}
2628
+ color={labelColor}
2629
+ anchorX="center"
2630
+ anchorY="middle"
2631
+ maxWidth={w * 0.9}
2632
+ textAlign="center"
2633
+ renderOrder={11}
2634
+ frustumCulled={false}
2635
+ >
2636
+ {panel.label}
2637
+ <meshBasicMaterial
2638
+ ref={captureLabelMat}
2639
+ attach="material"
2640
+ color={labelColor}
2641
+ depthWrite={false}
2642
+ depthTest={false}
2643
+ transparent
2644
+ opacity={baseOpacity}
2645
+ />
2646
+ </Text>
2647
+ )}
2648
+ </group>
2649
+ );
2650
+ }
2651
+
2652
+ interface SelectionRingProps {
2653
+ district: CityDistrict;
2654
+ centerOffset: { x: number; z: number };
2655
+ color: string;
2656
+ borderWidth: number;
2657
+ growProgress: number;
2658
+ }
2659
+
2660
+ // Lifted just above the default umbrella topY (height 4 + thickness 2 → 6)
2661
+ // so the ring isn't occluded by an `ElevatedScopePanel` covering the same
2662
+ // district when the city is flat.
2663
+ const SELECTION_RING_FLAT_Y = 7;
2664
+
2665
+ function SelectionRing({
2666
+ district,
2667
+ centerOffset,
2668
+ color,
2669
+ borderWidth,
2670
+ growProgress,
2671
+ }: SelectionRingProps) {
2672
+ const { worldBounds } = district;
2673
+ const inflate = 1;
2674
+ const minX = worldBounds.minX - inflate;
2675
+ const maxX = worldBounds.maxX + inflate;
2676
+ const minZ = worldBounds.minZ - inflate;
2677
+ const maxZ = worldBounds.maxZ + inflate;
2678
+ const cx = (minX + maxX) / 2 - centerOffset.x;
2679
+ const cz = (minZ + maxZ) / 2 - centerOffset.z;
2680
+ const w = maxX - minX;
2681
+ const d = maxZ - minZ;
2682
+
2683
+ const pathDepth = district.path.split('/').length;
2684
+ const groundY = -5 - pathDepth * 0.1 + 0.2;
2685
+ const y = SELECTION_RING_FLAT_Y + (groundY - SELECTION_RING_FLAT_Y) * growProgress;
2686
+
2687
+ const t = Math.max(0.5, borderWidth);
2688
+ const barH = 0.5;
2689
+
2690
+ return (
2691
+ <group position={[cx, y, cz]}>
2692
+ <mesh position={[0, 0, -d / 2]} renderOrder={20}>
2693
+ <boxGeometry args={[w + t, barH, t]} />
2694
+ <meshBasicMaterial color={color} transparent opacity={0.95} />
2695
+ </mesh>
2696
+ <mesh position={[0, 0, d / 2]} renderOrder={20}>
2697
+ <boxGeometry args={[w + t, barH, t]} />
2698
+ <meshBasicMaterial color={color} transparent opacity={0.95} />
2699
+ </mesh>
2700
+ <mesh position={[-w / 2, 0, 0]} renderOrder={20}>
2701
+ <boxGeometry args={[t, barH, d + t]} />
2702
+ <meshBasicMaterial color={color} transparent opacity={0.95} />
2703
+ </mesh>
2704
+ <mesh position={[w / 2, 0, 0]} renderOrder={20}>
2705
+ <boxGeometry args={[t, barH, d + t]} />
2706
+ <meshBasicMaterial color={color} transparent opacity={0.95} />
2707
+ </mesh>
2708
+ </group>
2709
+ );
2710
+ }
2711
+
2397
2712
  // Main scene component
2398
2713
  interface CitySceneProps {
2399
2714
  cityData: CityData;
@@ -2401,6 +2716,8 @@ interface CitySceneProps {
2401
2716
  onBuildingClick?: (building: CityBuilding, event: MouseEvent) => void;
2402
2717
  hoveredBuilding: CityBuilding | null;
2403
2718
  selectedBuilding: CityBuilding | null;
2719
+ selectedDistrict: CityDistrict | null;
2720
+ selectionStyle?: SelectionStyle;
2404
2721
  growProgress: number;
2405
2722
  animationConfig: AnimationConfig;
2406
2723
  highlightLayers: HighlightLayer[];
@@ -2412,7 +2729,10 @@ interface CitySceneProps {
2412
2729
  focusColor?: string | null;
2413
2730
  adaptCameraToBuildings?: boolean;
2414
2731
  elevatedScopePanels?: ElevatedScopePanel[];
2732
+ dismissingPanelIds?: ReadonlySet<string>;
2733
+ onPanelDismissed?: (id: string) => void;
2415
2734
  cameraControls?: CameraControlsConfig;
2735
+ defaultBuildingColor?: string;
2416
2736
  }
2417
2737
 
2418
2738
  function CityScene({
@@ -2421,6 +2741,8 @@ function CityScene({
2421
2741
  onBuildingClick,
2422
2742
  hoveredBuilding,
2423
2743
  selectedBuilding,
2744
+ selectedDistrict,
2745
+ selectionStyle,
2424
2746
  growProgress,
2425
2747
  animationConfig,
2426
2748
  highlightLayers,
@@ -2432,7 +2754,10 @@ function CityScene({
2432
2754
  focusColor,
2433
2755
  adaptCameraToBuildings = false,
2434
2756
  elevatedScopePanels,
2757
+ dismissingPanelIds,
2758
+ onPanelDismissed,
2435
2759
  cameraControls,
2760
+ defaultBuildingColor,
2436
2761
  onCameraReady,
2437
2762
  }: CitySceneProps & { onCameraReady?: () => void }) {
2438
2763
  const centerOffset = useMemo(
@@ -2739,6 +3064,7 @@ function CityScene({
2739
3064
  focusDirectory={buildingFocusDirectory}
2740
3065
  highlightLayers={highlightLayers}
2741
3066
  isolationMode={isolationMode}
3067
+ defaultBuildingColor={defaultBuildingColor}
2742
3068
  />
2743
3069
 
2744
3070
  <BuildingIcons
@@ -2754,137 +3080,25 @@ function CityScene({
2754
3080
  />
2755
3081
 
2756
3082
  {growProgress === 0 &&
2757
- elevatedScopePanels?.map(panel => {
2758
- const cx = (panel.bounds.minX + panel.bounds.maxX) / 2 - centerOffset.x;
2759
- const cz = (panel.bounds.minZ + panel.bounds.maxZ) / 2 - centerOffset.z;
2760
- const w = Math.max(1, panel.bounds.maxX - panel.bounds.minX);
2761
- const d = Math.max(1, panel.bounds.maxZ - panel.bounds.minZ);
2762
- const t = panel.thickness ?? 2;
2763
- const y = (panel.height ?? 4) + t / 2;
2764
- const opacity = panel.opacity ?? 1;
2765
- const isOpaque = opacity >= 1;
2766
- const topY = y + t / 2;
2767
- // Size text to the panel: roughly fit longest reasonable label,
2768
- // clamped so tiny tiles still render legibly and huge ones don't
2769
- // get absurd. Callers may override via panel.labelSize, but we
2770
- // still cap to the tile footprint so the label fits.
2771
- const tileMax = Math.min(w, d) / 2;
2772
- const requested = panel.labelSize ?? Math.min(w, d) / 6;
2773
- const labelSize = Math.max(4, Math.min(tileMax, requested));
2774
-
2775
- const interactive = Boolean(panel.onClick || panel.onDoubleClick);
2776
- const handleClick = panel.onClick
2777
- ? (e: ThreeEvent<MouseEvent>) => {
2778
- e.stopPropagation();
2779
- panel.onClick!(e.nativeEvent);
2780
- }
2781
- : undefined;
2782
- const handleDoubleClick = panel.onDoubleClick
2783
- ? (e: ThreeEvent<MouseEvent>) => {
2784
- e.stopPropagation();
2785
- panel.onDoubleClick!(e.nativeEvent);
2786
- }
2787
- : undefined;
2788
- const handlePointerOver = interactive
2789
- ? (e: ThreeEvent<PointerEvent>) => {
2790
- e.stopPropagation();
2791
- document.body.style.cursor = 'pointer';
2792
- }
2793
- : undefined;
2794
- const handlePointerOut = interactive
2795
- ? () => {
2796
- document.body.style.cursor = '';
2797
- }
2798
- : undefined;
2799
-
2800
- return (
2801
- <group key={panel.id}>
2802
- <mesh
2803
- position={[cx, y, cz]}
2804
- renderOrder={10}
2805
- onClick={handleClick}
2806
- onDoubleClick={handleDoubleClick}
2807
- onPointerOver={handlePointerOver}
2808
- onPointerOut={handlePointerOut}
2809
- >
2810
- <boxGeometry args={[w, t, d]} />
2811
- <meshBasicMaterial
2812
- color={panel.color}
2813
- transparent={!isOpaque}
2814
- opacity={opacity}
2815
- depthWrite={isOpaque}
2816
- />
2817
- </mesh>
2818
- {panel.displayLabel && (
2819
- <>
2820
- <Text
2821
- position={[cx, topY + 0.05, cz - labelSize * 0.6]}
2822
- rotation={[-Math.PI / 2, 0, 0]}
2823
- fontSize={labelSize}
2824
- color={panel.displayLabelColor ?? panel.labelColor ?? '#ffffff'}
2825
- anchorX="center"
2826
- anchorY="middle"
2827
- maxWidth={w * 0.9}
2828
- textAlign="center"
2829
- renderOrder={11}
2830
- frustumCulled={false}
2831
- >
2832
- {panel.displayLabel}
2833
- <meshBasicMaterial
2834
- attach="material"
2835
- color={panel.displayLabelColor ?? panel.labelColor ?? '#ffffff'}
2836
- depthWrite={false}
2837
- depthTest={false}
2838
- />
2839
- </Text>
2840
- {/* Underline rendered as a thin plane just below the
2841
- displayLabel. Width approximated from character count
2842
- (~0.55em advance) and clamped to the panel footprint. */}
2843
- <mesh
2844
- position={[cx, topY + 0.06, cz - labelSize * 0.05]}
2845
- rotation={[-Math.PI / 2, 0, 0]}
2846
- renderOrder={11}
2847
- >
2848
- <planeGeometry
2849
- args={[
2850
- Math.min(w * 0.9, panel.displayLabel.length * labelSize * 0.55),
2851
- labelSize * 0.06,
2852
- ]}
2853
- />
2854
- <meshBasicMaterial
2855
- color={panel.displayLabelColor ?? panel.labelColor ?? '#ffffff'}
2856
- depthWrite={false}
2857
- depthTest={false}
2858
- transparent
2859
- />
2860
- </mesh>
2861
- </>
2862
- )}
2863
- {panel.label && (
2864
- <Text
2865
- position={[cx, topY + 0.05, cz + (panel.displayLabel ? labelSize * 0.6 : 0)]}
2866
- rotation={[-Math.PI / 2, 0, 0]}
2867
- fontSize={labelSize}
2868
- color={panel.labelColor ?? '#ffffff'}
2869
- anchorX="center"
2870
- anchorY="middle"
2871
- maxWidth={w * 0.9}
2872
- textAlign="center"
2873
- renderOrder={11}
2874
- frustumCulled={false}
2875
- >
2876
- {panel.label}
2877
- <meshBasicMaterial
2878
- attach="material"
2879
- color={panel.labelColor ?? '#ffffff'}
2880
- depthWrite={false}
2881
- depthTest={false}
2882
- />
2883
- </Text>
2884
- )}
2885
- </group>
2886
- );
2887
- })}
3083
+ elevatedScopePanels?.map(panel => (
3084
+ <ElevatedScopePanelMesh
3085
+ key={panel.id}
3086
+ panel={panel}
3087
+ centerOffset={centerOffset}
3088
+ dismissing={dismissingPanelIds?.has(panel.id) ?? false}
3089
+ onDismissed={onPanelDismissed}
3090
+ />
3091
+ ))}
3092
+
3093
+ {selectedDistrict && (
3094
+ <SelectionRing
3095
+ district={selectedDistrict}
3096
+ centerOffset={centerOffset}
3097
+ color={selectionStyle?.color ?? '#facc15'}
3098
+ borderWidth={selectionStyle?.borderWidth ?? 2}
3099
+ growProgress={growProgress}
3100
+ />
3101
+ )}
2888
3102
  </>
2889
3103
  );
2890
3104
  }
@@ -2902,6 +3116,8 @@ export interface FileCity3DProps {
2902
3116
  height?: number | string;
2903
3117
  /** Callback when a building is clicked */
2904
3118
  onBuildingClick?: (building: CityBuilding, event: MouseEvent) => void;
3119
+ /** Callback when the hovered building changes (fires with null on hover-out) */
3120
+ onBuildingHover?: (building: CityBuilding | null) => void;
2905
3121
  /** CSS class name */
2906
3122
  className?: string;
2907
3123
  /** Inline styles */
@@ -2942,20 +3158,58 @@ export interface FileCity3DProps {
2942
3158
  backgroundColor?: string;
2943
3159
  /** Text color for secondary/placeholder text */
2944
3160
  textColor?: string;
2945
- /** Currently selected building (controlled by host) */
3161
+ /**
3162
+ * @deprecated Use `selectedPath` instead. When both are set, `selectedPath`
3163
+ * wins. This prop will be removed in a future release.
3164
+ */
2946
3165
  selectedBuilding?: CityBuilding | null;
3166
+
3167
+ /**
3168
+ * Path of the selected building or directory. The component resolves the
3169
+ * path against `cityData.buildings` (file selection — emphasizes the
3170
+ * building and shows the InfoPanel) and `cityData.districts` (directory
3171
+ * selection — draws a ring around the district). When both `selectedPath`
3172
+ * and `selectedBuilding` are set, `selectedPath` wins.
3173
+ */
3174
+ selectedPath?: string | null;
3175
+
3176
+ /** Visual style for the directory selection ring drawn for `selectedPath`. */
3177
+ selectionStyle?: SelectionStyle;
2947
3178
  /** When true, camera height adjusts based on tallest building when grown */
2948
3179
  adaptCameraToBuildings?: boolean;
2949
3180
 
2950
3181
  /** Base file type color layers (resolved with highlightLayers) */
2951
3182
  fileColorLayers?: HighlightLayer[];
2952
3183
 
3184
+ /**
3185
+ * Override the per-building color fallback. When unset (default), buildings
3186
+ * not matched by a fill highlight layer are colored by file extension via
3187
+ * the built-in file-type palette. Set to a CSS color (e.g. `'#475569'`) to
3188
+ * render unmatched buildings in a neutral tone — useful for debug stories
3189
+ * that want to isolate highlight-layer rendering.
3190
+ */
3191
+ defaultBuildingColor?: string;
3192
+
2953
3193
  /**
2954
3194
  * Translucent slabs rendered above the city showing scope coverage as
2955
3195
  * elevated planes over the directories they own.
2956
3196
  */
2957
3197
  elevatedScopePanels?: ElevatedScopePanel[];
2958
3198
 
3199
+ /**
3200
+ * Set of panel ids that should play the "lift up and fade out" dismiss
3201
+ * animation. Add an id here to start the animation; once it settles,
3202
+ * `onPanelDismissed` fires so the host can drop the panel from
3203
+ * `elevatedScopePanels`.
3204
+ */
3205
+ dismissingPanelIds?: ReadonlySet<string>;
3206
+
3207
+ /**
3208
+ * Fires once a panel's dismiss animation has settled. The host should
3209
+ * remove the id from both `dismissingPanelIds` and `elevatedScopePanels`.
3210
+ */
3211
+ onPanelDismissed?: (id: string) => void;
3212
+
2959
3213
  /**
2960
3214
  * Configure how mouse / trackpad / touch input drives the camera.
2961
3215
  * Defaults match Google Maps style: left-drag pans, right-drag rotates,
@@ -2965,6 +3219,15 @@ export interface FileCity3DProps {
2965
3219
  * Memoize this object to avoid unnecessary camera re-mounts.
2966
3220
  */
2967
3221
  cameraControls?: CameraControlsConfig;
3222
+
3223
+ /**
3224
+ * Fires once per R3F render frame with the live camera and canvas size.
3225
+ * Use to project world points to canvas pixels for HTML/SVG overlays that
3226
+ * need to track buildings as the camera pans / zooms / rotates.
3227
+ *
3228
+ * Memoize the callback to avoid re-mounting the bridge.
3229
+ */
3230
+ onCameraFrame?: OnCameraFrame;
2968
3231
  }
2969
3232
 
2970
3233
  /**
@@ -2978,6 +3241,7 @@ export function FileCity3D({
2978
3241
  width = '100%',
2979
3242
  height = 600,
2980
3243
  onBuildingClick,
3244
+ onBuildingHover,
2981
3245
  className,
2982
3246
  style,
2983
3247
  animation,
@@ -2985,6 +3249,8 @@ export function FileCity3D({
2985
3249
  onGrowChange,
2986
3250
  showControls = false,
2987
3251
  elevatedScopePanels,
3252
+ dismissingPanelIds,
3253
+ onPanelDismissed,
2988
3254
  highlightLayers: externalHighlightLayers,
2989
3255
  isolationMode: externalIsolationMode,
2990
3256
  dimOpacity: _dimOpacity = 0.15,
@@ -3000,14 +3266,26 @@ export function FileCity3D({
3000
3266
  backgroundColor = '#0f172a',
3001
3267
  textColor = '#94a3b8',
3002
3268
  selectedBuilding = null,
3269
+ selectedPath = null,
3270
+ selectionStyle,
3003
3271
  adaptCameraToBuildings = false,
3004
3272
  fileColorLayers,
3273
+ defaultBuildingColor,
3005
3274
  cameraControls,
3275
+ onCameraFrame,
3006
3276
  }: FileCity3DProps) {
3007
3277
  const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
3008
3278
  const [internalIsGrown, setInternalIsGrown] = useState(false);
3009
3279
  const [cameraReady, setCameraReady] = useState(false);
3010
3280
 
3281
+ const handleBuildingHover = useCallback(
3282
+ (building: CityBuilding | null) => {
3283
+ setHoveredBuilding(building);
3284
+ onBuildingHover?.(building);
3285
+ },
3286
+ [onBuildingHover],
3287
+ );
3288
+
3011
3289
  const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
3012
3290
 
3013
3291
  // ============================================================================
@@ -3045,6 +3323,22 @@ export function FileCity3D({
3045
3323
  const focusColor = resolved.focusColor;
3046
3324
  const isolationMode = resolved.isolationMode as IsolationMode;
3047
3325
 
3326
+ // `selectedPath` wins over the deprecated `selectedBuilding` when both are
3327
+ // set. A path resolves to either a building (file selection) or a district
3328
+ // (directory selection) — never both.
3329
+ const resolvedSelection = useMemo<{
3330
+ building: CityBuilding | null;
3331
+ district: CityDistrict | null;
3332
+ }>(() => {
3333
+ if (selectedPath != null) {
3334
+ const building = cityData.buildings.find(b => b.path === selectedPath) ?? null;
3335
+ if (building) return { building, district: null };
3336
+ const district = cityData.districts.find(d => d.path === selectedPath) ?? null;
3337
+ return { building: null, district };
3338
+ }
3339
+ return { building: selectedBuilding ?? null, district: null };
3340
+ }, [selectedPath, selectedBuilding, cityData.buildings, cityData.districts]);
3341
+
3048
3342
  const isGrown = externalIsGrown !== undefined ? externalIsGrown : internalIsGrown;
3049
3343
  const setIsGrown = (value: boolean) => {
3050
3344
  setInternalIsGrown(value);
@@ -3145,10 +3439,12 @@ export function FileCity3D({
3145
3439
  >
3146
3440
  <CityScene
3147
3441
  cityData={cityData}
3148
- onBuildingHover={setHoveredBuilding}
3442
+ onBuildingHover={handleBuildingHover}
3149
3443
  onBuildingClick={onBuildingClick}
3150
3444
  hoveredBuilding={hoveredBuilding}
3151
- selectedBuilding={selectedBuilding}
3445
+ selectedBuilding={resolvedSelection.building}
3446
+ selectedDistrict={resolvedSelection.district}
3447
+ selectionStyle={selectionStyle}
3152
3448
  growProgress={growProgress}
3153
3449
  animationConfig={animationConfig}
3154
3450
  highlightLayers={highlightLayers}
@@ -3160,11 +3456,15 @@ export function FileCity3D({
3160
3456
  focusColor={focusColor}
3161
3457
  adaptCameraToBuildings={adaptCameraToBuildings}
3162
3458
  elevatedScopePanels={elevatedScopePanels}
3459
+ dismissingPanelIds={dismissingPanelIds}
3460
+ onPanelDismissed={onPanelDismissed}
3163
3461
  cameraControls={cameraControls}
3462
+ defaultBuildingColor={defaultBuildingColor}
3164
3463
  onCameraReady={() => setCameraReady(true)}
3165
3464
  />
3465
+ {onCameraFrame && <CameraFrameBridge onCameraFrame={onCameraFrame} />}
3166
3466
  </Canvas>
3167
- <InfoPanel building={selectedBuilding} />
3467
+ <InfoPanel building={resolvedSelection.building} />
3168
3468
  {showControls && (
3169
3469
  <ControlsOverlay
3170
3470
  isFlat={!isGrown}