From 2ef6427b47fefb7928959e2b5fc1c5af0114f7e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gunnar=20Ingi=20Stef=C3=A1nsson?= Date: Mon, 13 Jan 2025 15:07:33 +0100 Subject: [PATCH] - gives individual mesh components separate names making them easier to work with in external software - gives the terrain a different material then other mesh components --- app/components/toast.tsx | 16 ++- app/hooks/queries/download/create-mesh.tsx | 118 +++++++++++++----- app/hooks/queries/download/export-query.tsx | 24 ++-- app/hooks/queries/feature-query.tsx | 5 +- .../_root.$scene/sidebar/export-settings.tsx | 5 +- app/symbology/symbology.tsx | 26 +++- 6 files changed, 146 insertions(+), 48 deletions(-) diff --git a/app/components/toast.tsx b/app/components/toast.tsx index 8acaf61..c752c8e 100644 --- a/app/components/toast.tsx +++ b/app/components/toast.tsx @@ -26,7 +26,11 @@ class ToastStore extends Accessor { @property() messages = [] as ToastMessage[]; - toast = (message: ToastMessage) => { + toast = (message: ToastMessage | ToastableError) => { + if (message instanceof ToastableError && message.originalError) { + console.error(message.originalError); + } + if (this.messages.some(m => m.key === message.key)) return; this.messages = [...this.messages, message]; @@ -92,6 +96,7 @@ export class ToastableError extends Error { message: string; title: string; severity: ComponentProps["kind"]; + originalError?: unknown get toast(): ToastMessage { return { @@ -102,11 +107,18 @@ export class ToastableError extends Error { } } - constructor(props: { key: string, message: string, title: string, severity: ComponentProps["kind"] }, options?: ErrorOptions) { + constructor(props: { + key: string, + message: string, + title: string, + severity: ComponentProps["kind"], + originalError?: unknown + }, options?: ErrorOptions) { super(props.message, options); this.key = props.key; this.message = props.message; this.title = props.title; this.severity = props.severity; + this.originalError = props.originalError; } } \ No newline at end of file diff --git a/app/hooks/queries/download/create-mesh.tsx b/app/hooks/queries/download/create-mesh.tsx index b45e849..8df2d3e 100644 --- a/app/hooks/queries/download/create-mesh.tsx +++ b/app/hooks/queries/download/create-mesh.tsx @@ -17,66 +17,114 @@ import MeshLocalVertexSpace from "@arcgis/core/geometry/support/MeshLocalVertexS import MeshGeoreferencedVertexSpace from "@arcgis/core/geometry/support/MeshGeoreferencedVertexSpace"; import * as meshUtils from "@arcgis/core/geometry/support/meshUtils"; import type Ground from '@arcgis/core/Ground'; -import { type Extent, Point } from "@arcgis/core/geometry"; +import { type Extent, Point, type SpatialReference } from "@arcgis/core/geometry"; import type WebScene from "@arcgis/core/WebScene"; // import convertGLBToOBJ from "./obj-conversion"; import * as projection from "@arcgis/core/geometry/projection"; -import { createOriginMarker } from "~/symbology/symbology"; +import { createOriginMarker, ExportColors } from "~/symbology/symbology"; +import MeshMaterial from "@arcgis/core/geometry/support/MeshMaterial.js"; +import type { MeshGraphic } from "./export-query"; async function extractElevation(ground: Ground, extent: __esri.Extent) { - return await meshUtils.createFromElevation(ground, extent, { + const mesh = await meshUtils.createFromElevation(ground, extent, { demResolution: "finest-contiguous" }); + + for (const component of mesh.components) { + component.name = "elevation"; + component.material ??= new MeshMaterial({ + color: ExportColors.terrain() + }) + } + + return mesh; +} + +async function createLayerMeshes({ + layer, + features, + vertexSpace, + signal, +}: { + layer: __esri.SceneLayer, + features: MeshGraphic[], + vertexSpace: MeshLocalVertexSpace | MeshGeoreferencedVertexSpace, + signal?: AbortSignal +}) { + const meshPromises = features + .map(async (feature) => { + const { geometry: mesh } = feature; + + await mesh.load(); + + const objectId = feature.getObjectId(); + for (const component of mesh.components) { + component.name = `${layer.title}-${objectId}`; + // if the feature already has a material, we use that instead + component.material ??= new MeshMaterial({ + color: ExportColors.feature() + }); + } + return meshUtils.convertVertexSpace(mesh, vertexSpace, { signal }); + }) + + const meshes = await Promise.all(meshPromises) + return meshes; } async function mergeSliceMeshes( { elevation, - features, + features: featureMap, origin, includeOriginMarker = true, + spatialReference, signal, }: { elevation: Mesh, - features: Mesh[], + features: Map<__esri.SceneLayer, MeshGraphic[]> origin: Point, includeOriginMarker?: boolean, + spatialReference: SpatialReference; signal?: AbortSignal }) { - const originSpatialReference = origin.spatialReference; - const featureSpatialReference = features.at(0)?.spatialReference ?? originSpatialReference; - - let projectedOrigin = origin; - if (originSpatialReference.wkid !== featureSpatialReference.wkid) { - await projection.load(); - projectedOrigin = projection.project(origin, featureSpatialReference) as Point; - } - - const VertexSpace = projectedOrigin.spatialReference.isWGS84 || projectedOrigin.spatialReference.isWebMercator + const VertexSpace = spatialReference.isWGS84 || spatialReference.isWebMercator ? MeshLocalVertexSpace : MeshGeoreferencedVertexSpace const vertexSpace = new VertexSpace({ - origin: [projectedOrigin.x, projectedOrigin.y, projectedOrigin.z], + origin: [origin.x, origin.y, origin.z], }); - const meshPromises = features - .map(async (mesh) => { - await mesh.load(); - return meshUtils.convertVertexSpace(mesh, vertexSpace, { signal }); - }) - .concat(meshUtils.convertVertexSpace(elevation, vertexSpace, { signal })); + const promises: Promise[] = []; + for (const [layer, features] of featureMap.entries()) { + if (layer.spatialReference.wkid !== origin.spatialReference.wkid) { + console.warn(`Layer ${layer.title} has a different spatial reference than previous layers. Skipping.`); + continue; + } + + const meshes = createLayerMeshes({ + layer, + features, + vertexSpace, + signal, + }); + promises.push(meshes); + } + + promises.push(meshUtils.convertVertexSpace(elevation, vertexSpace, { signal })); if (includeOriginMarker) { - const zmax = features.reduce((max, next) => next.extent.zmax > max ? next.extent.zmax : max, elevation.extent.zmax); - const zmin = features.reduce((min, next) => min > next.extent.zmin ? next.extent.zmin : min, elevation.extent.zmin); + const features = Array.from(featureMap.values()).flat(); + const zmax = features.reduce((max, { geometry: next }) => next.extent.zmax > max ? next.extent.zmax : max, elevation.extent.zmax); + const zmin = features.reduce((min, { geometry: next }) => min > next.extent.zmin ? next.extent.zmin : min, elevation.extent.zmin); const height = zmax - zmin; - const originMesh = await createOriginMarker(projectedOrigin, height); - meshPromises.push(meshUtils.convertVertexSpace(originMesh, vertexSpace, { signal })) + const originMesh = await createOriginMarker(origin, height); + promises.push(meshUtils.convertVertexSpace(originMesh, vertexSpace, { signal })) } - const meshes = await Promise.all(meshPromises) + const meshes = await Promise.all(promises).then((meshes) => meshes.flat()); const slice = meshUtils.merge(meshes.filter((mesh): mesh is Mesh => mesh != null)); @@ -93,21 +141,29 @@ export async function createMesh({ }: { scene: WebScene, extent: Extent, - features: Mesh[], + features: Map<__esri.SceneLayer, MeshGraphic[]> signal?: AbortSignal, origin: Point, includeOriginMarker?: boolean }) { const ground = scene.ground; - const sr = features.at(0)?.spatialReference ?? extent.spatialReference; - const projectedExtent = projection.project(extent, sr) as Extent; + const originSpatialReference = origin.spatialReference; + const sr = features.keys().next().value?.spatialReference ?? originSpatialReference; + + let projectedExtent = extent; + if (extent.spatialReference.wkid !== sr.wkid) { + await projection.load(); + projectedExtent = projection.project(extent, sr) as Extent; + } + const elevation = await extractElevation(ground, projectedExtent); const slice = await mergeSliceMeshes({ elevation, - features, + features: features, origin, includeOriginMarker, + spatialReference: sr, signal, }); diff --git a/app/hooks/queries/download/export-query.tsx b/app/hooks/queries/download/export-query.tsx index 932dcb1..0637f9f 100644 --- a/app/hooks/queries/download/export-query.tsx +++ b/app/hooks/queries/download/export-query.tsx @@ -23,6 +23,7 @@ import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query'; import { useAccessorValue } from "~/arcgis/reactive-hooks"; import { ToastableError, useToast } from "~/components/toast"; import WebScene from "@arcgis/core/WebScene"; +import type Graphic from "@arcgis/core/Graphic"; export function useExportSizeQuery({ enabled = false, includeOriginMarker = true }: { enabled?: boolean, includeOriginMarker?: boolean }) { const scene = useScene() @@ -71,7 +72,7 @@ export function useExportSizeQuery({ enabled = false, includeOriginMarker = true const blob = await createModelBlob({ scene, extent: selection!.extent, - meshes: features.map(f => f.geometry as Mesh), + features: featureQuery.data!, signal, origin: modelOrigin!, includeOriginMarker, @@ -101,7 +102,7 @@ export function useDownloadExportMutation() { filename: string, scene: WebScene, extent: Extent, - meshes: Mesh[], + features: Map<__esri.SceneLayer, MeshGraphic[]> origin: Point, signal?: AbortSignal }) => { @@ -131,7 +132,7 @@ async function createModelBlob(args: { filename: string, scene: WebScene, extent: Extent, - meshes: Mesh[], + features: Map<__esri.SceneLayer, MeshGraphic[]> origin: Point, signal?: AbortSignal }) { @@ -139,12 +140,13 @@ async function createModelBlob(args: { includeOriginMarker = false, scene, extent, - meshes, + features, origin, signal } = args; - if (meshes.length > MAX_FEATURES) { + const featureCount = Array.from(features.values()).flat().length; + if (featureCount > MAX_FEATURES) { throw new ToastableError({ key: 'too-many-features', message: 'Too many features have been selected', @@ -157,22 +159,28 @@ async function createModelBlob(args: { // eslint-disable-next-line no-var var mesh = await createMesh({ scene, - extent: extent, - features: meshes, + extent, + features, origin, includeOriginMarker, signal, }); - } catch (_error) { + } catch (error) { throw new ToastableError({ key: 'mesh-creation-failed', message: 'Failed to create mesh', title: 'Export error', severity: 'danger', + originalError: error }) } const file = await mesh.toBinaryGLTF(); const blob = new Blob([file], { type: 'model/gltf-binary' }); return blob +} + +export type MeshGraphic = Omit & { geometry: Mesh } +export function filterMeshGraphicsFromFeatureSet(featureSet: __esri.FeatureSet): MeshGraphic[] { + return featureSet.features.filter(feature => feature.geometry.type === "mesh") as any as MeshGraphic[] } \ No newline at end of file diff --git a/app/hooks/queries/feature-query.tsx b/app/hooks/queries/feature-query.tsx index 21df4e5..5afaa80 100644 --- a/app/hooks/queries/feature-query.tsx +++ b/app/hooks/queries/feature-query.tsx @@ -23,6 +23,7 @@ import { useSceneLayerViews } from "../useSceneLayers"; import { useQuery } from '@tanstack/react-query'; import { useAccessorValue } from "../../arcgis/reactive-hooks"; import { useDebouncedValue } from "../useDebouncedValue"; +import { filterMeshGraphicsFromFeatureSet, type MeshGraphic } from "./download/export-query"; export function useSelectedFeaturesFromLayerViews(key?: string) { const store = useSelectionState(); @@ -107,7 +108,7 @@ export function useSelectedFeaturesFromLayers(enabled = false) { const query = useQuery({ queryKey: ['selected-features', 'layers', sceneLayerViews?.map(lv => lv.layer.id), deferredPolygon?.rings], queryFn: async ({ signal }) => { - const featureMap = new Map(); + const featureMap = new Map(); const promises: Promise[] = []; for (const { layer } of sceneLayerViews!) { const query = layer.createQuery(); @@ -115,7 +116,7 @@ export function useSelectedFeaturesFromLayers(enabled = false) { query.spatialRelationship = 'intersects' const queryPromise = layer.queryFeatures(query, { signal }) .then((featureSet) => { - featureMap.set(layer, featureSet.features) + featureMap.set(layer, filterMeshGraphicsFromFeatureSet(featureSet)) }); promises.push(queryPromise); } diff --git a/app/routes/_root.$scene/sidebar/export-settings.tsx b/app/routes/_root.$scene/sidebar/export-settings.tsx index ed2306e..948278a 100644 --- a/app/routes/_root.$scene/sidebar/export-settings.tsx +++ b/app/routes/_root.$scene/sidebar/export-settings.tsx @@ -29,7 +29,6 @@ import { useSelectionState } from "~/routes/_root.$scene/selection/selection-sto import { useReferenceElementId } from "../selection/walk-through-context"; import { useHasTooManyFeatures, useSelectedFeaturesFromLayers } from "~/hooks/queries/feature-query"; import { usePreciseOriginElevationInfo } from "~/hooks/queries/elevation-query"; -import { Mesh } from "@arcgis/core/geometry"; interface ExportSettingsProps { state: BlockState['state']; @@ -52,7 +51,7 @@ export default function ExportSettings({ dispatch, state }: ExportSettingsProps) const featureQuery = useSelectedFeaturesFromLayers(editingState === 'idle'); - const features = Array.from(featureQuery.data?.values() ?? []).flat(); + const modelOrigin = usePreciseOriginElevationInfo().data; const hasTooManyFeatures = useHasTooManyFeatures(); @@ -156,7 +155,7 @@ export default function ExportSettings({ dispatch, state }: ExportSettingsProps) mutation.mutateAsync({ scene, extent: selection!.extent, - meshes: features.map(f => f.geometry as Mesh), + features: featureQuery.data!, origin: modelOrigin!, includeOriginMarker, filename, diff --git a/app/symbology/symbology.tsx b/app/symbology/symbology.tsx index c475b46..266f5d2 100644 --- a/app/symbology/symbology.tsx +++ b/app/symbology/symbology.tsx @@ -18,6 +18,8 @@ import Mesh from "@arcgis/core/geometry/Mesh"; import * as meshUtils from "@arcgis/core/geometry/support/meshUtils"; import { PointSymbol3D, ObjectSymbol3DLayer } from "@arcgis/core/symbols"; import Diamond from './diamond.gltf?url'; +import MeshMaterial from "@arcgis/core/geometry/support/MeshMaterial"; +import MeshMaterialMetallicRoughness from "@arcgis/core/geometry/support/MeshMaterialMetallicRoughness.js"; export const SymbologyColors = { selection(alpha = 1) { @@ -34,6 +36,18 @@ export const SymbologyColors = { } } as const; +export const ExportColors = { + terrain(alpha = 1) { + return new Color([106, 106, 106, alpha]) + }, + feature(alpha = 1) { + return new Color([210, 210, 210, alpha]) + }, + marker(alpha = 1) { + return new Color([255, 0, 0, alpha]) + } +} + const diamondSize = 7.5; const cylinderSize = 2; @@ -64,13 +78,16 @@ export function createOriginSymbol(height: number) { } export async function createOriginMarker(origin: Point, meshHeight: number) { + const material = new MeshMaterial({ + color: ExportColors.marker(1) + }); const markerHeight = meshHeight; - + new MeshMaterialMetallicRoughness const box = (await Mesh.createFromGLTF(origin, Diamond)) .scale(diamondSize) .offset(0, 0, markerHeight); - const cylender = Mesh.createCylinder(origin) + const cylender = Mesh.createCylinder(origin); const transform = cylender.transform?.clone() ?? {}; transform.scale = [cylinderSize, cylinderSize, markerHeight]; @@ -78,5 +95,10 @@ export async function createOriginMarker(origin: Point, meshHeight: number) { const mesh = meshUtils.merge([box, cylender]); + for (const component of mesh.components) { + component.name = "origin-point"; + component.material = material + } + return mesh; }