diff --git a/web/src/beta/lib/core/Crust/Plugins/api.ts b/web/src/beta/lib/core/Crust/Plugins/api.ts index 7fb8f0b4f6..401b394f2c 100644 --- a/web/src/beta/lib/core/Crust/Plugins/api.ts +++ b/web/src/beta/lib/core/Crust/Plugins/api.ts @@ -345,6 +345,8 @@ export function commonReearth({ moveRight, moveOverTerrain, flyToGround, + findFeatureById, + findFeaturesByIds, }: { engineName?: string; events: Events; @@ -390,6 +392,8 @@ export function commonReearth({ moveRight: GlobalThis["reearth"]["camera"]["moveRight"]; moveOverTerrain: GlobalThis["reearth"]["camera"]["moveOverTerrain"]; flyToGround: GlobalThis["reearth"]["camera"]["flyToGround"]; + findFeatureById: GlobalThis["reearth"]["layers"]["findFeatureById"]; + findFeaturesByIds: GlobalThis["reearth"]["layers"]["findFeaturesByIds"]; }): CommonReearth { return { version: window.REEARTH_CONFIG?.version || "", @@ -572,6 +576,8 @@ export function commonReearth({ get add() { return addLayer; }, + findFeatureById, + findFeaturesByIds, }, plugins: { get instances() { diff --git a/web/src/beta/lib/core/Crust/Plugins/hooks.ts b/web/src/beta/lib/core/Crust/Plugins/hooks.ts index 926ec01e35..87a1531bec 100644 --- a/web/src/beta/lib/core/Crust/Plugins/hooks.ts +++ b/web/src/beta/lib/core/Crust/Plugins/hooks.ts @@ -247,6 +247,20 @@ export default function ({ [engineRef], ); + const findFeatureById = useCallback( + (layerId: string, featureId: string) => { + return engineRef?.findFeatureById(layerId, featureId); + }, + [engineRef], + ); + + const findFeaturesByIds = useCallback( + (layerId: string, featureIds: string[]) => { + return engineRef?.findFeaturesByIds(layerId, featureIds); + }, + [engineRef], + ); + const addLayer = useCallback( (layer: NaiveLayer) => { return layersRef?.add(layer)?.id; @@ -333,6 +347,8 @@ export default function ({ moveRight, moveOverTerrain, flyToGround, + findFeatureById, + findFeaturesByIds, }), overrideSceneProperty, pluginInstances, @@ -388,6 +404,8 @@ export default function ({ pluginInstances, clientStorage, useExperimentalSandbox, + findFeatureById, + findFeaturesByIds, ], ); diff --git a/web/src/beta/lib/core/Crust/Plugins/plugin_types.ts b/web/src/beta/lib/core/Crust/Plugins/plugin_types.ts index e237d8fadf..725818855a 100644 --- a/web/src/beta/lib/core/Crust/Plugins/plugin_types.ts +++ b/web/src/beta/lib/core/Crust/Plugins/plugin_types.ts @@ -20,6 +20,7 @@ import type { OverriddenLayer, Undefinable, WrappedRef, + Feature, } from "@reearth/beta/lib/core/Map"; import { Block } from "../Infobox"; @@ -82,6 +83,8 @@ export type Reearth = { layerId: string | undefined, reason?: LayerSelectionReason | undefined, ) => void; + findFeatureById?: (layerId: string, featureId: string) => Feature | undefined; + findFeaturesByIds?: (layerId: string, featureId: string[]) => Feature[] | undefined; selectionReason?: LayerSelectionReason; // For compat overriddenInfobox?: LayerSelectionReason["defaultInfobox"]; diff --git a/web/src/beta/lib/core/Map/Layer/hooks.ts b/web/src/beta/lib/core/Map/Layer/hooks.ts index 86ec58e21a..eb43b38dfb 100644 --- a/web/src/beta/lib/core/Map/Layer/hooks.ts +++ b/web/src/beta/lib/core/Map/Layer/hooks.ts @@ -11,7 +11,15 @@ import { evalFeature, type Data, } from "../../mantle"; -import type { Atom, DataRange, Layer, ComputedLayer, ComputedFeature, Feature } from "../types"; +import type { + Atom, + DataRange, + Layer, + ComputedLayer, + ComputedFeature, + Feature, + LayerSimple, +} from "../types"; export type { Atom as Atom } from "../types"; @@ -35,6 +43,10 @@ export default function useHooks({ selectedFeatureId?: string; }) { const [computedLayer, set] = useAtom(useMemo(() => atom ?? createAtom(), [atom])); + const writeLayer = useCallback( + (value: Partial>) => set({ type: "writeLayer", value }), + [set], + ); const writeFeatures = useCallback( (features: Feature[]) => set({ type: "writeFeatures", features }), [set], @@ -131,6 +143,7 @@ export default function useHooks({ return { computedLayer, handleFeatureRequest: requestFetch, + handleLayerFetch: writeLayer, handleFeatureFetch: writeFeatures, handleComputedFeatureFetch: writeComputedFeatures, handleFeatureDelete: deleteFeatures, diff --git a/web/src/beta/lib/core/Map/Layer/index.tsx b/web/src/beta/lib/core/Map/Layer/index.tsx index bbb5f53236..3e8a344946 100644 --- a/web/src/beta/lib/core/Map/Layer/index.tsx +++ b/web/src/beta/lib/core/Map/Layer/index.tsx @@ -7,6 +7,7 @@ import type { Layer, DataType, ComputedFeature, + LayerSimple, } from "../../mantle"; import { SceneProperty } from "../types"; @@ -30,6 +31,7 @@ export type FeatureComponentProps = { layer: ComputedLayer; sceneProperty?: SceneProperty; onFeatureRequest?: (range: DataRange) => void; + onLayerFetch?: (value: Partial>) => void; onFeatureFetch?: (features: Feature[]) => void; onComputedFeatureFetch?: (feature: Feature[], computed: ComputedFeature[]) => void; onFeatureDelete?: (features: string[]) => void; @@ -59,6 +61,7 @@ export default function LayerComponent({ const { computedLayer, handleFeatureDelete, + handleLayerFetch, handleComputedFeatureDelete, handleFeatureFetch, handleComputedFeatureFetch, @@ -78,6 +81,7 @@ export default function LayerComponent({ layer={computedLayer} onFeatureDelete={handleFeatureDelete} onComputedFeatureDelete={handleComputedFeatureDelete} + onLayerFetch={handleLayerFetch} onFeatureFetch={handleFeatureFetch} onComputedFeatureFetch={handleComputedFeatureFetch} onFeatureRequest={handleFeatureRequest} diff --git a/web/src/beta/lib/core/Map/Layers/hooks.ts b/web/src/beta/lib/core/Map/Layers/hooks.ts index 2a9b553b71..cfeaec24c3 100644 --- a/web/src/beta/lib/core/Map/Layers/hooks.ts +++ b/web/src/beta/lib/core/Map/Layers/hooks.ts @@ -200,6 +200,8 @@ export default function useHooks({ // compat if (key === "pluginId") return layer.compat?.extensionId ? "reearth" : undefined; else if (key === "extensionId") return layer.compat?.extensionId; + // TODO: Support normal layer's properties + else if (key === "properties") return layer.type === "simple" ? layer.properties : undefined; else if (key === "property") return layer.compat?.property; else if (key === "propertyId") return layer.compat?.propertyId; else if (key === "isVisible") return layer.visible; diff --git a/web/src/beta/lib/core/Map/Layers/index.test.tsx b/web/src/beta/lib/core/Map/Layers/index.test.tsx index 2f7321b7e3..eca1630b02 100644 --- a/web/src/beta/lib/core/Map/Layers/index.test.tsx +++ b/web/src/beta/lib/core/Map/Layers/index.test.tsx @@ -28,6 +28,7 @@ test("simple", () => { }, onFeatureDelete: expect.any(Function), onFeatureFetch: expect.any(Function), + onLayerFetch: expect.any(Function), onComputedFeatureFetch: expect.any(Function), onComputedFeatureDelete: expect.any(Function), onFeatureRequest: expect.any(Function), @@ -45,6 +46,7 @@ test("simple", () => { }, onFeatureDelete: expect.any(Function), onFeatureFetch: expect.any(Function), + onLayerFetch: expect.any(Function), onComputedFeatureFetch: expect.any(Function), onComputedFeatureDelete: expect.any(Function), onFeatureRequest: expect.any(Function), diff --git a/web/src/beta/lib/core/Map/ref.ts b/web/src/beta/lib/core/Map/ref.ts index 50412d3d55..6390614474 100644 --- a/web/src/beta/lib/core/Map/ref.ts +++ b/web/src/beta/lib/core/Map/ref.ts @@ -60,6 +60,8 @@ const engineRefKeys: FunctionKeys = { inViewport: 1, onTick: 1, removeTickEventListener: 1, + findFeaturesByIds: 1, + findFeatureById: 1, }; const layersRefKeys: FunctionKeys = { diff --git a/web/src/beta/lib/core/Map/types/index.ts b/web/src/beta/lib/core/Map/types/index.ts index 16945f5938..baf1c60111 100644 --- a/web/src/beta/lib/core/Map/types/index.ts +++ b/web/src/beta/lib/core/Map/types/index.ts @@ -15,6 +15,7 @@ import type { LatLng, DataType, SelectedFeatureInfo, + Feature, } from "../../mantle"; import type { FeatureComponentType, @@ -96,6 +97,8 @@ export type EngineRef = { onTick: TickEvent; tickEventCallback?: RefObject; removeTickEventListener: TickEvent; + findFeatureById: (layerId: string, featureId: string) => Feature | undefined; + findFeaturesByIds: (layerId: string, featureId: string[]) => Feature[] | undefined; }; export type EngineProps = { diff --git a/web/src/beta/lib/core/engines/Cesium/Feature/Tileset/hooks.ts b/web/src/beta/lib/core/engines/Cesium/Feature/Tileset/hooks.ts index 1f33598eb6..aaf383f6f8 100644 --- a/web/src/beta/lib/core/engines/Cesium/Feature/Tileset/hooks.ts +++ b/web/src/beta/lib/core/engines/Cesium/Feature/Tileset/hooks.ts @@ -19,11 +19,14 @@ import { Resource, defaultValue, ImageBasedLighting, + Cesium3DTileContent, } from "cesium"; import { isEqual, pick } from "lodash-es"; import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CesiumComponentRef, useCesium } from "resium"; +import { LayerSimple } from "@reearth/beta/lib/core/Map"; + import type { ComputedFeature, ComputedLayer, @@ -67,26 +70,30 @@ const useData = (layer: ComputedLayer | undefined) => { }; const makeFeatureFrom3DTile = ( - id: string, - feature: InternalCesium3DTileFeature, - coordinates: number[], + tileFeature: InternalCesium3DTileFeature, + content: Cesium3DTileContent, ): Feature => { + const coordinates = content.tile.boundingSphere.center; + const featureId = getBuiltinFeatureId(tileFeature); + const id = generateIDWithMD5(`${coordinates.x}-${coordinates.y}-${coordinates.z}-${featureId}`); const properties = - feature instanceof Model + tileFeature instanceof Model ? {} - : Object.fromEntries(feature.getPropertyIds().map(id => [id, feature.getProperty(id)])); + : Object.fromEntries( + tileFeature.getPropertyIds().map(id => [id, tileFeature.getProperty(id)]), + ); return { type: "feature", id, geometry: { type: "Point", - coordinates, + coordinates: [coordinates.x, coordinates.y, coordinates.z], }, properties, range: { - x: coordinates[0], - y: coordinates[1], - z: coordinates[2], + x: coordinates.x, + y: coordinates.y, + z: coordinates.z, }, }; }; @@ -144,12 +151,14 @@ const useFeature = ({ layer, evalFeature, + onComputedFeatureFetch, }: { id?: string; tileset: MutableRefObject; layer?: ComputedLayer; evalFeature: EvalFeature; + onComputedFeatureFetch?: (f: Feature[], cf: ComputedFeature[]) => void; }) => { const cachedFeaturesRef = useRef([]); const cachedCalculatedLayerRef = useRef(layer); @@ -202,34 +211,32 @@ const useFeature = ({ [evalFeature, layerId], ); - useEffect(() => { - tileset.current?.tileLoad.addEventListener((t: Cesium3DTile) => { - if (t.tileset.isDestroyed()) return; - lookupFeatures(t.content, async (tileFeature, content) => { - const coordinates = content.tile.boundingSphere.center; - const featureId = getBuiltinFeatureId(tileFeature); - const id = generateIDWithMD5( - `${coordinates.x}-${coordinates.y}-${coordinates.z}-${featureId}`, - ); - const feature = (() => { - const normalFeature = makeFeatureFrom3DTile(id, tileFeature, [ - coordinates.x, - coordinates.y, - coordinates.z, - ]); - const feature: CachedFeature = { - feature: normalFeature, - raw: tileFeature, - }; - cachedFeaturesRef.current.push(feature); - cachedFeatureIds.current.add(id); - return feature; - })(); - - await attachComputedFeature(feature); - }); - }); - }, [tileset, cachedFeaturesRef, attachComputedFeature, layerId]); + useEffect( + () => + tileset.current?.tileLoad.addEventListener(async (t: Cesium3DTile) => { + if (t.tileset.isDestroyed()) return; + const features = new Set(); + await lookupFeatures(t.content, async (tileFeature, content) => { + const feature = (() => { + const normalFeature = makeFeatureFrom3DTile(tileFeature, content); + const feature: CachedFeature = { + feature: normalFeature, + raw: tileFeature, + }; + cachedFeaturesRef.current.push(feature); + cachedFeatureIds.current.add(normalFeature.id); + return feature; + })(); + + await attachComputedFeature(feature); + + // NOTE: Don't pass a large object + features.add(pick(feature.feature, ["id", "type", "range"])); + }); + onComputedFeatureFetch?.(Array.from(features.values()), []); + }), + [tileset, cachedFeaturesRef, attachComputedFeature, layerId, onComputedFeatureFetch], + ); useEffect(() => { cachedCalculatedLayerRef.current = layer; @@ -272,6 +279,7 @@ const useFeature = ({ async (startedComputingAt: number) => { const tempAsyncProcesses: Promise[] = []; let skipped = false; + // TODO: Search the layer's features from tilesetRef to improve performance instead of using cachedFeaturesRef for (const f of cachedFeaturesRef.current) { if (skippedComputingAt.current && skippedComputingAt.current > startedComputingAt) { skipped = true; @@ -312,9 +320,10 @@ export const useHooks = ({ property, sceneProperty, layer, - feature, meta, evalFeature, + onComputedFeatureFetch, + onLayerFetch, }: { id: string; boxId: string; @@ -325,6 +334,8 @@ export const useHooks = ({ feature?: ComputedFeature; meta?: Record; evalFeature: EvalFeature; + onComputedFeatureFetch?: (f: Feature[], cf: ComputedFeature[]) => void; + onLayerFetch?: (value: Partial>) => void; }) => { const { viewer } = useCesium(); const { tileset, styleUrl, edgeColor, edgeWidth, experimental_clipping, apiKey } = property ?? {}; @@ -384,14 +395,14 @@ export const useHooks = ({ const ref = useCallback( (tileset: CesiumComponentRef | null) => { if (tileset?.cesiumElement) { - attachTag(tileset.cesiumElement, { layerId: layer?.id || id, featureId: feature?.id }); + attachTag(tileset.cesiumElement, { layerId: layer?.id || id }); } if (layer?.id && tileset?.cesiumElement) { (tileset?.cesiumElement as any)[layerIdField] = layer.id; } tilesetRef.current = tileset?.cesiumElement; }, - [id, layer?.id, feature?.id], + [id, layer?.id], ); useFeature({ @@ -399,6 +410,7 @@ export const useHooks = ({ tileset: tilesetRef, layer, evalFeature, + onComputedFeatureFetch, }); const [terrainHeightEstimate, setTerrainHeightEstimate] = useState(0); @@ -582,6 +594,13 @@ export const useHooks = ({ sceneProperty?.light?.imageBasedLightIntensity, ]); + const handleReady = useCallback( + (tileset: Cesium3DTileset) => { + onLayerFetch?.({ properties: tileset.properties }); + }, + [onLayerFetch], + ); + return { tilesetUrl, ref, @@ -589,5 +608,6 @@ export const useHooks = ({ clippingPlanes, builtinBoxProps, imageBasedLighting, + handleReady, }; }; diff --git a/web/src/beta/lib/core/engines/Cesium/Feature/Tileset/index.tsx b/web/src/beta/lib/core/engines/Cesium/Feature/Tileset/index.tsx index 5c39e7522e..0a144fa6da 100644 --- a/web/src/beta/lib/core/engines/Cesium/Feature/Tileset/index.tsx +++ b/web/src/beta/lib/core/engines/Cesium/Feature/Tileset/index.tsx @@ -22,11 +22,21 @@ function Tileset({ sceneProperty, meta, evalFeature, + onComputedFeatureFetch, + onLayerFetch, ...props }: Props): JSX.Element | null { const { shadows, colorBlendMode, pbr } = property ?? {}; const boxId = `${layer?.id}_box`; - const { tilesetUrl, ref, style, clippingPlanes, builtinBoxProps, imageBasedLighting } = useHooks({ + const { + tilesetUrl, + ref, + style, + clippingPlanes, + builtinBoxProps, + imageBasedLighting, + handleReady, + } = useHooks({ id, boxId, isVisible, @@ -36,6 +46,8 @@ function Tileset({ sceneProperty, meta, evalFeature, + onComputedFeatureFetch, + onLayerFetch, }); const boxProperty = useMemo( () => ({ @@ -57,6 +69,7 @@ function Tileset({ clippingPlanes={clippingPlanes} colorBlendMode={colorBlendModeFor3DTile(colorBlendMode)} imageBasedLighting={imageBasedLighting} + onReady={handleReady} /> {builtinBoxProps && ( { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + const entity = findEntity(viewer, layerId, featureId); + const tag = getTag(entity); + if (!tag?.featureId) { + return; + } + if (entity instanceof Cesium.Entity) { + // TODO: Return description for CZML + return { + type: "feature", + id: tag.featureId, + properties: entity.properties, + }; + } + if (entity instanceof Cesium.Cesium3DTileFeature) { + return { + type: "feature", + id: tag.featureId, + properties: Object.fromEntries( + entity.getPropertyIds().map(key => [key, entity.getProperty(key)]), + ), + }; + } + return; + }, + findFeaturesByIds: (layerId: string, featureIds: string[]): Feature[] | undefined => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + return featureIds.map(f => e.findFeatureById(layerId, f)).filter((v): v is Feature => !!v); + }, onTick: cb => { tickEventCallback.current.push(cb); }, diff --git a/web/src/beta/lib/core/mantle/atoms/compute.test.ts b/web/src/beta/lib/core/mantle/atoms/compute.test.ts index 19816c3a2a..5e0625a710 100644 --- a/web/src/beta/lib/core/mantle/atoms/compute.test.ts +++ b/web/src/beta/lib/core/mantle/atoms/compute.test.ts @@ -174,7 +174,11 @@ test("computeAtom", async () => { act(() => { result.current.set({ type: "writeComputedFeatures", - value: { feature: features3, computed: toComputedFeature(features3) }, + value: { + feature: features3, + computed: toComputedFeature(features3), + needComputingLayer: true, + }, }); }); diff --git a/web/src/beta/lib/core/mantle/atoms/compute.ts b/web/src/beta/lib/core/mantle/atoms/compute.ts index 26f6a1c6b5..41467bae17 100644 --- a/web/src/beta/lib/core/mantle/atoms/compute.ts +++ b/web/src/beta/lib/core/mantle/atoms/compute.ts @@ -12,6 +12,7 @@ import type { DataType, Feature, Layer, + LayerSimple, } from "../types"; import { appearanceKeys } from "../types"; @@ -21,9 +22,13 @@ export type Atom = ReturnType; export type Command = | { type: "setLayer"; layer?: Layer } + | { type: "writeLayer"; value: Partial> } | { type: "requestFetch"; range: DataRange } | { type: "writeFeatures"; features: Feature[] } - | { type: "writeComputedFeatures"; value: { feature: Feature[]; computed: ComputedFeature[] } } + | { + type: "writeComputedFeatures"; + value: { feature: Feature[]; computed: ComputedFeature[]; needComputingLayer?: boolean }; + } | { type: "deleteFeatures"; features: string[] } | { type: "deleteComputedFeatures"; features: string[] } | { type: "override"; overrides?: Record } @@ -65,6 +70,7 @@ export function computeAtom(cache?: typeof globalDataFeaturesCache) { currentLayer.type === "simple" && currentLayer.data ? get(dataAtoms.getAll)(currentLayer.id, currentLayer.data)?.flat() ?? [] : [], + properties: currentLayer.type === "simple" ? currentLayer.properties : undefined, ...get(computedResult)?.layer, }; }); @@ -176,6 +182,13 @@ export function computeAtom(cache?: typeof globalDataFeaturesCache) { await set(dataAtoms.fetch, { data: currentLayer.data, range: value, layerId: currentLayer.id }); }); + // For delegated data + const writeLayer = atom(null, (get, set, value: Partial>) => { + const currentLayer = get(layer); + if (currentLayer?.type !== "simple" || !currentLayer.data) return; + set(layer, { ...currentLayer, ...value }); + }); + const writeFeatures = atom(null, async (get, set, value: Feature[]) => { const currentLayer = get(layer); if (currentLayer?.type !== "simple" || !currentLayer.data) return; @@ -190,7 +203,11 @@ export function computeAtom(cache?: typeof globalDataFeaturesCache) { const writeComputedFeatures = atom( null, - async (get, set, value: { feature: Feature[]; computed: ComputedFeature[] }) => { + async ( + get, + set, + value: { feature: Feature[]; computed: ComputedFeature[]; needComputingLayer?: boolean }, + ) => { const currentLayer = get(layer); if (currentLayer?.type !== "simple" || !currentLayer.data) return; @@ -208,21 +225,19 @@ export function computeAtom(cache?: typeof globalDataFeaturesCache) { layerId: currentLayer.id, }); - const computedLayer = await evalLayer(currentLayer, { - getAllFeatures: async () => undefined, - getFeatures: async () => undefined, - }); - - if (!computedLayer) { - return; - } + const computedLayer = value.needComputingLayer + ? await evalLayer(currentLayer, { + getAllFeatures: async () => undefined, + getFeatures: async () => undefined, + }) + : undefined; set(layerStatus, "ready"); const prevResult = get(computedResult); const result = { - layer: computedLayer.layer, + layer: computedLayer?.layer, features: [...(prevResult?.features || []), ...value.computed], }; @@ -285,6 +300,9 @@ export function computeAtom(cache?: typeof globalDataFeaturesCache) { case "setLayer": await s(set, value.layer); break; + case "writeLayer": + await s(writeLayer, value.value); + break; case "requestFetch": await s(requestFetch, value.range); break; diff --git a/web/src/beta/lib/core/mantle/evaluator/index.ts b/web/src/beta/lib/core/mantle/evaluator/index.ts index c17bf14987..39ce0e3fbb 100644 --- a/web/src/beta/lib/core/mantle/evaluator/index.ts +++ b/web/src/beta/lib/core/mantle/evaluator/index.ts @@ -19,7 +19,7 @@ export type EvalContext = { export type EvalResult = { features?: ComputedFeature[]; - layer: Partial; + layer?: Partial; }; export async function evalLayer(