diff --git a/app/components/Viewer/ObjectTree/Base/CommonTreeView.vue b/app/components/Viewer/ObjectTree/Base/CommonTreeView.vue new file mode 100644 index 00000000..287f718d --- /dev/null +++ b/app/components/Viewer/ObjectTree/Base/CommonTreeView.vue @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/components/Viewer/ObjectTree/Base/Controls.vue b/app/components/Viewer/ObjectTree/Base/Controls.vue index 41163d67..a60cf619 100644 --- a/app/components/Viewer/ObjectTree/Base/Controls.vue +++ b/app/components/Viewer/ObjectTree/Base/Controls.vue @@ -9,7 +9,7 @@ const { search, sortType, filterOptions, availableFilterOptions } = defineProps( availableFilterOptions: { type: Array, required: true }, }); -const emit = defineEmits(["update:search", "toggle-sort"]); +const emit = defineEmits(["update:search", "toggle-sort", "collapse-all"]); @@ -53,6 +53,13 @@ const emit = defineEmits(["update:search", "toggle-sort"]); + diff --git a/app/components/Viewer/ObjectTree/Base/ItemLabel.vue b/app/components/Viewer/ObjectTree/Base/ItemLabel.vue index acbbce2f..f7ffed30 100644 --- a/app/components/Viewer/ObjectTree/Base/ItemLabel.vue +++ b/app/components/Viewer/ObjectTree/Base/ItemLabel.vue @@ -1,37 +1,62 @@ - - {{ actualItem.title }} - - - ID: {{ actualItem.id }} - Name: {{ actualItem.title }} + + + - - Status: - {{ actualItem.is_active ? "Active" : "Inactive" }} + {{ actualItem.title }} + + + + + + ID: {{ actualItem.id }} + + + Name: {{ actualItem.title }} + + + Status: + {{ actualItem.is_active ? "Active" : "Inactive" }} - + diff --git a/app/components/Viewer/ObjectTree/Base/TreeRow.vue b/app/components/Viewer/ObjectTree/Base/TreeRow.vue new file mode 100644 index 00000000..6a3b764a --- /dev/null +++ b/app/components/Viewer/ObjectTree/Base/TreeRow.vue @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + {{ item.raw[itemProps.title] || item.id }} + + + + + + + + + + + diff --git a/app/components/Viewer/ObjectTree/Box.vue b/app/components/Viewer/ObjectTree/Box.vue index 60f30af8..c920d7ca 100644 --- a/app/components/Viewer/ObjectTree/Box.vue +++ b/app/components/Viewer/ObjectTree/Box.vue @@ -1,4 +1,6 @@ - + mdi-drag-variant {{ title }} @@ -103,10 +164,11 @@ watch( /> - + @@ -118,15 +180,44 @@ watch( diff --git a/app/components/Viewer/ObjectTree/Layout.vue b/app/components/Viewer/ObjectTree/Layout.vue index c0653521..3f8d4bbe 100644 --- a/app/components/Viewer/ObjectTree/Layout.vue +++ b/app/components/Viewer/ObjectTree/Layout.vue @@ -48,7 +48,7 @@ watch( { immediate: true }, ); -watch(maxWidth, (newMax) => { +watch([maxWidth, () => additionalViews.value.length], ([newMax]) => { const hasAdditional = additionalViews.value.length > 0; const gap = hasAdditional ? GAP_WIDTH : 0; const total = @@ -143,20 +143,20 @@ function onVerticalResizeStart(event, index) { const deltaPercent = (deltaY / containerHeight) * PERCENT_100; const minHeightPercent = (HEIGHT_MIN / containerHeight) * PERCENT_100; - let newH1 = startHeight1 + deltaPercent; - let newH2 = startHeight2 - deltaPercent; + let newHeight1 = startHeight1 + deltaPercent; + let newHeight2 = startHeight2 - deltaPercent; - if (newH1 < minHeightPercent) { - newH1 = minHeightPercent; - newH2 = startHeight1 + startHeight2 - minHeightPercent; - } else if (newH2 < minHeightPercent) { - newH2 = minHeightPercent; - newH1 = startHeight1 + startHeight2 - minHeightPercent; + if (newHeight1 < minHeightPercent) { + newHeight1 = minHeightPercent; + newHeight2 = startHeight1 + startHeight2 - minHeightPercent; + } else if (newHeight2 < minHeightPercent) { + newHeight2 = minHeightPercent; + newHeight1 = startHeight1 + startHeight2 - minHeightPercent; } const newHeights = [...rowHeights.value]; - newHeights[index] = newH1; - newHeights[index + 1] = newH2; + newHeights[index] = newHeight1; + newHeights[index + 1] = newHeight2; treeviewStore.setRowHeights(newHeights); document.body.style.userSelect = "none"; @@ -272,7 +272,7 @@ function onVerticalResizeStart(event, index) { display: flex; flex-direction: column; height: 100%; - overflow-y: auto; + overflow-y: hidden; overflow-x: hidden; flex-shrink: 0; } @@ -286,6 +286,8 @@ function onVerticalResizeStart(event, index) { } .view-wrapper { + display: flex; + flex-direction: column; overflow: hidden; padding: 2px; transition: transform 0.2s; diff --git a/app/components/Viewer/ObjectTree/Views/GlobalObjects.vue b/app/components/Viewer/ObjectTree/Views/GlobalObjects.vue index 47748688..12fda631 100644 --- a/app/components/Viewer/ObjectTree/Views/GlobalObjects.vue +++ b/app/components/Viewer/ObjectTree/Views/GlobalObjects.vue @@ -1,4 +1,5 @@ @@ -91,26 +101,29 @@ function handleHoverLeave(item) { :filter-options="filterOptions" :available-filter-options="availableFilterOptions" @toggle-sort="toggleSort" + @collapse-all="opened = []" /> - - + @@ -127,11 +140,24 @@ function handleHoverLeave(item) { " /> - + diff --git a/app/composables/use_hover_highlight.js b/app/composables/use_hover_highlight.js index a9e74931..13ceb3b1 100644 --- a/app/composables/use_hover_highlight.js +++ b/app/composables/use_hover_highlight.js @@ -1,7 +1,7 @@ import { useViewerStore } from "@ogw_front/stores/viewer"; import vtk_schemas from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_schemas.json"; -const HOVER_DELAY = 1000; +const HOVER_DELAY = 200; export function useHoverhighlight() { const viewerStore = useViewerStore(); @@ -9,7 +9,7 @@ export function useHoverhighlight() { let currentId = undefined; let currentType = undefined; - function onHoverEnter(id, block_ids = [], type = "model") { + function onHoverEnter(id, block_ids_provider = [], type = "model") { if (timer) { clearTimeout(timer); } @@ -17,12 +17,32 @@ export function useHoverhighlight() { timer = setTimeout(async () => { currentId = id; currentType = type; + + let block_ids = []; + if (typeof block_ids_provider === "function") { + block_ids = await block_ids_provider(); + } else { + block_ids = block_ids_provider; + } + + block_ids = (Array.isArray(block_ids) ? block_ids : []) + .map((blockId) => Number.parseInt(blockId, 10)) + .filter((blockId) => !Number.isNaN(blockId)); + + if (currentId !== id) { + return; + } + const params = { id, visibility: true, ...(type === "model" && { block_ids }), }; - await viewerStore.request(schema, params); + try { + await viewerStore.request(schema, params); + } catch (error) { + console.error(`Highlight failed for ${type} ${id}:`, error); + } }, HOVER_DELAY); } @@ -38,7 +58,16 @@ export function useHoverhighlight() { visibility: false, ...(currentType === "model" && { block_ids: [] }), }; - viewerStore.request(schema, params); + try { + viewerStore.request(schema, params); + } catch (error) { + console.error(`Unhighlight failed for ${currentType} ${id}:`, error); + } + currentId = undefined; + currentType = undefined; + } + + if (!currentId) { currentId = undefined; currentType = undefined; } diff --git a/app/composables/use_model_components.js b/app/composables/use_model_components.js new file mode 100644 index 00000000..65015fd5 --- /dev/null +++ b/app/composables/use_model_components.js @@ -0,0 +1,68 @@ +import { compareSelections } from "@ogw_front/utils/treeview"; +import { useDataStore } from "@ogw_front/stores/data"; +import { useDataStyleStore } from "@ogw_front/stores/data_style"; +import { useHybridViewerStore } from "@ogw_front/stores/hybrid_viewer"; + +export function useModelComponents(viewId) { + const dataStore = useDataStore(); + const dataStyleStore = useDataStyleStore(); + const hybridViewerStore = useHybridViewerStore(); + + const items = dataStore.refFormatedMeshComponents(viewId); + const componentsCache = ref(undefined); + const localCategories = ref([]); + + onMounted(async () => { + const data = await dataStore.fetchAllMeshComponents(viewId); + componentsCache.value = markRaw(data); + }); + + watch( + items, + (newItems) => { + if (!newItems) { + localCategories.value = []; + return; + } + localCategories.value = newItems.map((newCategory) => { + const existing = localCategories.value.find((category) => category.id === newCategory.id); + if (existing) { + existing.title = newCategory.title || newCategory.id; + return existing; + } + return reactive({ + ...newCategory, + title: newCategory.title || newCategory.id, + }); + }); + }, + { immediate: true }, + ); + + const selection = dataStyleStore.visibleMeshComponents(viewId); + + async function updateVisibility(current) { + const previous = selection.value; + const { added, removed } = compareSelections(current, previous); + + if (added.length === 0 && removed.length === 0) { + return; + } + + if (added.length > 0) { + await dataStyleStore.setModelComponentsVisibility(viewId, added, true); + } + if (removed.length > 0) { + await dataStyleStore.setModelComponentsVisibility(viewId, removed, false); + } + hybridViewerStore.remoteRender(); + } + + return { + items, + componentsCache, + localCategories, + selection, + updateVisibility, + }; +} diff --git a/app/composables/use_tree_filter.js b/app/composables/use_tree_filter.js index 527f386b..9ce823bc 100644 --- a/app/composables/use_tree_filter.js +++ b/app/composables/use_tree_filter.js @@ -2,33 +2,33 @@ function customFilter(value, searchQuery, item) { if (!searchQuery) { return true; } + if (!item || !item.raw) { + return false; + } const query = searchQuery.toLowerCase(); const title = (item.raw.title || "").toLowerCase(); const idValue = String(value || "").toLowerCase(); return title.includes(query) || idValue.includes(query); } -function sortAndFormatItems(items, sortType) { +function sortAndFormatItems(itemList, sortType) { + if (!itemList || !Array.isArray(itemList)) { + return []; + } const field = sortType === "name" ? "title" : "id"; - return items.map((category) => { - const children = (category.children || []).toSorted((itemA, itemB) => { - const valueA = itemA[field] || ""; - const valueB = itemB[field] || ""; - return valueA.localeCompare(valueB, undefined, { - numeric: true, - sensitivity: "base", - }); + const options = { numeric: true, sensitivity: "base" }; + + return itemList + .filter((item) => item !== null && item !== undefined) + .toSorted((itemA, itemB) => { + const fieldA = String(itemA[field] || itemA.id || ""); + const fieldB = String(itemB[field] || itemB.id || ""); + return fieldA.localeCompare(fieldB, undefined, options); }); - return { - ...category, - id: category.id, - title: category.title || category.id, - children, - }; - }); } -function useTreeFilter(rawItems, options = {}) { +function useTreeFilter(itemsIn, options = {}) { + const rawItems = toRef(itemsIn); const search = ref(""); const sortType = ref(options.defaultSort || "name"); const filterOptions = ref(options.defaultFilters || {}); @@ -80,4 +80,4 @@ function useTreeFilter(rawItems, options = {}) { }; } -export { customFilter, useTreeFilter }; +export { customFilter, useTreeFilter, sortAndFormatItems }; diff --git a/app/composables/use_tree_scroll.js b/app/composables/use_tree_scroll.js new file mode 100644 index 00000000..42e9a4fe --- /dev/null +++ b/app/composables/use_tree_scroll.js @@ -0,0 +1,65 @@ +export function useTreeScroll(propsIn, emit, displayItems, actualItemProps) { + const SCROLL_STICKY_THRESHOLD = 10; + const DEFAULT_ITEM_HEIGHT = 44; + + const props = toRef(propsIn); + const internalScrollTop = ref(props.value.scrollTop || 0); + const virtualScrollRef = ref(undefined); + + function handleScroll(event) { + internalScrollTop.value = event.target.scrollTop; + emit("update:scrollTop", event.target.scrollTop); + } + + watch( + () => props.value.scrollTop, + (newVal) => { + if (Math.abs(newVal - internalScrollTop.value) > 1) { + internalScrollTop.value = newVal; + if (virtualScrollRef.value && virtualScrollRef.value.$el) { + virtualScrollRef.value.$el.scrollTop = newVal; + } + } + }, + ); + + const stickyHeader = computed(() => { + if (internalScrollTop.value <= SCROLL_STICKY_THRESHOLD) { + return undefined; + } + + const itemHeight = actualItemProps.value.height || DEFAULT_ITEM_HEIGHT; + const firstVisibleIndex = Math.floor(internalScrollTop.value / itemHeight); + + if (firstVisibleIndex < 0 || firstVisibleIndex >= displayItems.value.length) { + return undefined; + } + + const firstVisibleItem = displayItems.value[firstVisibleIndex]; + if (!firstVisibleItem) { + return undefined; + } + + let current = firstVisibleIndex; + const firstVisibleDepth = firstVisibleItem.depth; + + while (current >= 0) { + const item = displayItems.value[current]; + if (item && !item.isLeaf && item.depth < firstVisibleDepth) { + return item; + } + if (item && item.depth === 0 && !item.isLeaf && current < firstVisibleIndex) { + return item; + } + current -= 1; + } + return undefined; + }); + + return { + internalScrollTop, + virtualScrollRef, + stickyHeader, + handleScroll, + }; +} diff --git a/app/composables/use_virtual_tree.js b/app/composables/use_virtual_tree.js new file mode 100644 index 00000000..458e99ee --- /dev/null +++ b/app/composables/use_virtual_tree.js @@ -0,0 +1,164 @@ +export function useVirtualTree(propsIn, emit) { + const props = toRef(propsIn); + + const actualItemProps = computed(() => ({ + value: "id", + title: "title", + children: "children", + height: 44, + ...props.value.itemProps, + })); + + const actualSelection = computed(() => ({ + selectable: false, + strategy: "classic", + ...props.value.selection, + })); + + const openedSet = computed(() => new Set(props.value.opened)); + const selectedSet = computed(() => new Set(props.value.selected)); + + function toggleOpen(item) { + const id = item[actualItemProps.value.value]; + const { opened: openedArray = [] } = props.value; + const newOpened = new Set(openedArray); + if (newOpened.has(id)) { + newOpened.delete(id); + } else { + newOpened.add(id); + } + emit("update:opened", [...newOpened]); + } + + function getAllChildrenIds(item, ids = []) { + const children = item[actualItemProps.value.children]; + if (children) { + for (const child of children) { + ids.push(child[actualItemProps.value.value]); + getAllChildrenIds(child, ids); + } + } + return ids; + } + + function isSelected(item) { + const id = item[actualItemProps.value.value]; + if (selectedSet.value.has(id)) { + return true; + } + if (actualSelection.value.strategy === "classic") { + const childrenIds = getAllChildrenIds(item); + return ( + childrenIds.length > 0 && childrenIds.every((childId) => selectedSet.value.has(childId)) + ); + } + return false; + } + + function getIndeterminate(item) { + if (actualSelection.value.strategy !== "classic") { + return false; + } + const childrenIds = getAllChildrenIds(item); + if (childrenIds.length === 0) { + return false; + } + + const selectedChildren = childrenIds.filter((childId) => selectedSet.value.has(childId)); + return selectedChildren.length > 0 && selectedChildren.length < childrenIds.length; + } + + function toggleSelect(item) { + const id = item[actualItemProps.value.value]; + const { selected: selectedArray = [] } = props.value; + const newSelected = new Set(selectedArray); + const isCurrentlySelected = newSelected.has(id) || isSelected(item); + + if (actualSelection.value.strategy === "classic") { + const childrenIds = getAllChildrenIds(item); + if (isCurrentlySelected) { + newSelected.delete(id); + for (const childId of childrenIds) { + newSelected.delete(childId); + } + } else { + newSelected.add(id); + for (const childId of childrenIds) { + newSelected.add(childId); + } + } + } else if (isCurrentlySelected) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + emit("update:selected", [...newSelected]); + } + + function flattenTree(itemsList, depth = 0, result = []) { + const { search, customFilter } = props.value; + const lowerSearch = search ? search.toLowerCase() : ""; + + for (const item of itemsList) { + const id = item[actualItemProps.value.value]; + const children = item[actualItemProps.value.children]; + const hasChildren = children && children.length > 0; + + const isOpen = openedSet.value.has(id); + + if (lowerSearch) { + const matches = customFilter + ? customFilter(id, search, { raw: item }) + : (item[actualItemProps.value.title] || "").toLowerCase().includes(lowerSearch) || + String(id).toLowerCase().includes(lowerSearch); + + if (!hasChildren && !matches) { + continue; + } + + if (hasChildren) { + const subtree = []; + flattenTree(children, depth + 1, subtree); + if (subtree.length === 0 && !matches) { + continue; + } + + result.push({ + raw: item, + id, + depth, + isOpen: true, + isLeaf: false, + }); + result.push(...subtree); + continue; + } + } + + result.push({ + raw: item, + id, + depth, + isOpen, + isLeaf: !hasChildren, + }); + + if (isOpen && hasChildren) { + flattenTree(children, depth + 1, result); + } + } + return result; + } + + const displayItems = computed(() => flattenTree(props.value.items || [])); + + return { + actualItemProps, + actualSelection, + displayItems, + toggleOpen, + toggleSelect, + isSelected, + getIndeterminate, + }; +} diff --git a/app/stores/data.js b/app/stores/data.js index 89cfcbfa..da87e466 100644 --- a/app/stores/data.js +++ b/app/stores/data.js @@ -63,12 +63,49 @@ export const useDataStore = defineStore("data", () => { id: meshComponent.geode_id, title: meshComponent.name, category: meshComponent.type, - viewer_id: meshComponent.viewer_id, + viewer_id: Number(meshComponent.viewer_id), is_active: meshComponent.is_active, })), })); } + async function getMeshComponentsByType(modelId, type) { + const components = await database.model_components + .where("[id+type]") + .equals([modelId, type]) + .toArray(); + return components.map((meshComponent) => ({ + id: meshComponent.geode_id, + title: meshComponent.name, + category: meshComponent.type, + viewer_id: Number(meshComponent.viewer_id), + is_active: meshComponent.is_active, + })); + } + + async function getAllMeshComponents(modelId) { + const items = await database.model_components.where("id").equals(modelId).toArray(); + return items.map((meshComponent) => ({ + id: meshComponent.geode_id, + title: meshComponent.name, + category: meshComponent.type, + viewer_id: Number(meshComponent.viewer_id), + is_active: meshComponent.is_active, + })); + } + + async function fetchAllMeshComponents(modelId) { + const components = await getAllMeshComponents(modelId); + const byType = {}; + for (const component of components) { + if (!byType[component.category]) { + byType[component.category] = []; + } + byType[component.category].push(component); + } + return byType; + } + function refFormatedMeshComponents(modelId) { return useObservable( liveQuery(() => formatedMeshComponents(modelId)), @@ -240,6 +277,8 @@ export const useDataStore = defineStore("data", () => { meshComponentType, formatedMeshComponents, refFormatedMeshComponents, + getMeshComponentsByType, + getAllMeshComponents, registerObject, deregisterObject, addItem, @@ -259,5 +298,6 @@ export const useDataStore = defineStore("data", () => { exportStores, importStores, clear, + fetchAllMeshComponents, }; }); diff --git a/app/stores/hybrid_viewer.js b/app/stores/hybrid_viewer.js index 40bc1982..6a397375 100644 --- a/app/stores/hybrid_viewer.js +++ b/app/stores/hybrid_viewer.js @@ -1,13 +1,19 @@ +/* oxlint-disable sort-imports */ // oxlint-disable-next-line import/no-unassigned-import import "@kitware/vtk.js/Rendering/Profiles/Geometry"; +import { newInstance as vtkXMLPolyDataReader } from "@kitware/vtk.js/IO/XML/XMLPolyDataReader"; import { newInstance as vtkActor } from "@kitware/vtk.js/Rendering/Core/Actor"; -import { newInstance as vtkGenericRenderWindow } from "@kitware/vtk.js/Rendering/Misc/GenericRenderWindow"; import { newInstance as vtkMapper } from "@kitware/vtk.js/Rendering/Core/Mapper"; -import { newInstance as vtkXMLPolyDataReader } from "@kitware/vtk.js/IO/XML/XMLPolyDataReader"; +import { newInstance as vtkGenericRenderWindow } from "@kitware/vtk.js/Rendering/Misc/GenericRenderWindow"; -import { Status } from "@ogw_front/utils/status"; import { useDataStore } from "@ogw_front/stores/data"; import { useViewerStore } from "@ogw_front/stores/viewer"; +import { Status } from "@ogw_front/utils/status"; +import { + applyCameraOptions, + computeAverageBrightness, + getCameraOptions, +} from "./hybrid_viewer_utils"; import viewer_schemas from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_schemas.json"; const RGB_MAX = 255; @@ -37,6 +43,13 @@ export const useHybridViewerStore = defineStore("hybridViewer", () => { let viewStream = undefined; const gridActor = undefined; + const latestImage = ref(undefined); + const offscreenCanvas = + typeof document === "undefined" ? undefined : document.createElement("canvas"); + const offscreenCtx = offscreenCanvas + ? offscreenCanvas.getContext("2d", { willReadFrequently: true }) + : undefined; + async function initHybridViewer() { if (status.value !== Status.NOT_CREATED) { return; @@ -58,6 +71,7 @@ export const useHybridViewerStore = defineStore("hybridViewer", () => { if (is_moving.value) { return; } + latestImage.value = event.image; webGLRenderWindow.setBackgroundImage(event.image); imageStyle.opacity = 1; }); @@ -136,18 +150,10 @@ export const useHybridViewerStore = defineStore("hybridViewer", () => { } function syncRemoteCamera() { - console.log("syncRemoteCamera"); const renderer = genericRenderWindow.value.getRenderer(); const camera = renderer.getActiveCamera(); const params = { - camera_options: { - focal_point: [...camera.getFocalPoint()], - view_up: [...camera.getViewUp()], - position: [...camera.getPosition()], - view_angle: camera.getViewAngle(), - clipping_range: [...camera.getClippingRange()], - distance: camera.getDistance(), - }, + camera_options: getCameraOptions(camera), }; viewerStore.request(viewer_schemas.opengeodeweb_viewer.viewer.update_camera, params, { response_function: () => { @@ -173,12 +179,10 @@ export const useHybridViewerStore = defineStore("hybridViewer", () => { imageStyle.transition = "opacity 0.1s ease-in"; imageStyle.zIndex = 1; resize(container.value.$el.offsetWidth, container.value.$el.offsetHeight); - console.log("setContainer", container.value.$el); useMousePressed({ target: container, onPressed: (event) => { - console.log("onPressed"); if (event.button === 0) { is_moving.value = true; event.stopPropagation(); @@ -190,7 +194,6 @@ export const useHybridViewerStore = defineStore("hybridViewer", () => { return; } is_moving.value = false; - console.log("onReleased"); syncRemoteCamera(); }, }); @@ -223,25 +226,24 @@ export const useHybridViewerStore = defineStore("hybridViewer", () => { remoteRender(); } + function getAverageBrightness(rect) { + return computeAverageBrightness(rect, { + latestImage: latestImage.value, + offscreenCtx, + offscreenCanvas, + genericRenderWindow: genericRenderWindow.value, + }); + } + function exportStores() { const renderer = genericRenderWindow.value.getRenderer(); const camera = renderer.getActiveCamera(); - const cameraSnapshot = camera - ? { - focal_point: [...camera.getFocalPoint()], - view_up: [...camera.getViewUp()], - position: [...camera.getPosition()], - view_angle: camera.getViewAngle(), - clipping_range: [...camera.getClippingRange()], - distance: camera.getDistance(), - } - : camera_options; + const cameraSnapshot = getCameraOptions(camera) || camera_options; return { zScale: zScale.value, camera_options: cameraSnapshot }; } async function importStores(snapshot) { if (!snapshot) { - console.warn("importStores called with undefined snapshot"); return; } const z_scale = snapshot.zScale; @@ -255,22 +257,12 @@ export const useHybridViewerStore = defineStore("hybridViewer", () => { const renderer = genericRenderWindow.value.getRenderer(); const camera = renderer.getActiveCamera(); - camera.setFocalPoint(...snapshot_camera_options.focal_point); - camera.setViewUp(...snapshot_camera_options.view_up); - camera.setPosition(...snapshot_camera_options.position); - camera.setViewAngle(snapshot_camera_options.view_angle); - camera.setClippingRange(...snapshot_camera_options.clipping_range); + applyCameraOptions(camera, snapshot_camera_options); genericRenderWindow.value.getRenderWindow().render(); const payload = { - camera_options: { - focal_point: [...snapshot_camera_options.focal_point], - view_up: [...snapshot_camera_options.view_up], - position: [...snapshot_camera_options.position], - view_angle: snapshot_camera_options.view_angle, - clipping_range: [...snapshot_camera_options.clipping_range], - }, + camera_options: getCameraOptions(snapshot_camera_options), }; return viewerStore.request(viewer_schemas.opengeodeweb_viewer.viewer.update_camera, payload, { response_function: () => { @@ -316,5 +308,7 @@ export const useHybridViewerStore = defineStore("hybridViewer", () => { clear, exportStores, importStores, + latestImage, + getAverageBrightness, }; }); diff --git a/app/stores/hybrid_viewer_utils.js b/app/stores/hybrid_viewer_utils.js new file mode 100644 index 00000000..db68324d --- /dev/null +++ b/app/stores/hybrid_viewer_utils.js @@ -0,0 +1,101 @@ +const RGB_MAX = 255; +const BACKGROUND_GREY_VALUE = 180; +const SAMPLE_SIZE = 10; +const TOTAL_CHANNELS = 400; +const RGBA_CHANNELS = 4; + +function getCameraOptions(camera) { + if (!camera) { + return undefined; + } + + if (typeof camera.getFocalPoint !== "function") { + return { ...camera }; + } + return { + focal_point: [...camera.getFocalPoint()], + view_up: [...camera.getViewUp()], + position: [...camera.getPosition()], + view_angle: camera.getViewAngle(), + clipping_range: [...camera.getClippingRange()], + distance: camera.getDistance(), + }; +} + +function applyCameraOptions(camera, options) { + if (!camera || !options) { + return; + } + if (options.focal_point) { + camera.setFocalPoint(...options.focal_point); + } + if (options.view_up) { + camera.setViewUp(...options.view_up); + } + if (options.position) { + camera.setPosition(...options.position); + } + if (options.view_angle) { + camera.setViewAngle(options.view_angle); + } + if (options.clipping_range) { + camera.setClippingRange(...options.clipping_range); + } +} + +function mapRect(rect, latestImage, canvasRect) { + const scaleX = latestImage.width / canvasRect.width; + const scaleY = latestImage.height / canvasRect.height; + return { + relX: (rect.x - canvasRect.left) * scaleX, + relY: (rect.y - canvasRect.top) * scaleY, + relW: rect.width * scaleX, + relH: rect.height * scaleY, + }; +} + +function computeAverageBrightness(rect, options) { + const { latestImage, offscreenCtx, offscreenCanvas, genericRenderWindow } = options; + if (!latestImage || !offscreenCtx || !offscreenCanvas || !genericRenderWindow) { + return BACKGROUND_GREY_VALUE / RGB_MAX; + } + + const canvas = genericRenderWindow.getApiSpecificRenderWindow().getCanvas(); + if (!canvas) { + return BACKGROUND_GREY_VALUE / RGB_MAX; + } + + const { relX, relY, relW, relH } = mapRect(rect, latestImage, canvas.getBoundingClientRect()); + + offscreenCanvas.width = SAMPLE_SIZE; + offscreenCanvas.height = SAMPLE_SIZE; + + try { + offscreenCtx.drawImage( + latestImage, + Math.max(0, relX), + Math.max(0, relY), + Math.min(latestImage.width, relW), + Math.min(latestImage.height, relH), + 0, + 0, + SAMPLE_SIZE, + SAMPLE_SIZE, + ); + const { data } = offscreenCtx.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE); + + let minBrightness = 1; + for (let i = 0; i < TOTAL_CHANNELS; i += RGBA_CHANNELS) { + const brightness = (data[i] + data[i + 1] + data[i + 2]) / (3 * RGB_MAX); + if (brightness < minBrightness) { + minBrightness = brightness; + } + } + + return minBrightness; + } catch { + return BACKGROUND_GREY_VALUE / RGB_MAX; + } +} + +export { applyCameraOptions, computeAverageBrightness, getCameraOptions };