diff --git a/web/src/classic/components/molecules/Visualizer/compatibility/FALLBACKS.md b/web/src/classic/components/molecules/Visualizer/compatibility/FALLBACKS.md index 0d82329db..c78dd8b3f 100644 --- a/web/src/classic/components/molecules/Visualizer/compatibility/FALLBACKS.md +++ b/web/src/classic/components/molecules/Visualizer/compatibility/FALLBACKS.md @@ -6,6 +6,7 @@ The fallback system provides temporary alternatives when Cesium Ion assets canno ## Data Flow +### Scene Properties ``` sceneProperty (from props) ↓ @@ -18,11 +19,22 @@ applyFallbacks() - Fallback when no Cesium Ion token overriddenSceneProperty (passed to consumers) ``` +### Layers +``` +rootLayer (from props) + ↓ +(no backward compatibility needed for layers) + ↓ +applyLayerFallbacks() - Fallback when no Cesium Ion token + ↓ +transformedRootLayer (passed to LayerStore) +``` + ## When Fallbacks Apply Fallbacks **ONLY** apply when: - No Cesium Ion token is provided (`sceneProperty.default?.ion` is empty/undefined) -- The tile or terrain type requires Cesium Ion authentication +- The tile, terrain, or layer type requires Cesium Ion authentication If a Cesium Ion token is present, no fallbacks are applied and the original Cesium Ion assets are used. @@ -32,19 +44,25 @@ The fallback logic is implemented in three files: ### 1. `fallbacks.ts` Contains the core fallback logic: -- `applyFallbacks()` - Main entry point that checks for token and applies fallbacks +- `applyFallbacks()` - Main entry point that checks for token and applies fallbacks to scene properties - `fallbackTileType()` - Fallbacks for tiles requiring Cesium Ion - `fallbackTerrainType()` - Fallbacks for terrain requiring Cesium Ion +- `fallbackLayerSourceType()` - Fallbacks for layer sourceType requiring Cesium Ion (OSM buildings) +- `applyLayerFallbacks()` - Main entry point for layer tree transformations when no Cesium Ion token ### 2. `fallbacks.test.ts` Comprehensive unit tests covering: - All tile type fallbacks - Terrain type fallback +- Layer sourceType fallback (individual function) +- Layer tree fallbacks (applyLayerFallbacks) - Token presence/absence scenarios -- Edge cases (empty token, mixed tile types, etc.) +- Edge cases (empty token, mixed tile types, nested layers, etc.) ### 3. `hooks.ts` -Integration point where fallbacks are applied after backward compatibility: +Integration point where both scene property and layer fallbacks are applied: + +**Scene Property Fallbacks:** ```typescript const backwardCompatibleSceneProperty = useMemo( () => applyBackwardCompatibility(overriddenScenePropertyRaw), @@ -57,6 +75,23 @@ const overriddenSceneProperty = useMemo( ); ``` +**Layer Fallbacks:** +```typescript +const transformedRootLayer = useMemo( + () => applyLayerFallbacks(rootLayer, !!overriddenSceneProperty?.default?.ion), + [rootLayer, overriddenSceneProperty?.default?.ion], +); +``` + +The transformed layer tree is then passed to `useLayers`: +```typescript +const { layers, selectedLayer, ... } = useLayers({ + rootLayer: transformedRootLayer, + selected: outerSelectedLayerId, + onSelect: onLayerSelect, +}); +``` + ## Tile Fallback Rules ### When No Cesium Ion Token @@ -143,6 +178,49 @@ After fallback: } ``` +## Layer Fallback Rules + +### When No Cesium Ion Token + +| Source Type | Fallback Type | Notes | +|-------------|----------------------|-----------------------------------------| +| `osm` | `reearth-buildings` | Re:Earth Buildings (OSM Buildings) | + +**Note:** Other layer sourceTypes remain unchanged (no fallback). This fallback is applied at the layer level in the Tileset component. + +### Example + +**Scenario: No Cesium Ion token** + +A Tileset layer with sourceType "osm" will automatically use Re:Earth Buildings instead: + +Before fallback: +```typescript +{ + sourceType: "osm", + // This would require Cesium Ion asset ID 96188 +} +``` + +After fallback: +```typescript +{ + sourceType: "reearth-buildings", + // Uses https://buildings.reearth.land/tileset.json +} +``` + +**Scenario: With Cesium Ion token** + +```typescript +{ + sourceType: "osm", + // Token available - uses Cesium Ion asset ID 96188 +} +``` + +No fallback applied - layer uses OSM Buildings from Cesium Ion. + ## Combined Example: Backward Compatibility + Fallbacks ### Input (Old Format) @@ -196,9 +274,11 @@ Run the unit tests: yarn test fallbacks.test.ts ``` -All 22 tests should pass, covering: +All 38 tests should pass, covering: - 8 tests for tile type fallbacks - 4 tests for terrain type fallback +- 6 tests for layer sourceType fallback (individual function) +- 10 tests for layer tree fallbacks (`applyLayerFallbacks()`) - 10 tests for the main `applyFallbacks()` function ## Future Removal @@ -210,9 +290,15 @@ This fallback system is temporary and will be removed in a future release when: ## Notes -- **Token Check**: Fallbacks only apply when `sceneProperty.default?.ion` is falsy (undefined, null, or empty string) -- **Immutability**: The original `sceneProperty` is never modified. All transformations return new objects. +- **Token Check**: Fallbacks only apply when `sceneProperty.default?.ion` is falsy (undefined, null, or empty string). +- **Data Flow Separation**: + - **Scene Property Fallbacks**: Applied to tiles and terrain in `applyFallbacks()` function in hooks.ts + - **Layer Fallbacks**: Applied to entire layer tree in `applyLayerFallbacks()` function in hooks.ts + - Both are applied early in the data pipeline, before the data reaches rendering components +- **Immutability**: The original `sceneProperty` and `rootLayer` are never modified. All transformations return new objects. - **Performance**: Uses `useMemo` to avoid re-computing fallbacks on every render. -- **Deep cloning**: Uses `lodash-es/cloneDeep` to ensure nested objects are properly cloned. -- **Selective Fallback**: Only specific Cesium Ion asset IDs are mapped to fallbacks. Unknown asset IDs remain unchanged. +- **Deep cloning**: Uses `lodash-es/cloneDeep` to ensure nested objects are properly cloned for scene properties. +- **Shallow cloning for layers**: Layer fallbacks use shallow cloning with object spread for better performance. +- **Recursive transformation**: Layer fallbacks recursively transform the entire layer tree, including all children. +- **Selective Fallback**: Only specific Cesium Ion asset IDs and sourceTypes are mapped to fallbacks. Unknown types remain unchanged. - **No Double Fallback**: If backward compatibility already changed the type to something other than `cesium_ion`, fallbacks won't apply. diff --git a/web/src/classic/components/molecules/Visualizer/compatibility/fallbacks.test.ts b/web/src/classic/components/molecules/Visualizer/compatibility/fallbacks.test.ts index 31d8cb489..f673abd1e 100644 --- a/web/src/classic/components/molecules/Visualizer/compatibility/fallbacks.test.ts +++ b/web/src/classic/components/molecules/Visualizer/compatibility/fallbacks.test.ts @@ -1,8 +1,15 @@ import { describe, expect, it } from "vitest"; import type { SceneProperty } from "../Engine/ref"; +import type { Layer } from "../Layers"; -import { applyFallbacks, fallbackTileType, fallbackTerrainType } from "./fallbacks"; +import { + applyFallbacks, + fallbackTileType, + fallbackTerrainType, + fallbackLayerSourceType, + applyLayerFallbacks, +} from "./fallbacks"; describe("fallbackTileType", () => { it("should fallback cesium_ion asset_id 2 to google_satellite", () => { @@ -175,6 +182,47 @@ describe("fallbackTerrainType", () => { }); }); +describe("fallbackLayerSourceType", () => { + it('should fallback "osm" to "reearth-buildings" when no Cesium Ion token', () => { + const result = fallbackLayerSourceType("osm", false); + + expect(result).toBe("reearth-buildings"); + }); + + it('should NOT fallback "osm" when Cesium Ion token is provided', () => { + const result = fallbackLayerSourceType("osm", true); + + expect(result).toBe("osm"); + }); + + it('should not fallback non-"osm" sourceTypes', () => { + const sourceTypes = ["url", "google-photorealistic", "reearth-buildings", undefined]; + + sourceTypes.forEach(sourceType => { + const result = fallbackLayerSourceType(sourceType, false); + expect(result).toBe(sourceType); + }); + }); + + it("should not fallback when sourceType is undefined", () => { + const result = fallbackLayerSourceType(undefined, false); + + expect(result).toBeUndefined(); + }); + + it("should handle layerId parameter for logging", () => { + const result = fallbackLayerSourceType("osm", false, "test-layer-id"); + + expect(result).toBe("reearth-buildings"); + }); + + it("should preserve sourceType when token is available even with layerId", () => { + const result = fallbackLayerSourceType("osm", true, "test-layer-id"); + + expect(result).toBe("osm"); + }); +}); + describe("applyFallbacks", () => { it("should return undefined when sceneProperty is undefined", () => { const result = applyFallbacks(undefined); @@ -324,3 +372,222 @@ describe("applyFallbacks", () => { expect(result?.tiles?.[0].tile_type).toBe("google_satellite"); }); }); + +describe("applyLayerFallbacks", () => { + it("should return undefined when rootLayer is undefined", () => { + const result = applyLayerFallbacks(undefined, false); + expect(result).toBeUndefined(); + }); + + it("should NOT apply fallbacks when Cesium Ion token is provided", () => { + const rootLayer: Layer = { + id: "root", + extensionId: "tileset", + property: { + default: { + sourceType: "osm", + }, + }, + }; + + const result = applyLayerFallbacks(rootLayer, true); + + // Should return the same layer without fallbacks + expect(result?.property?.default?.sourceType).toBe("osm"); + }); + + it("should fallback osm sourceType to reearth-buildings when no token", () => { + const rootLayer: Layer = { + id: "layer-1", + extensionId: "tileset", + property: { + default: { + sourceType: "osm", + }, + }, + }; + + const result = applyLayerFallbacks(rootLayer, false); + + expect(result?.property?.default?.sourceType).toBe("reearth-buildings"); + }); + + it("should not fallback non-tileset layers", () => { + const rootLayer: Layer = { + id: "layer-1", + extensionId: "marker", + property: { + default: { + sourceType: "osm", + }, + }, + }; + + const result = applyLayerFallbacks(rootLayer, false); + + expect(result?.property?.default?.sourceType).toBe("osm"); + }); + + it("should not fallback tileset layers with non-osm sourceType", () => { + const rootLayer: Layer = { + id: "layer-1", + extensionId: "tileset", + property: { + default: { + sourceType: "google-photorealistic", + }, + }, + }; + + const result = applyLayerFallbacks(rootLayer, false); + + expect(result?.property?.default?.sourceType).toBe("google-photorealistic"); + }); + + it("should recursively apply fallbacks to child layers", () => { + const rootLayer: Layer = { + id: "root", + extensionId: "group", + children: [ + { + id: "child-1", + extensionId: "tileset", + property: { + default: { + sourceType: "osm", + }, + }, + }, + { + id: "child-2", + extensionId: "tileset", + property: { + default: { + sourceType: "osm", + }, + }, + }, + ], + }; + + const result = applyLayerFallbacks(rootLayer, false); + + expect(result?.children?.[0].property?.default?.sourceType).toBe("reearth-buildings"); + expect(result?.children?.[1].property?.default?.sourceType).toBe("reearth-buildings"); + }); + + it("should handle mixed layer types in children", () => { + const rootLayer: Layer = { + id: "root", + extensionId: "group", + children: [ + { + id: "child-1", + extensionId: "tileset", + property: { + default: { + sourceType: "osm", + }, + }, + }, + { + id: "child-2", + extensionId: "marker", + property: { + default: { + sourceType: "osm", + }, + }, + }, + { + id: "child-3", + extensionId: "tileset", + property: { + default: { + sourceType: "google-photorealistic", + }, + }, + }, + ], + }; + + const result = applyLayerFallbacks(rootLayer, false); + + expect(result?.children?.[0].property?.default?.sourceType).toBe("reearth-buildings"); + expect(result?.children?.[1].property?.default?.sourceType).toBe("osm"); // Not a tileset + expect(result?.children?.[2].property?.default?.sourceType).toBe("google-photorealistic"); // Not osm + }); + + it("should not mutate original layer", () => { + const rootLayer: Layer = { + id: "layer-1", + extensionId: "tileset", + property: { + default: { + sourceType: "osm", + }, + }, + }; + + const result = applyLayerFallbacks(rootLayer, false); + + // Original should not be modified + expect(rootLayer.property?.default?.sourceType).toBe("osm"); + // Result should be modified + expect(result?.property?.default?.sourceType).toBe("reearth-buildings"); + }); + + it("should preserve other layer properties", () => { + const rootLayer: Layer = { + id: "layer-1", + extensionId: "tileset", + isVisible: true, + property: { + default: { + sourceType: "osm", + tileset: "custom-tileset", + shadows: "enabled", + }, + }, + }; + + const result = applyLayerFallbacks(rootLayer, false); + + expect(result?.id).toBe("layer-1"); + expect(result?.extensionId).toBe("tileset"); + expect(result?.isVisible).toBe(true); + expect(result?.property?.default?.sourceType).toBe("reearth-buildings"); + expect(result?.property?.default?.tileset).toBe("custom-tileset"); + expect(result?.property?.default?.shadows).toBe("enabled"); + }); + + it("should handle deeply nested layer trees", () => { + const rootLayer: Layer = { + id: "root", + extensionId: "group", + children: [ + { + id: "level1", + extensionId: "group", + children: [ + { + id: "level2", + extensionId: "tileset", + property: { + default: { + sourceType: "osm", + }, + }, + }, + ], + }, + ], + }; + + const result = applyLayerFallbacks(rootLayer, false); + + expect(result?.children?.[0].children?.[0].property?.default?.sourceType).toBe( + "reearth-buildings", + ); + }); +}); diff --git a/web/src/classic/components/molecules/Visualizer/compatibility/fallbacks.ts b/web/src/classic/components/molecules/Visualizer/compatibility/fallbacks.ts index 1366c9e0c..1a51add69 100644 --- a/web/src/classic/components/molecules/Visualizer/compatibility/fallbacks.ts +++ b/web/src/classic/components/molecules/Visualizer/compatibility/fallbacks.ts @@ -1,6 +1,7 @@ import { cloneDeep } from "lodash-es"; import type { SceneProperty, TerrainProperty } from "../Engine/ref"; +import type { Layer } from "../Layers"; /** * Apply fallback transformations when Cesium Ion token is not available @@ -103,3 +104,80 @@ export function fallbackTerrainType(terrain: TerrainProperty): TerrainProperty { return terrain; } + +/** + * Fallback layer sourceType when Cesium Ion token is not available + * Fallback rule (only when no Cesium Ion token): + * - "osm" → "reearth-buildings" + * + * @param sourceType - The layer sourceType from layer property + * @param hasCesiumIonToken - Whether a Cesium Ion token is available + * @param layerId - Optional layer ID for logging purposes + * @returns The fallback sourceType or the original if no fallback needed + */ +export function fallbackLayerSourceType( + sourceType: string | undefined, + hasCesiumIonToken: boolean, + layerId?: string, +): string | undefined { + // If Cesium Ion token is available, no fallback needed + if (hasCesiumIonToken || sourceType !== "osm") { + return sourceType; + } + + console.warn( + `[Re:Earth] Layer sourceType fallback: "osm" (OSM Buildings) → "reearth-buildings" (Re:Earth Buildings) - No Cesium Ion token available${ + layerId ? ` (layer ID: ${layerId})` : "" + }`, + ); + + return "reearth-buildings"; +} + +/** + * Apply fallback transformations to a layer tree when Cesium Ion token is not available + * This recursively transforms all layers in the tree and their children + * + * @param rootLayer - The root layer to transform + * @param hasCesiumIonToken - Whether a Cesium Ion token is available + * @returns The transformed root layer with fallbacks applied, or undefined if no root layer + */ +export function applyLayerFallbacks( + rootLayer: Layer | undefined, + hasCesiumIonToken: boolean, +): Layer | undefined { + if (!rootLayer) return undefined; + + // If Cesium Ion token is available, no fallback needed + if (hasCesiumIonToken) return rootLayer; + + // Helper function to transform a single layer + const transformLayer = (layer: Layer): Layer => { + // Clone the layer to avoid mutation + const transformedLayer = { ...layer }; + + // Apply sourceType fallback for tileset layers + if (layer.extensionId === "tileset" && layer.property?.default?.sourceType === "osm") { + transformedLayer.property = { + ...layer.property, + default: { + ...layer.property.default, + sourceType: fallbackLayerSourceType( + layer.property.default.sourceType, + hasCesiumIonToken, + layer.id, + ), + }, + }; + } + + // Recursively transform child layers + if (layer.children && layer.children.length > 0) { + transformedLayer.children = layer.children.map(transformLayer); + } + + return transformedLayer; + }; + + return transformLayer(rootLayer); +} diff --git a/web/src/classic/components/molecules/Visualizer/compatibility/index.ts b/web/src/classic/components/molecules/Visualizer/compatibility/index.ts index 53c1d27e8..74fd1d5e3 100644 --- a/web/src/classic/components/molecules/Visualizer/compatibility/index.ts +++ b/web/src/classic/components/molecules/Visualizer/compatibility/index.ts @@ -12,4 +12,10 @@ export { migrateTileType, migrateTerrainType, } from "./backwardCompatibility"; -export { applyFallbacks, fallbackTileType, fallbackTerrainType } from "./fallbacks"; +export { + applyFallbacks, + fallbackTileType, + fallbackTerrainType, + applyLayerFallbacks, + fallbackLayerSourceType, +} from "./fallbacks"; diff --git a/web/src/classic/components/molecules/Visualizer/hooks.ts b/web/src/classic/components/molecules/Visualizer/hooks.ts index 1498e9519..34ef4dc27 100644 --- a/web/src/classic/components/molecules/Visualizer/hooks.ts +++ b/web/src/classic/components/molecules/Visualizer/hooks.ts @@ -7,7 +7,7 @@ import { useSet } from "react-use"; import { useDrop, DropOptions } from "@reearth/classic/util/use-dnd"; import { Camera, LatLng, ValueTypes, ValueType } from "@reearth/classic/util/value"; -import { applyBackwardCompatibility, applyFallbacks } from "./compatibility"; +import { applyBackwardCompatibility, applyFallbacks, applyLayerFallbacks } from "./compatibility"; import type { OverriddenInfobox, Ref as EngineRef, @@ -131,6 +131,16 @@ export default ({ [backwardCompatibleSceneProperty], ); + // Step 4: Apply layer fallbacks when Cesium Ion token is not available + // Data flow: rootLayer → (backward compatibility - not needed) → fallbacks → consumers + // Fallback rules (only when no Cesium Ion token): + // Layers: + // - sourceType "osm" → "reearth-buildings" + const transformedRootLayer = useMemo( + () => applyLayerFallbacks(rootLayer, !!overriddenSceneProperty?.default?.ion), + [rootLayer, overriddenSceneProperty?.default?.ion], + ); + const wrapperRef = useRef(null); const { ref: dropRef, isDroppable } = useDrop( useMemo( @@ -173,7 +183,7 @@ export default ({ addLayer, overrideLayerProperty, } = useLayers({ - rootLayer, + rootLayer: transformedRootLayer, selected: outerSelectedLayerId, onSelect: onLayerSelect, });