@principal-ai/file-city-react 0.5.34 → 0.5.36
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.
- package/dist/components/FileCity3D/FileCity3D.d.ts +3 -26
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +278 -191
- package/dist/components/FileCity3D/index.d.ts +1 -1
- package/dist/components/FileCity3D/index.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +402 -232
- package/src/components/FileCity3D/index.ts +2 -1
- package/src/index.ts +0 -2
- package/src/stories/2D3DComparison.stories.tsx +2 -2
- package/src/stories/FileCity3D.stories.tsx +65 -6
|
@@ -18,6 +18,9 @@ import type {
|
|
|
18
18
|
CityBuilding,
|
|
19
19
|
CityDistrict,
|
|
20
20
|
FileConfigResult,
|
|
21
|
+
HighlightLayer as BuilderHighlightLayer,
|
|
22
|
+
LayerItem,
|
|
23
|
+
LayerRenderStrategy,
|
|
21
24
|
} from '@principal-ai/file-city-builder';
|
|
22
25
|
import * as THREE from 'three';
|
|
23
26
|
import type { ThreeElements } from '@react-three/fiber';
|
|
@@ -33,34 +36,8 @@ declare module 'react' {
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
// Re-export types for convenience
|
|
36
|
-
export type { CityData, CityBuilding, CityDistrict };
|
|
37
|
-
|
|
38
|
-
// Highlight layer types
|
|
39
|
-
export interface HighlightLayer {
|
|
40
|
-
/** Unique identifier */
|
|
41
|
-
id: string;
|
|
42
|
-
/** Display name */
|
|
43
|
-
name: string;
|
|
44
|
-
/** Whether layer is active */
|
|
45
|
-
enabled: boolean;
|
|
46
|
-
/** Highlight color (hex) */
|
|
47
|
-
color: string;
|
|
48
|
-
/** Items to highlight */
|
|
49
|
-
items: HighlightItem[];
|
|
50
|
-
/** Opacity for highlighted items (0-1) */
|
|
51
|
-
opacity?: number;
|
|
52
|
-
/** Rendering priority (higher renders on top) */
|
|
53
|
-
priority?: number;
|
|
54
|
-
/** Border width in pixels */
|
|
55
|
-
borderWidth?: number;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface HighlightItem {
|
|
59
|
-
/** File or directory path */
|
|
60
|
-
path: string;
|
|
61
|
-
/** Type of item */
|
|
62
|
-
type: 'file' | 'directory';
|
|
63
|
-
}
|
|
39
|
+
export type { CityData, CityBuilding, CityDistrict, LayerItem, LayerRenderStrategy };
|
|
40
|
+
export type HighlightLayer = BuilderHighlightLayer;
|
|
64
41
|
|
|
65
42
|
/** What to do with non-highlighted buildings */
|
|
66
43
|
export type IsolationMode =
|
|
@@ -273,25 +250,71 @@ function getColorForFile(building: CityBuilding): string {
|
|
|
273
250
|
return getConfigForFile(building).color;
|
|
274
251
|
}
|
|
275
252
|
|
|
253
|
+
interface LayerMatch {
|
|
254
|
+
layer: HighlightLayer;
|
|
255
|
+
item: LayerItem;
|
|
256
|
+
color: string;
|
|
257
|
+
opacity: number;
|
|
258
|
+
borderWidth?: number;
|
|
259
|
+
renderStrategy: LayerRenderStrategy;
|
|
260
|
+
}
|
|
261
|
+
|
|
276
262
|
/**
|
|
277
|
-
*
|
|
263
|
+
* Get ALL layer matches for a path, sorted by priority (highest first).
|
|
264
|
+
* Returns array to support multiple layers rendering together (e.g., fill + border).
|
|
278
265
|
*/
|
|
279
|
-
function
|
|
266
|
+
function getLayerMatchesForPath(
|
|
280
267
|
path: string,
|
|
281
268
|
layers: HighlightLayer[],
|
|
282
|
-
):
|
|
269
|
+
): LayerMatch[] {
|
|
270
|
+
const matches: LayerMatch[] = [];
|
|
271
|
+
|
|
283
272
|
for (const layer of layers) {
|
|
284
273
|
if (!layer.enabled) continue;
|
|
285
274
|
|
|
286
275
|
for (const item of layer.items) {
|
|
276
|
+
let isMatch = false;
|
|
277
|
+
|
|
287
278
|
if (item.type === 'file' && item.path === path) {
|
|
288
|
-
|
|
279
|
+
isMatch = true;
|
|
280
|
+
} else if (item.type === 'directory' && (path === item.path || path.startsWith(item.path + '/'))) {
|
|
281
|
+
isMatch = true;
|
|
289
282
|
}
|
|
290
|
-
|
|
291
|
-
|
|
283
|
+
|
|
284
|
+
if (isMatch) {
|
|
285
|
+
matches.push({
|
|
286
|
+
layer,
|
|
287
|
+
item,
|
|
288
|
+
color: layer.color,
|
|
289
|
+
opacity: layer.opacity ?? 1,
|
|
290
|
+
borderWidth: layer.borderWidth,
|
|
291
|
+
renderStrategy: item.renderStrategy || 'border', // Default from 2D renderer
|
|
292
|
+
});
|
|
292
293
|
}
|
|
293
294
|
}
|
|
294
295
|
}
|
|
296
|
+
|
|
297
|
+
// Sort by priority (highest first)
|
|
298
|
+
return matches.sort((a, b) => (b.layer.priority ?? 0) - (a.layer.priority ?? 0));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Get the highest-priority fill color for a path (backward compatibility).
|
|
303
|
+
* Returns the first matching layer with 'fill' strategy.
|
|
304
|
+
*/
|
|
305
|
+
function getHighlightForPath(
|
|
306
|
+
path: string,
|
|
307
|
+
layers: HighlightLayer[],
|
|
308
|
+
): { color: string; opacity: number } | null {
|
|
309
|
+
const matches = getLayerMatchesForPath(path, layers);
|
|
310
|
+
|
|
311
|
+
// Find first fill match
|
|
312
|
+
const fillMatch = matches.find(m => m.renderStrategy === 'fill');
|
|
313
|
+
|
|
314
|
+
if (fillMatch) {
|
|
315
|
+
return { color: fillMatch.color, opacity: fillMatch.opacity };
|
|
316
|
+
}
|
|
317
|
+
|
|
295
318
|
return null;
|
|
296
319
|
}
|
|
297
320
|
|
|
@@ -406,6 +429,252 @@ function BuildingEdges({
|
|
|
406
429
|
);
|
|
407
430
|
}
|
|
408
431
|
|
|
432
|
+
// ============================================================================
|
|
433
|
+
// Border Highlights - Colored edge outlines for highlighted buildings
|
|
434
|
+
// ============================================================================
|
|
435
|
+
|
|
436
|
+
interface BorderEdgeData {
|
|
437
|
+
x: number;
|
|
438
|
+
z: number;
|
|
439
|
+
fullHeight: number;
|
|
440
|
+
buildingIndex: number;
|
|
441
|
+
staggerDelayMs: number;
|
|
442
|
+
color: string;
|
|
443
|
+
opacity: number;
|
|
444
|
+
borderWidth: number;
|
|
445
|
+
edgeType: 'vertical' | 'horizontal-x' | 'horizontal-z'; // Edge orientation
|
|
446
|
+
width?: number; // For horizontal edges (length along X axis)
|
|
447
|
+
depth?: number; // For horizontal edges (length along Z axis)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
interface BorderHighlightsProps {
|
|
451
|
+
buildings: CityBuilding[];
|
|
452
|
+
centerOffset: { x: number; z: number };
|
|
453
|
+
highlightLayers: HighlightLayer[];
|
|
454
|
+
growProgress: number;
|
|
455
|
+
minHeight: number;
|
|
456
|
+
baseOffset: number;
|
|
457
|
+
springDuration: number;
|
|
458
|
+
heightMultipliersRef: React.MutableRefObject<Float32Array | null>;
|
|
459
|
+
heightScaling: HeightScaling;
|
|
460
|
+
linearScale: number;
|
|
461
|
+
flatPatterns: FlatPattern[];
|
|
462
|
+
staggerIndices: number[];
|
|
463
|
+
animationConfig: AnimationConfig;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function BorderHighlights({
|
|
467
|
+
buildings,
|
|
468
|
+
centerOffset,
|
|
469
|
+
highlightLayers,
|
|
470
|
+
growProgress,
|
|
471
|
+
minHeight,
|
|
472
|
+
baseOffset,
|
|
473
|
+
springDuration,
|
|
474
|
+
heightMultipliersRef,
|
|
475
|
+
heightScaling,
|
|
476
|
+
linearScale,
|
|
477
|
+
flatPatterns,
|
|
478
|
+
staggerIndices,
|
|
479
|
+
animationConfig,
|
|
480
|
+
}: BorderHighlightsProps) {
|
|
481
|
+
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
482
|
+
const startTimeRef = useRef<number | null>(null);
|
|
483
|
+
const tempObject = useMemo(() => new THREE.Object3D(), []);
|
|
484
|
+
const tempColor = useMemo(() => new THREE.Color(), []);
|
|
485
|
+
|
|
486
|
+
// Pre-compute border edge data from buildings with border highlights
|
|
487
|
+
const borderEdgeData = useMemo(() => {
|
|
488
|
+
const edges: BorderEdgeData[] = [];
|
|
489
|
+
|
|
490
|
+
buildings.forEach((building, buildingIndex) => {
|
|
491
|
+
const matches = getLayerMatchesForPath(building.path, highlightLayers);
|
|
492
|
+
|
|
493
|
+
// Find border matches
|
|
494
|
+
const borderMatches = matches.filter(m => m.renderStrategy === 'border');
|
|
495
|
+
|
|
496
|
+
if (borderMatches.length === 0) return;
|
|
497
|
+
|
|
498
|
+
// Use highest priority border match
|
|
499
|
+
const borderMatch = borderMatches[0];
|
|
500
|
+
|
|
501
|
+
const [width, , depth] = building.dimensions;
|
|
502
|
+
const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale, flatPatterns);
|
|
503
|
+
const x = building.position.x - centerOffset.x;
|
|
504
|
+
const z = building.position.z - centerOffset.z;
|
|
505
|
+
const staggerIndex = staggerIndices[buildingIndex] ?? buildingIndex;
|
|
506
|
+
const staggerDelayMs = (animationConfig.staggerDelay || 15) * staggerIndex;
|
|
507
|
+
|
|
508
|
+
const halfW = width / 2;
|
|
509
|
+
const halfD = depth / 2;
|
|
510
|
+
|
|
511
|
+
// Create 4 vertical corner edges
|
|
512
|
+
const corners = [
|
|
513
|
+
{ x: x - halfW, z: z - halfD },
|
|
514
|
+
{ x: x + halfW, z: z - halfD },
|
|
515
|
+
{ x: x - halfW, z: z + halfD },
|
|
516
|
+
{ x: x + halfW, z: z + halfD },
|
|
517
|
+
];
|
|
518
|
+
|
|
519
|
+
corners.forEach(corner => {
|
|
520
|
+
edges.push({
|
|
521
|
+
x: corner.x,
|
|
522
|
+
z: corner.z,
|
|
523
|
+
fullHeight,
|
|
524
|
+
buildingIndex,
|
|
525
|
+
staggerDelayMs,
|
|
526
|
+
color: borderMatch.color,
|
|
527
|
+
opacity: borderMatch.opacity,
|
|
528
|
+
borderWidth: borderMatch.borderWidth ?? 2,
|
|
529
|
+
edgeType: 'vertical',
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// Create 4 horizontal edges on top (roof outline)
|
|
534
|
+
// Two edges along X axis (front and back)
|
|
535
|
+
edges.push({
|
|
536
|
+
x: x,
|
|
537
|
+
z: z - halfD,
|
|
538
|
+
fullHeight,
|
|
539
|
+
buildingIndex,
|
|
540
|
+
staggerDelayMs,
|
|
541
|
+
color: borderMatch.color,
|
|
542
|
+
opacity: borderMatch.opacity,
|
|
543
|
+
borderWidth: borderMatch.borderWidth ?? 2,
|
|
544
|
+
edgeType: 'horizontal-x',
|
|
545
|
+
width,
|
|
546
|
+
});
|
|
547
|
+
edges.push({
|
|
548
|
+
x: x,
|
|
549
|
+
z: z + halfD,
|
|
550
|
+
fullHeight,
|
|
551
|
+
buildingIndex,
|
|
552
|
+
staggerDelayMs,
|
|
553
|
+
color: borderMatch.color,
|
|
554
|
+
opacity: borderMatch.opacity,
|
|
555
|
+
borderWidth: borderMatch.borderWidth ?? 2,
|
|
556
|
+
edgeType: 'horizontal-x',
|
|
557
|
+
width,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Two edges along Z axis (left and right)
|
|
561
|
+
edges.push({
|
|
562
|
+
x: x - halfW,
|
|
563
|
+
z: z,
|
|
564
|
+
fullHeight,
|
|
565
|
+
buildingIndex,
|
|
566
|
+
staggerDelayMs,
|
|
567
|
+
color: borderMatch.color,
|
|
568
|
+
opacity: borderMatch.opacity,
|
|
569
|
+
borderWidth: borderMatch.borderWidth ?? 2,
|
|
570
|
+
edgeType: 'horizontal-z',
|
|
571
|
+
depth,
|
|
572
|
+
});
|
|
573
|
+
edges.push({
|
|
574
|
+
x: x + halfW,
|
|
575
|
+
z: z,
|
|
576
|
+
fullHeight,
|
|
577
|
+
buildingIndex,
|
|
578
|
+
staggerDelayMs,
|
|
579
|
+
color: borderMatch.color,
|
|
580
|
+
opacity: borderMatch.opacity,
|
|
581
|
+
borderWidth: borderMatch.borderWidth ?? 2,
|
|
582
|
+
edgeType: 'horizontal-z',
|
|
583
|
+
depth,
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
return edges;
|
|
588
|
+
}, [
|
|
589
|
+
buildings,
|
|
590
|
+
centerOffset,
|
|
591
|
+
highlightLayers,
|
|
592
|
+
heightScaling,
|
|
593
|
+
linearScale,
|
|
594
|
+
flatPatterns,
|
|
595
|
+
staggerIndices,
|
|
596
|
+
animationConfig.staggerDelay,
|
|
597
|
+
]);
|
|
598
|
+
|
|
599
|
+
// Animate border edges
|
|
600
|
+
useFrame(({ clock }) => {
|
|
601
|
+
if (!meshRef.current || borderEdgeData.length === 0) return;
|
|
602
|
+
|
|
603
|
+
if (startTimeRef.current === null && growProgress > 0) {
|
|
604
|
+
startTimeRef.current = clock.elapsedTime * 1000;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const currentTime = clock.elapsedTime * 1000;
|
|
608
|
+
const animStartTime = startTimeRef.current ?? currentTime;
|
|
609
|
+
|
|
610
|
+
borderEdgeData.forEach((edge, idx) => {
|
|
611
|
+
const { x, z, fullHeight, staggerDelayMs, buildingIndex, color, opacity, borderWidth, edgeType, width, depth } = edge;
|
|
612
|
+
|
|
613
|
+
// Get height multiplier from shared ref (for collapse animation)
|
|
614
|
+
const heightMultiplier = heightMultipliersRef.current?.[buildingIndex] ?? 1;
|
|
615
|
+
|
|
616
|
+
// Calculate per-building animation progress
|
|
617
|
+
const elapsed = currentTime - animStartTime - staggerDelayMs;
|
|
618
|
+
let animProgress = growProgress;
|
|
619
|
+
|
|
620
|
+
if (growProgress > 0 && elapsed >= 0) {
|
|
621
|
+
const t = Math.min(elapsed / springDuration, 1);
|
|
622
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
623
|
+
animProgress = eased * growProgress;
|
|
624
|
+
} else if (growProgress > 0 && elapsed < 0) {
|
|
625
|
+
animProgress = 0;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Apply both grow animation and collapse multiplier
|
|
629
|
+
const height = animProgress * fullHeight * heightMultiplier + minHeight;
|
|
630
|
+
|
|
631
|
+
// Fixed thickness based on borderWidth (don't scale with building size)
|
|
632
|
+
const thickness = Math.max(0.2, borderWidth * 0.1); // Convert pixels to world units
|
|
633
|
+
|
|
634
|
+
if (edgeType === 'vertical') {
|
|
635
|
+
// Vertical corner edges
|
|
636
|
+
const yPosition = height / 2 + baseOffset;
|
|
637
|
+
tempObject.position.set(x, yPosition, z);
|
|
638
|
+
tempObject.rotation.set(0, 0, 0);
|
|
639
|
+
tempObject.scale.set(thickness, height, thickness);
|
|
640
|
+
} else if (edgeType === 'horizontal-x') {
|
|
641
|
+
// Horizontal edges along X axis (front/back of roof)
|
|
642
|
+
const yPosition = height + baseOffset;
|
|
643
|
+
tempObject.position.set(x, yPosition, z);
|
|
644
|
+
tempObject.rotation.set(0, 0, Math.PI / 2); // Rotate to horizontal along X
|
|
645
|
+
tempObject.scale.set(thickness, width!, thickness);
|
|
646
|
+
} else if (edgeType === 'horizontal-z') {
|
|
647
|
+
// Horizontal edges along Z axis (left/right of roof)
|
|
648
|
+
const yPosition = height + baseOffset;
|
|
649
|
+
tempObject.position.set(x, yPosition, z);
|
|
650
|
+
tempObject.rotation.set(Math.PI / 2, 0, 0); // Rotate to horizontal along Z
|
|
651
|
+
tempObject.scale.set(thickness, depth!, thickness);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
tempObject.updateMatrix();
|
|
655
|
+
meshRef.current!.setMatrixAt(idx, tempObject.matrix);
|
|
656
|
+
|
|
657
|
+
// Set per-instance color with opacity
|
|
658
|
+
tempColor.set(color);
|
|
659
|
+
meshRef.current!.setColorAt(idx, tempColor);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
meshRef.current.instanceMatrix.needsUpdate = true;
|
|
663
|
+
if (meshRef.current.instanceColor) {
|
|
664
|
+
meshRef.current.instanceColor.needsUpdate = true;
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
if (borderEdgeData.length === 0) return null;
|
|
669
|
+
|
|
670
|
+
return (
|
|
671
|
+
<instancedMesh ref={meshRef} args={[undefined, undefined, borderEdgeData.length]} frustumCulled={false}>
|
|
672
|
+
<boxGeometry args={[1, 1, 1]} />
|
|
673
|
+
<meshBasicMaterial transparent opacity={0.9} vertexColors />
|
|
674
|
+
</instancedMesh>
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
409
678
|
// ============================================================================
|
|
410
679
|
// Instanced Buildings - High performance rendering for large scenes
|
|
411
680
|
// ============================================================================
|
|
@@ -532,9 +801,10 @@ function InstancedBuildings({
|
|
|
532
801
|
return buildings.map((building, index) => {
|
|
533
802
|
const [width, , depth] = building.dimensions;
|
|
534
803
|
const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale, flatPatterns);
|
|
535
|
-
//
|
|
536
|
-
const
|
|
537
|
-
const
|
|
804
|
+
// Get all layer matches and find first fill match for building color
|
|
805
|
+
const matches = getLayerMatchesForPath(building.path, highlightLayers);
|
|
806
|
+
const fillMatch = matches.find(m => m.renderStrategy === 'fill');
|
|
807
|
+
const color = fillMatch?.color ?? getColorForFile(building);
|
|
538
808
|
|
|
539
809
|
const x = building.position.x - centerOffset.x;
|
|
540
810
|
const z = building.position.z - centerOffset.z;
|
|
@@ -751,6 +1021,23 @@ function InstancedBuildings({
|
|
|
751
1021
|
springDuration={springDuration}
|
|
752
1022
|
heightMultipliersRef={heightMultipliersRef}
|
|
753
1023
|
/>
|
|
1024
|
+
|
|
1025
|
+
{/* Border highlights (colored, layer-driven) */}
|
|
1026
|
+
<BorderHighlights
|
|
1027
|
+
buildings={buildings}
|
|
1028
|
+
centerOffset={centerOffset}
|
|
1029
|
+
highlightLayers={highlightLayers}
|
|
1030
|
+
growProgress={growProgress}
|
|
1031
|
+
minHeight={minHeight}
|
|
1032
|
+
baseOffset={baseOffset}
|
|
1033
|
+
springDuration={springDuration}
|
|
1034
|
+
heightMultipliersRef={heightMultipliersRef}
|
|
1035
|
+
heightScaling={heightScaling}
|
|
1036
|
+
linearScale={linearScale}
|
|
1037
|
+
flatPatterns={flatPatterns}
|
|
1038
|
+
staggerIndices={staggerIndices}
|
|
1039
|
+
animationConfig={animationConfig}
|
|
1040
|
+
/>
|
|
754
1041
|
</group>
|
|
755
1042
|
);
|
|
756
1043
|
}
|
|
@@ -1156,101 +1443,56 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1156
1443
|
const frameCount = useRef(0);
|
|
1157
1444
|
const hasNotifiedReady = useRef(false);
|
|
1158
1445
|
const prevIsFlatRef = useRef(isFlat); // Track previous isFlat to detect actual state changes
|
|
1159
|
-
const isSyncingInitial = useRef(false); // Flag to prevent onStart from triggering during Frame 3 sync
|
|
1160
1446
|
|
|
1161
|
-
//
|
|
1447
|
+
// Helper to calculate flat camera height with known FOV (50) and aspect ratio
|
|
1162
1448
|
// Formula: height = citySize / (2 * tan(fov/2) * min(1, aspect))
|
|
1163
1449
|
// Padding factor adds space around the city to match 2D component
|
|
1164
|
-
const calculateFlatCameraHeight = useCallback(() => {
|
|
1165
|
-
const
|
|
1166
|
-
const fovRad = (
|
|
1450
|
+
const calculateFlatCameraHeight = useCallback((aspect: number) => {
|
|
1451
|
+
const fov = 50; // Known FOV that will be set on PerspectiveCamera
|
|
1452
|
+
const fovRad = (fov * Math.PI) / 180;
|
|
1167
1453
|
const tanHalfFov = Math.tan(fovRad / 2);
|
|
1168
|
-
const aspect = perspCam.aspect || 1;
|
|
1169
1454
|
// Use min(1, aspect) to handle both landscape and portrait viewports
|
|
1170
1455
|
const effectiveAspect = Math.min(1, aspect);
|
|
1171
1456
|
const baseHeight = citySize / (2 * tanHalfFov * effectiveAspect);
|
|
1172
1457
|
// Add padding to match 2D component's default padding
|
|
1173
1458
|
const paddingFactor = 1.08;
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
fov: perspCam.fov,
|
|
1177
|
-
aspect,
|
|
1178
|
-
effectiveAspect,
|
|
1179
|
-
citySize,
|
|
1180
|
-
baseHeight,
|
|
1181
|
-
result,
|
|
1182
|
-
});
|
|
1183
|
-
return result;
|
|
1184
|
-
}, [camera, citySize]);
|
|
1185
|
-
|
|
1186
|
-
// Compute target camera position
|
|
1187
|
-
// When flat, always use top-down view (ignore focusTarget)
|
|
1188
|
-
// When grown, use focusTarget if available, otherwise angled overview
|
|
1189
|
-
const targetPos = useMemo(() => {
|
|
1190
|
-
// Flat state: always top-down, ignore any focus
|
|
1191
|
-
// Height calculated mathematically to match 2D view zoom level
|
|
1192
|
-
if (isFlat) {
|
|
1193
|
-
return {
|
|
1194
|
-
x: 0,
|
|
1195
|
-
y: calculateFlatCameraHeight(),
|
|
1196
|
-
z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
1197
|
-
targetX: 0,
|
|
1198
|
-
targetY: 0,
|
|
1199
|
-
targetZ: 0,
|
|
1200
|
-
};
|
|
1201
|
-
}
|
|
1459
|
+
return baseHeight * paddingFactor;
|
|
1460
|
+
}, [citySize]);
|
|
1202
1461
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
y: height,
|
|
1210
|
-
z: focusTarget.z + distance,
|
|
1211
|
-
targetX: focusTarget.x,
|
|
1212
|
-
targetY: 0,
|
|
1213
|
-
targetZ: focusTarget.z,
|
|
1214
|
-
};
|
|
1215
|
-
}
|
|
1462
|
+
// Calculate initial 2D position (component always starts in 2D mode)
|
|
1463
|
+
// We need aspect ratio from the camera, but we'll use a default until Frame 1
|
|
1464
|
+
const getInitial2DPosition = useCallback(() => {
|
|
1465
|
+
const perspCam = camera as THREE.PerspectiveCamera;
|
|
1466
|
+
const aspect = perspCam.aspect || 1;
|
|
1467
|
+
const height = calculateFlatCameraHeight(aspect);
|
|
1216
1468
|
|
|
1217
|
-
// Grown state without focus: angled overview
|
|
1218
|
-
const baseHeight = citySize * 1.1;
|
|
1219
|
-
const buildingAwareHeight = maxBuildingHeight > 0
|
|
1220
|
-
? Math.max(baseHeight, maxBuildingHeight * 2.5)
|
|
1221
|
-
: baseHeight;
|
|
1222
1469
|
return {
|
|
1223
1470
|
x: 0,
|
|
1224
|
-
y:
|
|
1225
|
-
z:
|
|
1471
|
+
y: height,
|
|
1472
|
+
z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
1226
1473
|
targetX: 0,
|
|
1227
1474
|
targetY: 0,
|
|
1228
1475
|
targetZ: 0,
|
|
1229
1476
|
};
|
|
1230
|
-
}, [
|
|
1231
|
-
|
|
1232
|
-
// Freeze initial camera position on Frame 1 (not during render)
|
|
1233
|
-
// This ensures we capture the correct calculation after canvas is initialized
|
|
1234
|
-
const initialPosRef = useRef<typeof targetPos | null>(null);
|
|
1477
|
+
}, [camera, calculateFlatCameraHeight]);
|
|
1235
1478
|
|
|
1236
1479
|
// Spring animation for camera movement
|
|
1237
|
-
// Initialize
|
|
1480
|
+
// Initialize with correct 2D position from the start
|
|
1238
1481
|
const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => {
|
|
1239
|
-
|
|
1482
|
+
// Calculate initial position with default aspect ratio
|
|
1483
|
+
// This will be corrected in Frame 1 if aspect is different
|
|
1484
|
+
const initialHeight = calculateFlatCameraHeight(1);
|
|
1485
|
+
|
|
1486
|
+
console.log('[Spring init] Initializing with 2D position, height:', initialHeight);
|
|
1240
1487
|
return {
|
|
1241
1488
|
camX: 0,
|
|
1242
|
-
camY:
|
|
1243
|
-
camZ: 0,
|
|
1489
|
+
camY: initialHeight,
|
|
1490
|
+
camZ: 0.001,
|
|
1244
1491
|
lookX: 0,
|
|
1245
1492
|
lookY: 0,
|
|
1246
1493
|
lookZ: 0,
|
|
1247
1494
|
config: { tension: 60, friction: 20 },
|
|
1248
1495
|
onStart: () => {
|
|
1249
|
-
// Block animations during initial sync in Frame 3
|
|
1250
|
-
if (isSyncingInitial.current) {
|
|
1251
|
-
console.log('[Spring onStart] Blocked - syncing initial position');
|
|
1252
|
-
return;
|
|
1253
|
-
}
|
|
1254
1496
|
// Only allow animations after initial setup is complete
|
|
1255
1497
|
if (hasAppliedInitial.current) {
|
|
1256
1498
|
console.log('[Spring onStart] Animation starting - camY:', camY.get());
|
|
@@ -1308,42 +1550,31 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1308
1550
|
azimuthAngle: number; // horizontal angle to maintain
|
|
1309
1551
|
} | null>(null);
|
|
1310
1552
|
|
|
1311
|
-
// When isFlat changes
|
|
1312
|
-
//
|
|
1313
|
-
// from aspect ratio changes or other recalculations
|
|
1553
|
+
// When isFlat changes from true to false, animate to 3D view
|
|
1554
|
+
// Component always starts in 2D, so we only animate the 2D→3D transition
|
|
1314
1555
|
useEffect(() => {
|
|
1315
|
-
console.log('[useEffect]
|
|
1316
|
-
console.log('[useEffect] Dependency check - focusTarget:', focusTarget, 'citySize:', citySize, 'maxBuildingHeight:', maxBuildingHeight);
|
|
1556
|
+
console.log('[useEffect] isFlat:', isFlat, 'prevIsFlat:', prevIsFlatRef.current, 'hasAppliedInitial:', hasAppliedInitial.current);
|
|
1317
1557
|
|
|
1318
1558
|
// Skip until camera is initialized
|
|
1319
|
-
if (!hasAppliedInitial.current
|
|
1559
|
+
if (!hasAppliedInitial.current) {
|
|
1320
1560
|
console.log('[useEffect] Skipping - not initialized yet');
|
|
1321
1561
|
return;
|
|
1322
1562
|
}
|
|
1323
1563
|
|
|
1324
|
-
// Only animate if isFlat
|
|
1564
|
+
// Only animate if isFlat changed from true to false (2D → 3D transition)
|
|
1325
1565
|
const isFlatChanged = prevIsFlatRef.current !== isFlat;
|
|
1326
1566
|
|
|
1327
1567
|
if (!isFlatChanged) {
|
|
1328
|
-
|
|
1329
|
-
// Use initialPosRef to prevent updates from targetPos recalculations
|
|
1330
|
-
console.log('[useEffect] No isFlat change - skipping (prevIsFlat:', prevIsFlatRef.current, 'isFlat:', isFlat, ')');
|
|
1568
|
+
console.log('[useEffect] No isFlat change - skipping');
|
|
1331
1569
|
return;
|
|
1332
1570
|
}
|
|
1333
1571
|
|
|
1334
1572
|
console.log('[useEffect] isFlat changed from', prevIsFlatRef.current, 'to', isFlat, '- animating transition');
|
|
1335
1573
|
prevIsFlatRef.current = isFlat;
|
|
1336
1574
|
|
|
1337
|
-
//
|
|
1575
|
+
// Calculate target position for 3D view
|
|
1338
1576
|
const newPos = isFlat
|
|
1339
|
-
?
|
|
1340
|
-
x: 0,
|
|
1341
|
-
y: calculateFlatCameraHeight(),
|
|
1342
|
-
z: 0.001,
|
|
1343
|
-
targetX: 0,
|
|
1344
|
-
targetY: 0,
|
|
1345
|
-
targetZ: 0,
|
|
1346
|
-
}
|
|
1577
|
+
? getInitial2DPosition() // Going back to 2D
|
|
1347
1578
|
: focusTarget
|
|
1348
1579
|
? {
|
|
1349
1580
|
x: focusTarget.x,
|
|
@@ -1362,10 +1593,7 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1362
1593
|
targetZ: 0,
|
|
1363
1594
|
};
|
|
1364
1595
|
|
|
1365
|
-
|
|
1366
|
-
initialPosRef.current = newPos;
|
|
1367
|
-
|
|
1368
|
-
console.log('[useEffect] Starting animation to:', newPos);
|
|
1596
|
+
console.log('[useEffect] Animating to:', newPos);
|
|
1369
1597
|
api.start({
|
|
1370
1598
|
camX: newPos.x,
|
|
1371
1599
|
camY: newPos.y,
|
|
@@ -1373,21 +1601,18 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1373
1601
|
lookX: newPos.targetX,
|
|
1374
1602
|
lookY: newPos.targetY,
|
|
1375
1603
|
lookZ: newPos.targetZ,
|
|
1376
|
-
onRest: () => {
|
|
1377
|
-
console.log('[useEffect animation] onRest called');
|
|
1378
|
-
isAnimatingRef.current = false;
|
|
1379
|
-
},
|
|
1380
1604
|
});
|
|
1381
|
-
|
|
1605
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1606
|
+
}, [isFlat]); // Only animate when isFlat changes, not when focusTarget/citySize/etc change
|
|
1382
1607
|
|
|
1383
1608
|
// Update camera each frame
|
|
1384
1609
|
useFrame(() => {
|
|
1385
1610
|
frameCount.current++;
|
|
1386
1611
|
|
|
1387
|
-
//
|
|
1388
|
-
//
|
|
1612
|
+
// On Frame 1: Set camera to initial 2D position and mark as ready
|
|
1613
|
+
// Component always starts in 2D mode, so we just need to set the correct position once
|
|
1389
1614
|
if (frameCount.current === 1) {
|
|
1390
|
-
// Ensure camera FOV is
|
|
1615
|
+
// Ensure camera FOV is correct (defaults to 75 before prop applies)
|
|
1391
1616
|
const perspCam = camera as THREE.PerspectiveCamera;
|
|
1392
1617
|
if (perspCam.fov !== 50) {
|
|
1393
1618
|
console.log('[Frame 1] Correcting FOV from', perspCam.fov, 'to 50');
|
|
@@ -1395,99 +1620,41 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1395
1620
|
perspCam.updateProjectionMatrix();
|
|
1396
1621
|
}
|
|
1397
1622
|
|
|
1398
|
-
// Calculate
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
:
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
targetZ: 0,
|
|
1428
|
-
};
|
|
1429
|
-
console.log('[Frame 1] Calculated position:', freshPos);
|
|
1430
|
-
initialPosRef.current = freshPos;
|
|
1623
|
+
// Calculate initial 2D position with correct aspect ratio
|
|
1624
|
+
const initialPos = getInitial2DPosition();
|
|
1625
|
+
console.log('[Frame 1] Setting camera to initial 2D position:', initialPos);
|
|
1626
|
+
|
|
1627
|
+
camera.position.set(initialPos.x, initialPos.y, initialPos.z);
|
|
1628
|
+
|
|
1629
|
+
// Wait for controls to be ready, then set target and sync spring
|
|
1630
|
+
if (controlsRef.current) {
|
|
1631
|
+
controlsRef.current.target.set(initialPos.targetX, initialPos.targetY, initialPos.targetZ);
|
|
1632
|
+
controlsRef.current.update();
|
|
1633
|
+
|
|
1634
|
+
// Sync spring to match camera position (use immediate to avoid animation)
|
|
1635
|
+
api.start({
|
|
1636
|
+
camX: initialPos.x,
|
|
1637
|
+
camY: initialPos.y,
|
|
1638
|
+
camZ: initialPos.z,
|
|
1639
|
+
lookX: initialPos.targetX,
|
|
1640
|
+
lookY: initialPos.targetY,
|
|
1641
|
+
lookZ: initialPos.targetZ,
|
|
1642
|
+
immediate: true,
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
hasAppliedInitial.current = true;
|
|
1646
|
+
|
|
1647
|
+
// Notify parent that camera is ready
|
|
1648
|
+
if (!hasNotifiedReady.current && onCameraReady) {
|
|
1649
|
+
hasNotifiedReady.current = true;
|
|
1650
|
+
onCameraReady();
|
|
1651
|
+
}
|
|
1431
1652
|
}
|
|
1432
|
-
const pos = initialPosRef.current;
|
|
1433
|
-
console.log('[Frame 1] Setting camera to:', pos);
|
|
1434
|
-
camera.position.set(pos.x, pos.y, pos.z);
|
|
1435
1653
|
return;
|
|
1436
1654
|
}
|
|
1437
1655
|
|
|
1438
|
-
//
|
|
1439
|
-
if (
|
|
1440
|
-
if (!controlsRef.current) return;
|
|
1441
|
-
|
|
1442
|
-
// Set initial target and sync spring (after OrbitControls is ready)
|
|
1443
|
-
// Use frozen initialPosRef to match Frame 1 position
|
|
1444
|
-
if (!hasAppliedInitial.current && initialPosRef.current) {
|
|
1445
|
-
const pos = initialPosRef.current;
|
|
1446
|
-
camera.position.set(pos.x, pos.y, pos.z);
|
|
1447
|
-
controlsRef.current.target.set(pos.targetX, pos.targetY, pos.targetZ);
|
|
1448
|
-
controlsRef.current.update();
|
|
1449
|
-
|
|
1450
|
-
// Sync spring to this position so future animations start from here
|
|
1451
|
-
console.log('[Frame 3] Syncing spring to:', pos);
|
|
1452
|
-
|
|
1453
|
-
// Set flag to prevent onStart from triggering
|
|
1454
|
-
isSyncingInitial.current = true;
|
|
1455
|
-
|
|
1456
|
-
// Stop any ongoing animations first
|
|
1457
|
-
api.stop();
|
|
1458
|
-
|
|
1459
|
-
// Use api.start with immediate: true to set both current AND target values
|
|
1460
|
-
// This ensures the spring won't try to animate back to any previous target
|
|
1461
|
-
api.start({
|
|
1462
|
-
camX: pos.x,
|
|
1463
|
-
camY: pos.y,
|
|
1464
|
-
camZ: pos.z,
|
|
1465
|
-
lookX: pos.targetX,
|
|
1466
|
-
lookY: pos.targetY,
|
|
1467
|
-
lookZ: pos.targetZ,
|
|
1468
|
-
immediate: true,
|
|
1469
|
-
});
|
|
1470
|
-
|
|
1471
|
-
// Clear the syncing flag after a small delay to ensure spring is settled
|
|
1472
|
-
setTimeout(() => {
|
|
1473
|
-
isSyncingInitial.current = false;
|
|
1474
|
-
console.log('[Frame 3] Sync complete - spring ready for animations');
|
|
1475
|
-
}, 100);
|
|
1476
|
-
|
|
1477
|
-
// Ensure animation flag is off
|
|
1478
|
-
isAnimatingRef.current = false;
|
|
1479
|
-
|
|
1480
|
-
console.log('[Frame 3] Spring values after sync:', { camY: camY.get() });
|
|
1481
|
-
|
|
1482
|
-
hasAppliedInitial.current = true;
|
|
1483
|
-
|
|
1484
|
-
// Notify parent that camera is ready (only once)
|
|
1485
|
-
if (!hasNotifiedReady.current && onCameraReady) {
|
|
1486
|
-
hasNotifiedReady.current = true;
|
|
1487
|
-
onCameraReady();
|
|
1488
|
-
}
|
|
1489
|
-
return;
|
|
1490
|
-
}
|
|
1656
|
+
// Wait for controls and initialization to complete
|
|
1657
|
+
if (!controlsRef.current || !hasAppliedInitial.current) return;
|
|
1491
1658
|
|
|
1492
1659
|
// Handle orbit animation (horizontal rotation along arc)
|
|
1493
1660
|
if (isOrbitingRef.current && orbitParamsRef.current) {
|
|
@@ -1846,6 +2013,14 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1846
2013
|
/>
|
|
1847
2014
|
</>
|
|
1848
2015
|
);
|
|
2016
|
+
}, (prevProps, nextProps) => {
|
|
2017
|
+
// Custom comparison: only re-render if isFlat, citySize, or maxBuildingHeight changes
|
|
2018
|
+
// Skip re-render when only focusTarget changes (handled internally by useEffect on isFlat)
|
|
2019
|
+
return (
|
|
2020
|
+
prevProps.isFlat === nextProps.isFlat &&
|
|
2021
|
+
prevProps.citySize === nextProps.citySize &&
|
|
2022
|
+
prevProps.maxBuildingHeight === nextProps.maxBuildingHeight
|
|
2023
|
+
);
|
|
1849
2024
|
});
|
|
1850
2025
|
|
|
1851
2026
|
// Info panel overlay
|
|
@@ -1997,7 +2172,7 @@ function CityScene({
|
|
|
1997
2172
|
[cityData.bounds],
|
|
1998
2173
|
);
|
|
1999
2174
|
|
|
2000
|
-
const citySize = Math.
|
|
2175
|
+
const citySize = Math.min(
|
|
2001
2176
|
cityData.bounds.maxX - cityData.bounds.minX,
|
|
2002
2177
|
cityData.bounds.maxZ - cityData.bounds.minZ,
|
|
2003
2178
|
);
|
|
@@ -2154,7 +2329,7 @@ function CityScene({
|
|
|
2154
2329
|
|
|
2155
2330
|
const centerX = (minX + maxX) / 2;
|
|
2156
2331
|
const centerZ = (minZ + maxZ) / 2;
|
|
2157
|
-
const size = Math.
|
|
2332
|
+
const size = Math.min(maxX - minX, maxZ - minZ);
|
|
2158
2333
|
|
|
2159
2334
|
return { x: centerX, z: centerZ, size };
|
|
2160
2335
|
}
|
|
@@ -2199,11 +2374,6 @@ function CityScene({
|
|
|
2199
2374
|
return cityData.buildings.findIndex(b => b.path === selectedBuilding.path);
|
|
2200
2375
|
}, [selectedBuilding, cityData.buildings]);
|
|
2201
2376
|
|
|
2202
|
-
// Calculate spring duration for animation sync
|
|
2203
|
-
const tension = animationConfig.tension || 120;
|
|
2204
|
-
const friction = animationConfig.friction || 14;
|
|
2205
|
-
const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
|
|
2206
|
-
|
|
2207
2377
|
return (
|
|
2208
2378
|
<>
|
|
2209
2379
|
<AnimatedCamera
|