@geode/opengeodeweb-front 10.36.0-rc.1 → 10.36.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import { DEBOUNCE_DELAY, DEFAULT_NORMALS } from "@ogw_front/utils/clipping_planes";
3
+ import {
4
+ DEFAULT_SLICE_AXIS,
5
+ SLICE_AXES,
6
+ type SliceAxis,
7
+ areAllGrids,
8
+ } from "@ogw_front/utils/slice";
9
+ import type { ClippingPlane } from "@ogw_front/composables/clipping_planes_widget_entries";
3
10
  import ClippingPlaneCard from "@ogw_front/components/ClippingPlaneCard.vue";
11
+ import SliceCard from "@ogw_front/components/SliceCard.vue";
4
12
  import ToolPanel from "@ogw_front/components/ToolPanel.vue";
5
13
  import { useClippingPlanesWidget } from "@ogw_front/composables/clipping_planes_widget";
6
14
  import { useDataStore } from "@ogw_front/stores/data";
@@ -18,10 +26,23 @@ const dataStore = useDataStore();
18
26
  const hybridViewerStore = useHybridViewerStore();
19
27
  const targetAllVisible = ref<boolean>(true);
20
28
  const selectedDatasetIds = ref<string[]>([]);
21
- const planes = ref<{ origin?: number[]; normal: number[] }[]>([
22
- { origin: undefined, normal: [1, 0, 0] },
23
- ]);
29
+ let lastItemId = 0;
30
+ function newItemId(): number {
31
+ lastItemId += 1;
32
+ return lastItemId;
33
+ }
34
+ const planes = ref<ClippingPlane[]>([{ id: newItemId(), origin: undefined, normal: [1, 0, 0] }]);
24
35
  const allItems = dataStore.refAllItems();
