@preference-sl/pref-viewer 2.16.2 → 2.17.0-beta.1

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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Applies semantic selection state to Babylon model meshes using non-destructive overlays.
3
+ * Retains the semantic snapshot across model replacements and restores original overlays on cleanup.
4
+ * @class BabylonJSSelectionController
5
+ */
6
+ export default class BabylonJSSelectionController {
7
+ #getModelMeshes;
8
+ #requestRender;
9
+ #selectableColor;
10
+ #selectedColor;
11
+ #overlayAlpha;
12
+ #originalOverlays = new Map();
13
+ #state = {};
14
+
15
+ /**
16
+ * Removes the glTF loader suffix from a mesh id while preserving every other id shape.
17
+ * @public
18
+ * @static
19
+ * @param {unknown} value - Mesh id candidate.
20
+ * @returns {string} Normalized semantic id, or an empty string when unusable.
21
+ */
22
+ static normalizeMeshId(value) {
23
+ return typeof value === "string" ? value.replace(/_primitive\d+$/, "") : "";
24
+ }
25
+
26
+ /**
27
+ * Determines whether a completed pointer gesture represents a primary click.
28
+ * @public
29
+ * @static
30
+ * @param {{x:number,y:number,button:number,pointerId?:number,dragged?:boolean}|null} start - Pointer-down snapshot.
31
+ * @param {{x:number,y:number,button:number,pointerId?:number}|null} end - Pointer-up snapshot.
32
+ * @param {number} maxDistancePx - Maximum Euclidean movement in CSS pixels.
33
+ * @returns {boolean} Whether the gesture is a click.
34
+ */
35
+ static isPrimaryClickGesture(start, end, maxDistancePx) {
36
+ if (!start || !end || start.button !== 0 || end.button !== 0 || start.dragged || start.pointerId !== end.pointerId) {
37
+ return false;
38
+ }
39
+ return Math.hypot(end.x - start.x, end.y - start.y) <= maxDistancePx;
40
+ }
41
+
42
+ /**
43
+ * Checks that a model mesh has geometry and can participate in semantic picking.
44
+ * Explicit ray predicates bypass Babylon's default visibility and pickability checks.
45
+ * @public
46
+ * @static
47
+ * @param {object} mesh - Babylon mesh considered by the picking predicate.
48
+ * @param {Set<object>} modelMeshes - Mesh references belonging to the model container.
49
+ * @returns {boolean} Whether the mesh is visible, enabled, pickable and has vertices.
50
+ */
51
+ static isPickableModelMesh(mesh, modelMeshes) {
52
+ return modelMeshes.has(mesh) && mesh.isVisible !== false && mesh.visibility !== 0
53
+ && mesh.isPickable !== false && mesh.isEnabled?.() !== false
54
+ && (mesh.getTotalVertices?.() ?? 0) > 0;
55
+ }
56
+
57
+ /**
58
+ * Builds the set of model meshes accepted by picking, including Babylon instances owned by them.
59
+ * @public
60
+ * @static
61
+ * @param {Array<object>} meshes - Meshes from the model AssetContainer.
62
+ * @returns {Set<object>} Accepted model mesh references.
63
+ */
64
+ static getModelMeshSet(meshes = []) {
65
+ const result = new Set();
66
+ for (const mesh of meshes ?? []) {
67
+ if (!mesh) {
68
+ continue;
69
+ }
70
+ result.add(mesh);
71
+ for (const instance of mesh.instances ?? []) {
72
+ result.add(instance);
73
+ }
74
+ }
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Converts ordered Babylon ray hits into the public semantic mesh-id list.
80
+ * @public
81
+ * @static
82
+ * @param {Array<object>} picks - Babylon PickingInfo results.
83
+ * @param {Array<object>} modelMeshes - Meshes belonging to the model container.
84
+ * @returns {string[]} Stable, distance-ordered, deduplicated semantic ids.
85
+ */
86
+ static collectPickedMeshIds(picks = [], modelMeshes = []) {
87
+ const acceptedMeshes = BabylonJSSelectionController.getModelMeshSet(modelMeshes);
88
+ const ids = [];
89
+ const seen = new Set();
90
+ [...(picks ?? [])]
91
+ .filter((pick) => pick?.hit === true && acceptedMeshes.has(pick.pickedMesh))
92
+ .sort((left, right) => (left.distance ?? Number.POSITIVE_INFINITY) - (right.distance ?? Number.POSITIVE_INFINITY))
93
+ .forEach((pick) => {
94
+ const id = BabylonJSSelectionController.normalizeMeshId(pick.pickedMesh?.id);
95
+ if (id && !seen.has(id)) {
96
+ seen.add(id);
97
+ ids.push(id);
98
+ }
99
+ });
100
+ return ids;
101
+ }
102
+
103
+ /**
104
+ * Creates a controller with a live model-mesh provider and selection presentation settings.
105
+ * @public
106
+ * @param {function(): Array<object>} getModelMeshes - Returns current model meshes.
107
+ * @param {{selectableColor:object,selectedColor:object,overlayAlpha:number,requestRender?:function():void}} options - Overlay configuration.
108
+ */
109
+ constructor(getModelMeshes, options) {
110
+ this.#getModelMeshes = getModelMeshes;
111
+ this.#selectableColor = options.selectableColor;
112
+ this.#selectedColor = options.selectedColor;
113
+ this.#overlayAlpha = options.overlayAlpha;
114
+ this.#requestRender = options.requestRender ?? (() => {});
115
+ }
116
+
117
+ /**
118
+ * Applies a complete selection snapshot to every matching model mesh.
119
+ * @public
120
+ * @param {{selectableIds?:string[],selectedIds?:string[],selectedItems?:Array<{id:string,highlights?:string[]}>}} state - Canonical selection state.
121
+ * @returns {void}
122
+ */
123
+ applySelectionState(state = {}) {
124
+ this.#state = state;
125
+ this.refresh();
126
+ }
127
+
128
+ /**
129
+ * Reapplies the retained snapshot after model meshes are replaced.
130
+ * Restores removed meshes, gives selected ids priority and requests rendering only on changes.
131
+ * @public
132
+ * @returns {void}
133
+ */
134
+ refresh() {
135
+ const selectableIds = new Set(this.#state.selectableIds ?? []);
136
+ const selectedIds = new Set(this.#state.selectedIds ?? []);
137
+ for (const item of this.#state.selectedItems ?? []) {
138
+ if (item?.id) {
139
+ selectedIds.add(item.id);
140
+ }
141
+ for (const id of item?.highlights ?? []) {
142
+ selectedIds.add(id);
143
+ }
144
+ }
145
+
146
+ const currentMeshes = new Set(BabylonJSSelectionController.getModelMeshSet(this.#getModelMeshes?.() ?? []));
147
+ let changed = false;
148
+ for (const mesh of this.#originalOverlays.keys()) {
149
+ if (!currentMeshes.has(mesh)) {
150
+ changed = this.#restoreMesh(mesh) || changed;
151
+ }
152
+ }
153
+
154
+ for (const mesh of currentMeshes) {
155
+ const id = BabylonJSSelectionController.normalizeMeshId(mesh?.id);
156
+ const color = selectedIds.has(id)
157
+ ? this.#selectedColor
158
+ : (selectableIds.has(id) ? this.#selectableColor : null);
159
+ if (color) {
160
+ if (!this.#originalOverlays.has(mesh)) {
161
+ this.#originalOverlays.set(mesh, {
162
+ renderOverlay: mesh.renderOverlay,
163
+ overlayColor: mesh.overlayColor,
164
+ overlayAlpha: mesh.overlayAlpha,
165
+ });
166
+ }
167
+ if (mesh.renderOverlay !== true || mesh.overlayColor !== color || mesh.overlayAlpha !== this.#overlayAlpha) {
168
+ mesh.renderOverlay = true;
169
+ mesh.overlayColor = color;
170
+ mesh.overlayAlpha = this.#overlayAlpha;
171
+ changed = true;
172
+ }
173
+ } else {
174
+ changed = this.#restoreMesh(mesh) || changed;
175
+ }
176
+ }
177
+
178
+ if (changed) {
179
+ this.#requestRender();
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Restores all meshes changed by this controller and releases their saved overlay properties.
185
+ * @public
186
+ * @returns {void}
187
+ */
188
+ dispose() {
189
+ let changed = false;
190
+ for (const mesh of [...this.#originalOverlays.keys()]) {
191
+ changed = this.#restoreMesh(mesh) || changed;
192
+ }
193
+ if (changed) {
194
+ this.#requestRender();
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Restores a mesh's saved overlay properties and removes its restoration snapshot.
200
+ * @private
201
+ * @param {object} mesh - Mesh previously updated by this controller.
202
+ * @returns {boolean} Whether a saved overlay was restored.
203
+ */
204
+ #restoreMesh(mesh) {
205
+ const original = this.#originalOverlays.get(mesh);
206
+ if (!original) {
207
+ return false;
208
+ }
209
+ mesh.renderOverlay = original.renderOverlay;
210
+ mesh.overlayColor = original.overlayColor;
211
+ mesh.overlayAlpha = original.overlayAlpha;
212
+ this.#originalOverlays.delete(mesh);
213
+ return true;
214
+ }
215
+ }