From 603c1e0a1b57a60dc726339a2368d821dad09364 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 29 Sep 2026 16:50:13 +0200 Subject: [PATCH 1/7] feat(objectTreeContextMenu): Add logic for context menu to interact with headers of object tree [skip ci] --- .../Viewer/Generic/Model/ModelStyleCard.vue | 60 +++++++-------- .../Viewer/ObjectTree/Views/GlobalObjects.vue | 33 ++++++-- .../Viewer/Options/AttributeColorBar.vue | 7 ++ .../Viewer/Options/AttributeSelector.vue | 64 ++++++++++++++- app/components/Viewer/Options/TextureItem.vue | 32 +++++--- app/composables/batch_style.ts | 44 ++++++++++- app/stores/menu.ts | 1 + app/utils/attributes.ts | 27 ++++++- .../unit/composables/batch_style.nuxt.test.ts | 77 +++++++++++++++++++ tests/unit/utils/attributes.nuxt.test.ts | 38 +++++++++ 10 files changed, 331 insertions(+), 52 deletions(-) create mode 100644 tests/unit/composables/batch_style.nuxt.test.ts create mode 100644 tests/unit/utils/attributes.nuxt.test.ts diff --git a/app/components/Viewer/Generic/Model/ModelStyleCard.vue b/app/components/Viewer/Generic/Model/ModelStyleCard.vue index 48d2237b6..f7c2964c2 100644 --- a/app/components/Viewer/Generic/Model/ModelStyleCard.vue +++ b/app/components/Viewer/Generic/Model/ModelStyleCard.vue @@ -9,23 +9,15 @@ import type { RGBAColor } from "@ogw_front/utils/default_styles/constants"; import SurfacesOptions from "./SurfacesOptions.vue"; import ViewerOptionsColoringTypeSelector from "@ogw_front/components/Viewer/Options/ColoringTypeSelector.vue"; import VisibilitySwitch from "@ogw_front/components/Viewer/Options/VisibilitySwitch.vue"; +import { useBatchStyle } from "@ogw_front/composables/batch_style"; import { useDataStore } from "@ogw_front/stores/data"; import { useDataStyleStore } from "@ogw_front/stores/data_style"; import { useHybridViewerStore } from "@ogw_front/stores/hybrid_viewer"; -import { useTreeviewStore } from "@ogw_front/stores/treeview"; const dataStyleStore = useDataStyleStore(); const hybridViewerStore = useHybridViewerStore(); const dataStore = useDataStore(); -const treeviewStore = useTreeviewStore(); - -function getBatchComponentIds(currentId: string): string[] { - const { activeItems } = treeviewStore; - if (activeItems.includes(currentId) && activeItems.length > 1) { - return activeItems; - } - return [currentId]; -} +const { applyBatchStyle } = useBatchStyle(); interface Props { itemProps: Record; @@ -35,9 +27,9 @@ const { itemProps } = defineProps(); const modelId = computed(() => itemProps.meta_data.modelId || itemProps.id); const componentId = computed(() => itemProps.meta_data.pickedComponentId); -const selection = computed( - () => dataStyleStore.visibleMeshComponents(modelId.value).value || [], -); +function visibleComponents(targetId: string): string[] { + return dataStyleStore.visibleMeshComponents(targetId).value || []; +} const componentType = ref(undefined); const collections = ref(undefined); @@ -105,7 +97,9 @@ const modelVisibility = computed({ if (newValue === undefined) { return; } - await dataStyleStore.setModelVisibility(modelId.value, newValue); + await applyBatchStyle(modelId.value, (targetId: string) => + dataStyleStore.setModelVisibility(targetId, newValue), + ); hybridViewerStore.remoteRender(); }, }); @@ -113,16 +107,18 @@ const modelVisibility = computed({ const modelComponentsColor = computed({ get: () => dataStyleStore.getModelColor(modelId.value) as RGBAColor | undefined, set: async (color) => { - await dataStyleStore.mutateStyle(modelId.value, { - coloring: { constant: color }, + await applyBatchStyle(modelId.value, async (targetId: string) => { + await dataStyleStore.mutateStyle(targetId, { + coloring: { constant: color }, + }); + const activeColoring = dataStyleStore.getModelActiveColoring(targetId); + await dataStyleStore.setModelComponentsColor( + targetId, + visibleComponents(targetId), + color, + typeof activeColoring === "string" ? activeColoring : undefined, + ); }); - const activeColoring = dataStyleStore.getModelActiveColoring(modelId.value); - await dataStyleStore.setModelComponentsColor( - modelId.value, - selection.value, - color, - typeof activeColoring === "string" ? activeColoring : undefined, - ); hybridViewerStore.remoteRender(); }, }); @@ -133,15 +129,17 @@ const modelComponentsActiveColoring = computed({ if (typeof coloringType !== "string") { return; } - await dataStyleStore.mutateStyle(modelId.value, { - coloring: { active: coloringType }, + await applyBatchStyle(modelId.value, async (targetId: string) => { + await dataStyleStore.mutateStyle(targetId, { + coloring: { active: coloringType }, + }); + await dataStyleStore.setModelComponentsColor( + targetId, + visibleComponents(targetId), + dataStyleStore.getModelColor(targetId), + coloringType, + ); }); - await dataStyleStore.setModelComponentsColor( - modelId.value, - selection.value, - dataStyleStore.getModelColor(modelId.value), - coloringType, - ); hybridViewerStore.remoteRender(); }, }); diff --git a/app/components/Viewer/ObjectTree/Views/GlobalObjects.vue b/app/components/Viewer/ObjectTree/Views/GlobalObjects.vue index ef8f1ad24..52d9d339f 100644 --- a/app/components/Viewer/ObjectTree/Views/GlobalObjects.vue +++ b/app/components/Viewer/ObjectTree/Views/GlobalObjects.vue @@ -17,8 +17,15 @@ const dataStyleStore = useDataStyleStore(); const hybridViewerStore = useHybridViewerStore(); const { onHoverEnter, onHoverLeave } = useHoverhighlight(); +interface ShowMenuPayload { + event: MouseEvent; + itemId: string; + context_type?: "geode_object_type"; + targetIds?: string[]; +} + interface Emits { - "show-menu": [payload: { event: MouseEvent; itemId: string }]; + "show-menu": [payload: ShowMenuPayload]; } const emit = defineEmits(); @@ -82,6 +89,21 @@ watch( }, ); +function showMenu(event: MouseEvent, item: TreeGroupItem): void { + const actualItem = item.raw || item; + if (!actualItem.children) { + emit("show-menu", { event, itemId: actualItem.id }); + return; + } + const childIds = actualItem.children.map((child) => child.id); + emit("show-menu", { + event, + itemId: actualItem.id, + context_type: "geode_object_type", + targetIds: applySearchFilter(childIds) as string[], + }); +} + function isModel(item: TreeGroupItem): boolean { const actualItem = item.raw || item; return ( @@ -190,18 +212,13 @@ function expandAll(): void { @update:scroll-top="treeviewStore.setScrollTop(mainView?.id ?? '', $event)" @hover:enter="({ item }) => handleHoverEnter({ item: item as unknown as TreeGroupItem })" @hover:leave="({ item }) => handleHoverLeave({ item: item as unknown as TreeGroupItem })" - @contextmenu=" - emit('show-menu', { - event: $event.event, - itemId: $event.item.id as string, - }) - " + @contextmenu="showMenu($event.event, $event.item as unknown as TreeGroupItem)" > diff --git a/app/components/Viewer/Options/AttributeColorBar.vue b/app/components/Viewer/Options/AttributeColorBar.vue index caf4c07c2..f2833371e 100644 --- a/app/components/Viewer/Options/AttributeColorBar.vue +++ b/app/components/Viewer/Options/AttributeColorBar.vue @@ -8,6 +8,12 @@ interface Emits { const emit = defineEmits(); +interface Props { + hideRange?: boolean; +} + +const { hideRange = false } = defineProps(); + const minimum = defineModel("minimum"); const maximum = defineModel("maximum"); const colorMap = defineModel("colorMap"); @@ -21,6 +27,7 @@ const colorMap = defineModel("colorMap"); :max="maximum ?? 0" /> +import { getAttributeRange, intersectAttributes } from "@ogw_front/utils/attributes"; +import { menuGroupTargets, runWithBatchTargets } from "@ogw_front/composables/batch_style"; import { DEFAULT_NO_DATA_COLOR } from "@ogw_front/utils/default_styles/constants"; import type { JsonRpcSchema } from "@ogw_shared/utils/types.js"; import ViewerOptionsAttributeColorBar from "@ogw_front/components/Viewer/Options/AttributeColorBar.vue"; import ViewerOptionsColorPicker from "@ogw_front/components/Viewer/Options/ColorPicker.vue"; -import { getAttributeRange } from "@ogw_front/utils/attributes"; import { useBackStore } from "@ogw_front/stores/back"; const backStore = useBackStore(); @@ -22,6 +23,12 @@ interface Props { const { id, componentIds = undefined, schema } = defineProps(); +interface Emits { + "update:attributeColorMap": [colorMap: string]; +} + +const emit = defineEmits(); + interface AttributeInfo { attribute_name: string; nb_items: number; @@ -30,6 +37,9 @@ interface AttributeInfo { } const attributes = ref([]); +const attributesPerTarget = ref>({}); + +const groupTargetIds = computed(() => menuGroupTargets(id)); const currentAttribute = computed(() => attributes.value.find((attr) => attr.attribute_name === attributeName.value), @@ -103,7 +113,49 @@ function hasSelectedComponent(components: unknown): boolean { return Array.isArray(components) && components.length > 0; } +function getGroupAttributes(targetIds: string[]): void { + for (const targetId of targetIds) { + backStore.request( + { schema, params: { id: targetId } }, + { + response_function: (response: unknown) => { + attributesPerTarget.value[targetId] = ( + response as { attributes: AttributeInfo[] } + ).attributes; + if (targetIds.every((target) => attributesPerTarget.value[target] !== undefined)) { + attributes.value = intersectAttributes( + targetIds.map((target) => attributesPerTarget.value[target] ?? []), + ); + } + }, + }, + ); + } +} + +function initGroupAttribute(targetIds: string[], name: string, item: number): void { + emit("update:attributeColorMap", attributeColorMap.value ?? "batlow"); + for (const targetId of targetIds) { + const attribute = attributesPerTarget.value[targetId]?.find( + (candidate) => candidate.attribute_name === name, + ); + if (attribute) { + const { min, max } = getAttributeRange( + attribute as unknown as Parameters[0], + item, + ); + runWithBatchTargets([targetId], () => { + attributeRange.value = [min, max]; + }); + } + } +} + function getAttributes(): void { + if (groupTargetIds.value) { + getGroupAttributes(groupTargetIds.value); + return; + } const schemaProperties = schema.properties as Record | undefined; const requiresComponent = schemaProperties?.component_ids !== undefined; if (requiresComponent && !hasSelectedComponent(componentIds)) { @@ -136,7 +188,16 @@ watch( }, ); +watch([attributeName, attributeItem], ([name, item]) => { + if (groupTargetIds.value && name !== undefined) { + initGroupAttribute(groupTargetIds.value, name, item ?? 0); + } +}); + watch([attributeName, attributeItem, currentAttribute], () => { + if (groupTargetIds.value) { + return; + } if (attributeColorMap.value === undefined) { attributeColorMap.value = "batlow"; } @@ -200,6 +261,7 @@ watch([attributeName, attributeItem, currentAttribute], () => { v-model:minimum="rangeMin" v-model:maximum="rangeMax" v-model:colorMap="attributeColorMap" + :hide-range="groupTargetIds !== undefined" @reset="resetRange" /> diff --git a/app/components/Viewer/Options/TextureItem.vue b/app/components/Viewer/Options/TextureItem.vue index 2d6c9bf18..a3e3a5fc3 100644 --- a/app/components/Viewer/Options/TextureItem.vue +++ b/app/components/Viewer/Options/TextureItem.vue @@ -1,6 +1,7 @@