36
+ const sliceEnabled = ref<boolean>(false);
37
+ const slices = ref<{ id: number; axis: SliceAxis; index: number }[]>([
38
+ { id: newItemId(), axis: DEFAULT_SLICE_AXIS, index: 0 },
39
+ ]);
40
+ const sliceMaxIndices = ref<[number, number, number]>([0, 0, 0]);
41
+ const targetIds = computed<string[]>(() =>
42
+ targetAllVisible.value ? allItems.value.map((item) => item.id) : selectedDatasetIds.value,
43
+ );
44
+ const isAllGrid = computed<boolean>(() => areAllGrids(allItems.value, targetIds.value));
45
+ const isSliceActive = computed<boolean>(() => isAllGrid.value && sliceEnabled.value);
25
46
  const availableDatasets = computed<{ title: string; value: string }[]>(() =>
26
47
  allItems.value.map((item) => ({
27
48
  title: item.name || item.id,
@@ -30,6 +51,8 @@ const availableDatasets = computed<{ title: string; value: string }[]>(() =>
30
51
  );
31
52
  const widgetContainer = useTemplateRef("widgetContainer");
32
53
  let debouncedApply: ((...args: unknown[]) => void) | undefined = undefined;
54
+ let areSlicesApplied = false;
55
+ let pendingSlices: Promise<void> = Promise.resolve();
33
56
 
34
57
  const {
35
58
  getSceneCenter,
@@ -49,41 +72,103 @@ const {
49
72
  debouncedApply: (...args: unknown[]) => debouncedApply?.(...args),
50
73
  });
51
74
 
75
+ function getUntargetedIds(): string[] {
76
+ return allItems.value.map((item) => item.id).filter((id) => !targetIds.value.includes(id));
77
+ }
78
+
52
79
  async function applyClippingPlanes(): Promise<void> {
53
- const allIds = allItems.value.map((item) => item.id);
54
- if (allIds.length === 0) {
80
+ if (allItems.value.length === 0) {
55
81
  return;
56
82
  }
57
83
  const center = getSceneCenter();
58
- const targetIds = targetAllVisible.value ? allIds : selectedDatasetIds.value;
59
- const untargetedIds = allIds.filter((id) => !targetIds.includes(id));
84
+ const untargetedIds = getUntargetedIds();
60
85
  const planesData = planes.value.map((plane) => ({
61
86
  origin: (plane.origin || center).map(Number),
62
87
  normal: plane.normal.map(Number),
63
88
  }));
64
-
65
- if (targetIds.length > 0) {
66
- await hybridViewerStore.setClippingPlanes(targetIds, planesData);
89
+ if (targetIds.value.length > 0) {
90
+ await hybridViewerStore.setClippingPlanes(
91
+ targetIds.value,
92
+ isSliceActive.value ? [] : planesData,
93
+ );
67
94
  }
68
95
  if (untargetedIds.length > 0) {
69
96
  await hybridViewerStore.setClippingPlanes(untargetedIds, []);
70
97
  }
71
98
  }
72
99
 
100
+ async function sendSlices(): Promise<void> {
101
+ if (!isAllGrid.value && !areSlicesApplied) {
102
+ return;
103
+ }
104
+ const isActive = isSliceActive.value;
105
+ const untargetedIds = getUntargetedIds();
106
+ if (targetIds.value.length > 0) {
107
+ const maxIndices = await hybridViewerStore.setSlice(
108
+ targetIds.value,
109
+ isActive ? slices.value.map(({ axis, index }) => ({ axis, index })) : [],
110
+ );
111
+ if (isAllGrid.value) {
112
+ sliceMaxIndices.value = maxIndices;
113
+ }
114
+ if (isActive) {
115
+ for (const slice of slices.value) {
116
+ slice.index = Math.min(slice.index, maxIndices[slice.axis]);
117
+ }
118
+ }
119
+ }
120
+ if (areSlicesApplied && untargetedIds.length > 0) {
121
+ await hybridViewerStore.setSlice(untargetedIds, []);
122
+ }
123
+ areSlicesApplied = isActive;
124
+ }
125
+
126
+ function queueSliceTask(task: () => Promise<void>): Promise<void> {
127
+ const previous = pendingSlices;
128
+ pendingSlices = (async (): Promise<void> => {
129
+ await Promise.allSettled([previous]);
130
+ await task();
131
+ })();
132
+ return pendingSlices;
133
+ }
134
+
135
+ function applySlices(): Promise<void> {
136
+ return queueSliceTask(sendSlices);
137
+ }
138
+
139
+ async function applyAll(): Promise<void> {
140
+ await applyClippingPlanes();
141
+ await applySlices();
142
+ }
143
+
73
144
  debouncedApply = useDebounceFn(() => applyClippingPlanes(), DEBOUNCE_DELAY);
145
+ const debouncedApplySlices = useDebounceFn(() => applySlices(), DEBOUNCE_DELAY);
74
146
 
75
147
  function addPlane(): void {
76
148
  // Index is always in-bounds (modulo the fixed-size list); the fallbacks only
77
149
  // Satisfy noUncheckedIndexedAccess and are never hit at runtime.
78
150
  const normal = DEFAULT_NORMALS[planes.value.length % DEFAULT_NORMALS.length] ??
79
151
  DEFAULT_NORMALS[0] ?? [1, 0, 0];
80
- planes.value.push({ origin: getSceneCenter(), normal });
152
+ planes.value.push({ id: newItemId(), origin: getSceneCenter(), normal });
81
153
  }
82
154
 
83
155
  function removePlane(index: number): void {
84
156
  planes.value.splice(index, 1);
85
157
  }
86
158
 
159
+ function addSlice(): void {
160
+ const lastSlice = slices.value.at(-1);
161
+ slices.value.push({
162
+ id: newItemId(),
163
+ axis: lastSlice ? SLICE_AXES[lastSlice.axis].next : DEFAULT_SLICE_AXIS,
164
+ index: 0,
165
+ });
166
+ }
167
+
168
+ function removeSlice(index: number): void {
169
+ slices.value.splice(index, 1);
170
+ }
171
+
87
172
  function flipNormal(plane: { normal: number[] }): void {
88
173
  plane.normal = plane.normal.map((component) => -component);
89
174
  syncWidgets();
@@ -92,15 +177,23 @@ function flipNormal(plane: { normal: number[] }): void {
92
177
 
93
178
  async function resetClippingPlanes(): Promise<void> {
94
179
  setFromWidget(true);
95
- planes.value = [{ origin: undefined, normal: [1, 0, 0] }];
180
+ planes.value = [{ id: newItemId(), origin: undefined, normal: [1, 0, 0] }];
181
+ sliceEnabled.value = false;
182
+ slices.value = [{ id: newItemId(), axis: DEFAULT_SLICE_AXIS, index: 0 }];
96
183
  updateWidgetPlacement({ isReset: true });
97
184
  setFromWidget(false);
98
- await applyClippingPlanes();
185
+ await applyAll();
99
186
  }
100
187
 
101
188
  async function removeClippingPlanes(): Promise<void> {
102
189
  const allIds = allItems.value.map((item) => item.id);
103
190
  await hybridViewerStore.setClippingPlanes(allIds, []);
191
+ await queueSliceTask(async () => {
192
+ if (areSlicesApplied) {
193
+ await hybridViewerStore.setSlice(allIds, []);
194
+ areSlicesApplied = false;
195
+ }
196
+ });
104
197
  }
105
198
 
106
199
  watch(widgetContainer, (container) => {
@@ -124,7 +217,7 @@ watch(
124
217
  watch(show, (visible) => {
125
218
  if (visible) {
126
219
  updateWidgetPlacement({ isReset: true });
127
- applyClippingPlanes();
220
+ applyAll();
128
221
  }
129
222
  });
130
223
 
@@ -133,7 +226,23 @@ watch(
133
226
  () => {
134
227
  if (show.value) {
135
228
  updateWidgetPlacement({ isReset: true });
136
- applyClippingPlanes();
229
+ applyAll();
230
+ }
231
+ },
232
+ { deep: true },
233
+ );
234
+
235
+ watch(sliceEnabled, () => {
236
+ if (show.value) {
237
+ applyAll();
238
+ }
239
+ });
240
+
241
+ watch(
242
+ slices,
243
+ () => {
244
+ if (show.value) {
245
+ debouncedApplySlices();
137
246
  }
138
247
  },
139
248
  { deep: true },
@@ -142,7 +251,7 @@ watch(
142
251
  watch(allItems, () => {
143
252
  if (show.value) {
144
253
  updateWidgetPlacement({ isReset: true });
145
- applyClippingPlanes();
254
+ applyAll();
146
255
  }
147
256
  });
148
257
 
@@ -151,7 +260,7 @@ watch(
151
260
  (actorCount) => {
152
261
  if (show.value && actorCount > 0) {
153
262
  updateWidgetPlacement({ isReset: true });
154
- applyClippingPlanes();
263
+ applyAll();
155
264
  }
156
265
  },
157
266
  );
@@ -171,6 +280,7 @@ onBeforeUnmount(cleanupLocalWidget);
171
280
  >
172
281
  <v-card-text class="pa-3 max-panel-height overflow-y-auto">
173
282
  <v-sheet
283
+ v-show="!isSliceActive"
174
284
  ref="widgetContainer"
175
285
  height="180"
176
286
  color="transparent"
@@ -203,28 +313,66 @@ onBeforeUnmount(cleanupLocalWidget);
203
313
 
204
314
  <v-divider class="my-2" />
205
315
 
206
- <v-row align="center" justify="space-between" no-gutters class="mb-2">
207
- <v-col class="text-caption font-weight-bold">Planes ({{ planes.length }})</v-col>
208
- <v-col cols="auto">
209
- <v-btn
210
- data-testid="addPlaneButton"
211
- size="x-small"
212
- variant="tonal"
213
- color="primary"
214
- icon="mdi-plus"
215
- @click="addPlane"
316
+ <template v-if="isAllGrid">
317
+ <v-switch
318
+ v-model="sliceEnabled"
319
+ data-testid="sliceSwitch"
320
+ label="Slice"
321
+ color="primary"
322
+ density="compact"
323
+ hide-details
324
+ class="mb-2 text-caption"
325
+ />
326
+ <template v-if="sliceEnabled">
327
+ <v-row align="center" justify="space-between" no-gutters class="mb-2">
328
+ <v-col class="text-caption font-weight-bold">Slices ({{ slices.length }})</v-col>
329
+ <v-col cols="auto">
330
+ <v-btn
331
+ data-testid="addSliceButton"
332
+ size="x-small"
333
+ variant="tonal"
334
+ color="primary"
335
+ icon="mdi-plus"
336
+ @click="addSlice"
337
+ />
338
+ </v-col>
339
+ </v-row>
340
+ <SliceCard
341
+ v-for="(slice, idx) in slices"
342
+ :key="slice.id"
343
+ :slice="slice"
344
+ :index="idx"
345
+ :max-index="sliceMaxIndices[slice.axis]"
346
+ @remove="removeSlice(idx)"
216
347
  />
217
- </v-col>
218
- </v-row>
219
-
220
- <ClippingPlaneCard
221
- v-for="(plane, idx) in planes"
222
- :key="idx"
223
- :plane="plane"
224
- :index="idx"
225
- @remove="removePlane(idx)"
226
- @flip-normal="flipNormal(plane)"
227
- />
348
+ </template>
349
+ <v-divider class="my-2" />
350
+ </template>
351
+
352
+ <template v-if="!isSliceActive">
353
+ <v-row align="center" justify="space-between" no-gutters class="mb-2">
354
+ <v-col class="text-caption font-weight-bold">Planes ({{ planes.length }})</v-col>
355
+ <v-col cols="auto">
356
+ <v-btn
357
+ data-testid="addPlaneButton"
358
+ size="x-small"
359
+ variant="tonal"
360
+ color="primary"
361
+ icon="mdi-plus"
362
+ @click="addPlane"
363
+ />
364
+ </v-col>
365
+ </v-row>
366
+
367
+ <ClippingPlaneCard
368
+ v-for="(plane, idx) in planes"
369
+ :key="plane.id"
370
+ :plane="plane"
371
+ :index="idx"
372
+ @remove="removePlane(idx)"
373
+ @flip-normal="flipNormal(plane)"
374
+ />
375
+ </template>
228
376
  </v-card-text>
229
377
 
230
378
  <template #actions>
@@ -0,0 +1,79 @@
1
+ <script setup lang="ts">
2
+ import { SLICE_AXES, type SliceAxis } from "@ogw_front/utils/slice";
3
+
4
+ interface Props {
5
+ slice: { axis: SliceAxis; index: number };
6
+ index: number;
7
+ maxIndex: number;
8
+ }
9
+
10
+ const { slice, index, maxIndex } = defineProps<Props>();
11
+
12
+ interface Emits {
13
+ remove: [];
14
+ }
15
+
16
+ const emit = defineEmits<Emits>();
17
+ </script>
18
+
19
+ <template>
20
+ <v-card data-testid="sliceCard" variant="outlined" class="pa-2 mb-3 rounded-lg border-opacity-50">
21
+ <v-row align="center" no-gutters class="mb-1">
22
+ <v-col cols="auto">
23
+ <v-chip size="x-small" variant="flat" color="primary" class="font-weight-bold">
24
+ Slice #{{ index + 1 }}
25
+ </v-chip>
26
+ </v-col>
27
+ <v-col class="px-2">
28
+ <v-slider
29
+ v-model="slice.index"
30
+ data-testid="sliceIndexSlider"
31
+ :min="0"
32
+ :max="maxIndex"
33
+ :step="1"
34
+ color="primary"
35
+ track-color="grey-lighten-2"
36
+ density="compact"
37
+ hide-details
38
+ />
39
+ </v-col>
40
+ <v-col cols="auto">
41
+ <v-btn
42
+ data-testid="removeSliceButton"
43
+ icon="mdi-trash-can-outline"
44
+ size="x-small"
45
+ variant="text"
46
+ color="error"
47
+ @click="emit('remove')"
48
+ />
49
+ </v-col>
50
+ </v-row>
51
+
52
+ <v-row align="center" justify="space-between" no-gutters>
53
+ <v-col cols="auto">
54
+ <v-btn-toggle
55
+ v-model="slice.axis"
56
+ data-testid="sliceAxisToggle"
57
+ mandatory
58
+ density="compact"
59
+ color="primary"
60
+ variant="outlined"
61
+ divided
62
+ >
63
+ <v-btn
64
+ v-for="axis in SLICE_AXES"
65
+ :key="axis.value"
66
+ :value="axis.value"
67
+ size="small"
68
+ class="text-caption"
69
+ >
70
+ {{ axis.title }}
71
+ </v-btn>
72
+ </v-btn-toggle>
73
+ </v-col>
74
+ <v-col cols="auto" class="text-caption text-primary font-weight-bold">
75
+ {{ slice.index }} / {{ maxIndex }}
76
+ </v-col>
77
+ </v-row>
78
+ </v-card>
79
+ </template>
@@ -11,6 +11,7 @@ import type vtkAbstractWidgetFactory from "@kitware/vtk.js/Widgets/Core/Abstract
11
11
  import { newInstance as vtkImplicitPlaneWidget } from "@kitware/vtk.js/Widgets/Widgets3D/ImplicitPlaneWidget";
12
12
 
13
13
  interface ClippingPlane {
14
+ id: number;
14
15
  origin?: number[];
15
16
  normal: number[];
16
17
  }
@@ -0,0 +1,24 @@
1
+ const GRID_TYPES = new Set([
2
+ "RegularGrid2D",
3
+ "LightRegularGrid2D",
4
+ "RegularGrid3D",
5
+ "LightRegularGrid3D",
6
+ ]);
7
+ type SliceAxis = 0 | 1 | 2;
8
+ const SLICE_AXES = [
9
+ { title: "YZ", value: 0, next: 1 },
10
+ { title: "XZ", value: 1, next: 2 },
11
+ { title: "XY", value: 2, next: 0 },
12
+ ] as const satisfies readonly { title: string; value: SliceAxis; next: SliceAxis }[];
13
+ const DEFAULT_SLICE_AXIS: SliceAxis = 2;
14
+
15
+ function areAllGrids(items: { id: string; geode_object_type: string }[], ids: string[]): boolean {
16
+ const targetedItems = items.filter((item) => ids.includes(item.id));
17
+ return (
18
+ targetedItems.length > 0 &&
19
+ targetedItems.every((item) => GRID_TYPES.has(item.geode_object_type))
20
+ );
21
+ }
22
+
23
+ export { SLICE_AXES, DEFAULT_SLICE_AXIS, areAllGrids };
24
+ export type { SliceAxis };
@@ -0,0 +1,6 @@
1
+ // Decodes a base64 string (the back's binary_light_viewable) into raw bytes for vtk.js readers.
2
+ // Uint8Array.fromBase64 would do this natively but is unavailable in Node 22, which runs the tests.
3
+ export function base64ToArrayBuffer(b64: string): ArrayBuffer {
4
+ // Atob returns a "binary string": one char per byte, so each code point is the byte value.
5
+ return Uint8Array.from(atob(b64), (char) => char.codePointAt(0) ?? 0).buffer;
6
+ }
@@ -1,3 +1,4 @@
1
+ import type { SliceAxis } from "@ogw_front/utils/slice";
1
2
  import { useHybridViewerCore } from "./core";
2
3
  import { useViewerStore } from "@ogw_front/stores/viewer";
3
4
  import viewer_schemas from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_schemas.json";
@@ -30,6 +31,25 @@ async function setShrink(ids: string[], shrink_factor: number): Promise<void> {
30
31
  });
31
32
  await remoteRender();
32
33
  }
34
+ async function setSlice(
35
+ ids: string[],
36
+ slices: { axis: SliceAxis; index: number }[],
37
+ ): Promise<[number, number, number]> {
38
+ const viewerStore = useViewerStore();
39
+ const { remoteRender } = useHybridViewerCore();
40
+ const schema = viewer_schemas.opengeodeweb_viewer.viewer.slice;
41
+ const params = {
42
+ ids,
43
+ slices,
44
+ };
45
+ const response = await viewerStore.request({
46
+ schema,
47
+ params,
48
+ });
49
+ await remoteRender();
50
+ // oxlint-disable-next-line no-unsafe-type-assertion -- response shape is defined by the slice schema.
51
+ return (response as { max_indices: [number, number, number] }).max_indices;
52
+ }
33
53
  interface ThresholdAttribute {
34
54
  name: string;
35
55
  location: "point" | "cell";
@@ -54,11 +74,16 @@ async function setThreshold(ids: string[], attribute?: ThresholdAttribute): Prom
54
74
  function useHybridViewerFilters(): {
55
75
  setClippingPlanes: (ids: string[], planes: unknown) => Promise<void>;
56
76
  setShrink: (ids: string[], shrink_factor: number) => Promise<void>;
77
+ setSlice: (
78
+ ids: string[],
79
+ slices: { axis: SliceAxis; index: number }[],
80
+ ) => Promise<[number, number, number]>;
57
81
  setThreshold: (ids: string[], attribute?: ThresholdAttribute) => Promise<void>;
58
82
  } {
59
83
  return {
60
84
  setClippingPlanes,
61
85
  setShrink,
86
+ setSlice,
62
87
  setThreshold,
63
88
  };
64
89
  }
@@ -1,6 +1,7 @@
1
1
  import type { HybridDb, vtkActor as VtkActorInstance } from "./vtk_types";
2
2
  import { requireRenderWindow, useHybridViewerCore } from "./core";
3
3
  import { ACTOR_COLOR } from "./constants";
4
+ import { base64ToArrayBuffer } from "./base64";
4
5
  import { useDataStore } from "@ogw_front/stores/data";
5
6
  import { useViewerStore } from "@ogw_front/stores/viewer";
6
7
  import viewer_schemas from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_schemas.json";
@@ -25,8 +26,11 @@ const useHybridViewerScene = createSharedComposable(() => {
25
26
  return;
26
27
  }
27
28
  const value = await dataStore.item(id);
29
+ if (value.binary_light_viewable === undefined) {
30
+ return;
31
+ }
28
32
  const reader = vtkXMLPolyDataReader();
29
- reader.parseAsArrayBuffer(new TextEncoder().encode(value.binary_light_viewable).buffer);
33
+ reader.parseAsArrayBuffer(base64ToArrayBuffer(value.binary_light_viewable));
30
34
  const actor = vtkActor();
31
35
  const mapper = vtkMapper();
32
36
  // oxlint-disable-next-line no-unsafe-type-assertion -- vtk.js's algorithm interface types getOutputData as `any`; this reader always produces polydata.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geode/opengeodeweb-front",
3
- "version": "10.36.0-rc.1",
3
+ "version": "10.36.0-rc.3",
4
4
  "description": "OpenSource Vue/Nuxt/Pinia/Vuetify framework for web applications",
5
5
  "homepage": "https://github.com/Geode-solutions/OpenGeodeWeb-Front",
6
6
  "bugs": {