@preference-sl/pref-viewer 2.18.0 → 3.0.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.
- package/README.md +267 -31
- package/index.d.ts +75 -6
- package/package.json +8 -2
- package/src/babylonjs-animation-controller.js +18 -10
- package/src/babylonjs-animation-opening-menu.js +80 -14
- package/src/babylonjs-animation-opening.js +3 -3
- package/src/babylonjs-ar-floor-bounds.js +169 -0
- package/src/babylonjs-ar-glass.js +128 -0
- package/src/babylonjs-ar-placement-feedback.js +449 -0
- package/src/babylonjs-ar-placement.js +1038 -0
- package/src/babylonjs-camera-wheel-motion.js +35 -2
- package/src/babylonjs-controller.js +542 -125
- package/src/babylonjs-orbital-pointers-input.js +186 -0
- package/src/{render-math.js → babylonjs-render-math.js} +11 -11
- package/src/babylonjs-selection-controller.js +4 -4
- package/src/babylonjs-view-cube-canvas-controller.js +1287 -0
- package/src/babylonjs-view-cube-canvas-orbit-math.js +220 -0
- package/src/babylonjs-view-cube-canvas-renderer.js +775 -0
- package/src/babylonjs-view-cube-controller.js +74 -1103
- package/src/babylonjs-view-cube-svg-controller.js +1012 -0
- package/src/file-storage.js +22 -4
- package/src/gltf-resolver.js +83 -13
- package/src/localization/translations.js +12 -0
- package/src/panzoom-controller.js +4 -4
- package/src/pref-viewer-2d.js +2 -2
- package/src/pref-viewer-3d.js +55 -9
- package/src/pref-viewer-controls-presentation.js +292 -0
- package/src/pref-viewer-dialog.js +86 -7
- package/src/pref-viewer-menu-3d.js +134 -17
- package/src/pref-viewer-style-controller.js +320 -0
- package/src/pref-viewer.js +133 -109
- package/src/quick-look-action.js +291 -0
- package/src/styles.js +702 -98
- package/src/babylonjs-view-cube-renderer.js +0 -548
- package/src/view-cube-orbit-math.js +0 -199
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Material } from "@babylonjs/core/Materials/material.js";
|
|
2
|
+
import MaterialUtils from "./material-utils.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Owns reversible alpha-glass overrides for one model during native AR.
|
|
6
|
+
* Physical transmission cannot sample the compositor camera image in this viewer's XR path.
|
|
7
|
+
* Snapshots shared materials once, including MultiMaterial slots; sync on entry/frames and restore on exit.
|
|
8
|
+
* Leaves colors, textures, reflection parameters, material references and ordinary alpha/opaque materials intact.
|
|
9
|
+
* The scalar opacity is an approximation, not physical camera refraction or transmission-texture evaluation.
|
|
10
|
+
* Call restore before replacing/releasing the owning model. Shared textures/materials are never disposed here.
|
|
11
|
+
*/
|
|
12
|
+
export default class BabylonJSARGlass {
|
|
13
|
+
#materials = new Map();
|
|
14
|
+
static #MIN_GLASS_OPACITY = 0.28;
|
|
15
|
+
static #TRANSMISSION_REDUCTION = 0.65;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bounds a finite material factor, using the supplied fallback for invalid/missing values.
|
|
19
|
+
* @param {number|undefined} value - Candidate alpha or transmission factor.
|
|
20
|
+
* @param {number} [fallback=1] - Value used when the candidate is not finite.
|
|
21
|
+
* @returns {number} A factor in [0, 1].
|
|
22
|
+
*/
|
|
23
|
+
static #factor(value, fallback = 1) {
|
|
24
|
+
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Collects unique referenced materials and nested multi-material slots, tolerating repeated/cyclic slots.
|
|
29
|
+
* @param {import("@babylonjs/core").Material|null} material - Mesh or sub-material reference.
|
|
30
|
+
* @param {Set<import("@babylonjs/core").Material>} used - Current model's material references.
|
|
31
|
+
* @returns {void}
|
|
32
|
+
*/
|
|
33
|
+
#collect(material, used) {
|
|
34
|
+
if (!material || used.has(material)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
used.add(material);
|
|
38
|
+
for (const child of material.subMaterials ?? []) {
|
|
39
|
+
this.#collect(child, used);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Snapshots physical glass and switches it to alpha blending without depth occlusion.
|
|
45
|
+
* Linked refraction uses alpha as inverse transmission, so it is not reused as geometric opacity.
|
|
46
|
+
* Retains a disposal observer only while the snapshot is owned; disposed materials are forgotten untouched.
|
|
47
|
+
* @param {import("@babylonjs/core").PBRMaterial} material - Current physical glass not already adapted.
|
|
48
|
+
* @returns {void} Does not allocate/dispose materials or textures or modify color/reflection properties.
|
|
49
|
+
*/
|
|
50
|
+
#adapt(material) {
|
|
51
|
+
const sub = material.subSurface;
|
|
52
|
+
const alpha = BabylonJSARGlass.#factor(material.alpha);
|
|
53
|
+
const linked = sub.isRefractionEnabled && sub.linkRefractionWithTransparency;
|
|
54
|
+
const transmission = Math.max(
|
|
55
|
+
sub.isRefractionEnabled ? BabylonJSARGlass.#factor(sub.refractionIntensity) * (linked ? 1 - alpha : 1) : 0,
|
|
56
|
+
sub.isTranslucencyEnabled ? BabylonJSARGlass.#factor(sub.translucencyIntensity) : 0);
|
|
57
|
+
const saved = {
|
|
58
|
+
alpha: material.alpha, transparencyMode: material.transparencyMode,
|
|
59
|
+
disableDepthWrite: material.disableDepthWrite, forceDepthWrite: material.forceDepthWrite,
|
|
60
|
+
needDepthPrePass: material.needDepthPrePass,
|
|
61
|
+
isRefractionEnabled: sub.isRefractionEnabled, isTranslucencyEnabled: sub.isTranslucencyEnabled,
|
|
62
|
+
observer: material.onDisposeObservable.addOnce(() => this.#materials.delete(material)),
|
|
63
|
+
};
|
|
64
|
+
this.#materials.set(material, saved);
|
|
65
|
+
sub.isRefractionEnabled = false;
|
|
66
|
+
sub.isTranslucencyEnabled = false;
|
|
67
|
+
material.alpha = (linked ? 1 : alpha) * Math.max(BabylonJSARGlass.#MIN_GLASS_OPACITY,
|
|
68
|
+
1 - transmission * BabylonJSARGlass.#TRANSMISSION_REDUCTION);
|
|
69
|
+
material.transparencyMode = Material.MATERIAL_ALPHABLEND;
|
|
70
|
+
material.disableDepthWrite = true;
|
|
71
|
+
material.forceDepthWrite = false;
|
|
72
|
+
material.needDepthPrePass = false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Restores one still-live material's exact saved values and releases its disposal subscription.
|
|
77
|
+
* @param {import("@babylonjs/core").Material} material - Material previously owned by this adapter.
|
|
78
|
+
* @returns {void} No-op for already restored/disposed materials.
|
|
79
|
+
*/
|
|
80
|
+
#restoreMaterial(material) {
|
|
81
|
+
const saved = this.#materials.get(material);
|
|
82
|
+
if (!saved) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.#materials.delete(material);
|
|
86
|
+
material.onDisposeObservable.remove(saved.observer);
|
|
87
|
+
material.alpha = saved.alpha;
|
|
88
|
+
material.transparencyMode = saved.transparencyMode;
|
|
89
|
+
material.disableDepthWrite = saved.disableDepthWrite;
|
|
90
|
+
material.forceDepthWrite = saved.forceDepthWrite;
|
|
91
|
+
material.needDepthPrePass = saved.needDepthPrePass;
|
|
92
|
+
material.subSurface.isRefractionEnabled = saved.isRefractionEnabled;
|
|
93
|
+
material.subSurface.isTranslucencyEnabled = saved.isTranslucencyEnabled;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Adapts physical glass actually referenced by current model meshes, including material swaps during AR.
|
|
98
|
+
* Restores materials no longer referenced and does not compound opacity on repeated XR frames.
|
|
99
|
+
* @param {import("@babylonjs/core").AssetContainer|null} asset - Current model, or null to restore everything.
|
|
100
|
+
* @returns {void} Ordinary alpha/opaque materials and unrelated scene materials are not changed.
|
|
101
|
+
*/
|
|
102
|
+
sync(asset) {
|
|
103
|
+
const used = new Set();
|
|
104
|
+
for (const mesh of asset?.meshes ?? []) {
|
|
105
|
+
this.#collect(mesh.material, used);
|
|
106
|
+
}
|
|
107
|
+
for (const material of this.#materials.keys()) {
|
|
108
|
+
if (!used.has(material)) {
|
|
109
|
+
this.#restoreMaterial(material);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const material of used) {
|
|
113
|
+
if (!this.#materials.has(material) && MaterialUtils.isPrefViewerPhysicalGlassMaterial(material)) {
|
|
114
|
+
this.#adapt(material);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Restores all remaining material snapshots and removes their observers on exit, cancellation or teardown.
|
|
121
|
+
* @returns {void} Idempotent; does not dispose any shared resource.
|
|
122
|
+
*/
|
|
123
|
+
restore() {
|
|
124
|
+
for (const material of this.#materials.keys()) {
|
|
125
|
+
this.#restoreMaterial(material);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { Mesh } from "@babylonjs/core/Meshes/mesh.js";
|
|
2
|
+
import { VertexData } from "@babylonjs/core/Meshes/mesh.vertexData.js";
|
|
3
|
+
import { TransformNode } from "@babylonjs/core/Meshes/transformNode.js";
|
|
4
|
+
import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial.js";
|
|
5
|
+
import { Material } from "@babylonjs/core/Materials/material.js";
|
|
6
|
+
import { ImageProcessingConfiguration } from "@babylonjs/core/Materials/imageProcessingConfiguration.js";
|
|
7
|
+
import { Constants } from "@babylonjs/core/Engines/constants.js";
|
|
8
|
+
import { Color3 } from "@babylonjs/core/Maths/math.color.js";
|
|
9
|
+
import { Vector3 } from "@babylonjs/core/Maths/math.vector.js";
|
|
10
|
+
import { PrefViewerStyleVariables } from "./styles.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Owns reusable planar gesture glyphs; each arrow is one joined shaft/head surface with an exterior-only border.
|
|
14
|
+
* Arrow geometry has no overlapping shaft/head or border/fill faces, including faint hidden passes.
|
|
15
|
+
* Frozen detected-floor axes orient glyphs and their movement emphasis independently of subsequent camera/model yaw.
|
|
16
|
+
* Primary-color emphasis follows the owning viewer canvas and marks translation/rotation direction and committed floor taps.
|
|
17
|
+
* Centralized appearance properties control opaque foregrounds, translucent outlines and a faint occluded pass.
|
|
18
|
+
* Isolated material image processing preserves UI sRGB colors without scene exposure/tone mapping or fog.
|
|
19
|
+
* Hidden portions remain readable without clearing scene depth or changing model transparency. Dispose before releasing the owning scene.
|
|
20
|
+
*/
|
|
21
|
+
export default class BabylonJSARPlacementFeedback {
|
|
22
|
+
/** @type {string} Black outline color for all glyphs. */
|
|
23
|
+
#outlineColor = "#000000";
|
|
24
|
+
/** @type {string} Normal reticle/arrow foreground color. */
|
|
25
|
+
#indicatorColor = "#ffffff";
|
|
26
|
+
/** @type {string} Accent follows the primary style, refreshed for each gesture/frame. */
|
|
27
|
+
#highlightColor = PrefViewerStyleVariables.colorPrimary;
|
|
28
|
+
/** @type {number} Normal outline opacity, in the inclusive range 0..1. */
|
|
29
|
+
#outlineOpacity = .4;
|
|
30
|
+
/** @type {number} Normal/primary foreground opacity, in the inclusive range 0..1. */
|
|
31
|
+
#indicatorOpacity = 1;
|
|
32
|
+
/** @type {number} Half the 24 cm triangular arrowhead base width, in metres. */
|
|
33
|
+
#headHalfWidth = .12;
|
|
34
|
+
/** @type {number} Half the 8 cm circle stroke width, in metres. */
|
|
35
|
+
#reticleCircleHalfWidth = .04;
|
|
36
|
+
/** @type {number} Half the 2 cm cross stroke width, in metres. */
|
|
37
|
+
#reticleCrossHalfWidth = .01;
|
|
38
|
+
/** @type {number} Exterior reticle-border offset, in metres. */
|
|
39
|
+
#reticleOutlineOffset = .01;
|
|
40
|
+
/** @type {number} Half the 8 cm arrow shaft width, in metres. */
|
|
41
|
+
#arrowStrokeHalfWidth = .04;
|
|
42
|
+
/** @type {number} Perpendicular exterior arrow-border offset, in metres. */
|
|
43
|
+
#arrowOutlineOffset = .01;
|
|
44
|
+
/** @type {number} Arrowhead tip-to-base length in metres. */
|
|
45
|
+
#arrowHeadLength = .10;
|
|
46
|
+
/** @type {number} Frozen detected-floor yaw; movement feedback uses the same axes. */
|
|
47
|
+
#floorYaw = 0;
|
|
48
|
+
|
|
49
|
+
#scene;
|
|
50
|
+
#root = null;
|
|
51
|
+
#groups = {};
|
|
52
|
+
#parts = [];
|
|
53
|
+
#materials = [];
|
|
54
|
+
#ghostMaterials = [];
|
|
55
|
+
#expires = 0;
|
|
56
|
+
static #OCCLUDED_OPACITY_FACTOR = .22;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Retains the scene without allocating meshes until an eligible interaction.
|
|
60
|
+
* @param {import("@babylonjs/core").Scene} scene - Owning render/depth context.
|
|
61
|
+
*/
|
|
62
|
+
constructor(scene) {
|
|
63
|
+
this.#scene = scene;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Adds a non-pickable normal/occluded pair sharing geometry and scene depth, without owning model resources.
|
|
68
|
+
* Both passes blend after opaque scene geometry and never write depth; explicit order preserves outlines.
|
|
69
|
+
* @param {string} mode - Gesture group.
|
|
70
|
+
* @param {Mesh} mesh - Newly allocated normal-pass mesh transferred to this owner.
|
|
71
|
+
* @param {number} shade - Default color index (0 outline, 1 foreground).
|
|
72
|
+
* @param {?number} [direction=null] - Translation quadrant or signed XZ arc direction.
|
|
73
|
+
* @param {string} [geometry="stroke"] - Semantic geometry kind for diagnostics/tests.
|
|
74
|
+
* @returns {void}
|
|
75
|
+
*/
|
|
76
|
+
#attach(mode, mesh, shade, direction = null, geometry = "stroke") {
|
|
77
|
+
mesh.parent = this.#groups[mode];
|
|
78
|
+
mesh.material = this.#materials[shade];
|
|
79
|
+
const ghost = mesh.clone(`${mesh.name}-occluded`, mesh.parent, true);
|
|
80
|
+
ghost.material = this.#ghostMaterials[shade];
|
|
81
|
+
for (const [pass, part] of [["visible", mesh], ["occluded", ghost]]) {
|
|
82
|
+
part.isPickable = false;
|
|
83
|
+
part.doNotSerialize = true;
|
|
84
|
+
part.alphaIndex = shade;
|
|
85
|
+
part.metadata = { prefViewerARFeedback: true, mode, direction, pass, shade, geometry };
|
|
86
|
+
}
|
|
87
|
+
this.#parts.push({ mode, direction, shade, mesh, ghost });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Creates the closed planar reticle band and an exterior-only contour on both sides of its stroke.
|
|
92
|
+
* @param {Vector3[]} path - Ring centreline with repeated closing endpoint.
|
|
93
|
+
* @returns {void} Owns indexed meshes without overlapping fill/border faces.
|
|
94
|
+
*/
|
|
95
|
+
#ring(path) {
|
|
96
|
+
const points = path.slice(0, -1);
|
|
97
|
+
const vertices = [];
|
|
98
|
+
for (const halfWidth of [this.#reticleCircleHalfWidth, this.#reticleCircleHalfWidth + this.#reticleOutlineOffset]) {
|
|
99
|
+
for (let i = 0; i < points.length; i++) {
|
|
100
|
+
const point = points[i];
|
|
101
|
+
const incoming = point.subtract(points[(i + points.length - 1) % points.length]).normalize();
|
|
102
|
+
const outgoing = points[(i + 1) % points.length].subtract(point).normalize();
|
|
103
|
+
const side = new Vector3(-outgoing.z, 0, outgoing.x);
|
|
104
|
+
const miter = new Vector3(-incoming.z, 0, incoming.x).add(side).normalize();
|
|
105
|
+
const offset = miter.scale(halfWidth / Vector3.Dot(miter, side));
|
|
106
|
+
vertices.push(point.add(offset), point.subtract(offset));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const fill = [];
|
|
110
|
+
const border = [];
|
|
111
|
+
const count = points.length * 2;
|
|
112
|
+
for (let i = 0; i < points.length; i++) {
|
|
113
|
+
const a = i * 2;
|
|
114
|
+
const b = ((i + 1) % points.length) * 2;
|
|
115
|
+
fill.push(a, b, a + 1, a + 1, b, b + 1);
|
|
116
|
+
for (const side of [0, 1]) {
|
|
117
|
+
border.push(a + side, b + side, a + side + count,
|
|
118
|
+
b + side, b + side + count, a + side + count);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const [points, triangles, shade] of [[vertices, border, 0], [vertices.slice(0, count), fill, 1]]) {
|
|
122
|
+
const data = new VertexData();
|
|
123
|
+
data.positions = points.flatMap(point => [point.x, 0, point.z]);
|
|
124
|
+
data.normals = points.flatMap(() => [0, 1, 0]);
|
|
125
|
+
data.indices = triangles;
|
|
126
|
+
const mesh = new Mesh("PrefViewerARFeedback-tap-ring", this.#scene);
|
|
127
|
+
data.applyToMesh(mesh);
|
|
128
|
+
this.#attach("tap", mesh, shade, null, "ring");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Creates a single joined cross with a 2 cm stroke and an exterior-only 1 cm contour.
|
|
134
|
+
* @returns {void} The centre is triangulated once, so confirmation/hidden alpha cannot accumulate there.
|
|
135
|
+
*/
|
|
136
|
+
#cross() {
|
|
137
|
+
const w = this.#reticleCrossHalfWidth;
|
|
138
|
+
const length = .08;
|
|
139
|
+
const points = [[-w,-length],[w,-length],[w,-w],[length,-w],[length,w],[w,w],
|
|
140
|
+
[w,length],[-w,length],[-w,w],[-length,w],[-length,-w],[-w,-w]]
|
|
141
|
+
.map(([x,z]) => new Vector3(x,0,z));
|
|
142
|
+
this.#silhouette("tap", points, points.map((_, i) => i), null, "cross", this.#reticleOutlineOffset);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Offsets every edge of a simple planar arrow silhouette outward by the configured distance.
|
|
147
|
+
* Intersecting adjacent offset lines gives mitered convex/concave corners and a true perpendicular border.
|
|
148
|
+
* @param {Vector3[]} points - Ordered, non-repeated boundary vertices with no degenerate edges.
|
|
149
|
+
* @param {number} offset - Perpendicular exterior distance in metres.
|
|
150
|
+
* @returns {Vector3[]} Corresponding outer vertices; arrow-size constraints keep the offset simple.
|
|
151
|
+
*/
|
|
152
|
+
#outerBoundary(points, offset) {
|
|
153
|
+
const area = points.reduce((sum, point, i) => {
|
|
154
|
+
const next = points[(i + 1) % points.length];
|
|
155
|
+
return sum + point.x * next.z - next.x * point.z;
|
|
156
|
+
}, 0);
|
|
157
|
+
const orientation = area < 0 ? 1 : -1;
|
|
158
|
+
return points.map((point, i) => {
|
|
159
|
+
const incoming = point.subtract(points[(i + points.length - 1) % points.length]).normalize();
|
|
160
|
+
const outgoing = points[(i + 1) % points.length].subtract(point).normalize();
|
|
161
|
+
const first = new Vector3(-incoming.z, 0, incoming.x).scale(orientation);
|
|
162
|
+
const second = new Vector3(-outgoing.z, 0, outgoing.x).scale(orientation);
|
|
163
|
+
const miter = first.add(second).normalize();
|
|
164
|
+
return point.add(miter.scale(offset / Vector3.Dot(miter, second)));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Triangulates a simple arrow silhouette by clipping convex ears, preserving concave shaft/head junctions.
|
|
170
|
+
* @param {Vector3[]} points - Ordered XZ polygon without repeated endpoints or intersecting edges.
|
|
171
|
+
* @returns {number[]} Triangle indices into the supplied boundary, with no overlapping interiors.
|
|
172
|
+
* @throws {Error} If an invalid/self-intersecting boundary cannot be triangulated.
|
|
173
|
+
*/
|
|
174
|
+
#triangulate(points) {
|
|
175
|
+
const remaining = points.map((_, index) => index);
|
|
176
|
+
const triangles = [];
|
|
177
|
+
const area = points.reduce((sum, p, i) => {
|
|
178
|
+
const q = points[(i + 1) % points.length];
|
|
179
|
+
return sum + p.x * q.z - q.x * p.z;
|
|
180
|
+
}, 0);
|
|
181
|
+
const orientation = Math.sign(area);
|
|
182
|
+
/** @param {Vector3} a - First point. @param {Vector3} b - Second point. @param {Vector3} c - Third point. @returns {number} Oriented 2D cross product. */
|
|
183
|
+
const cross = (a, b, c) => orientation * ((b.x - a.x) * (c.z - a.z) - (b.z - a.z) * (c.x - a.x));
|
|
184
|
+
while (remaining.length > 3) {
|
|
185
|
+
let clipped = false;
|
|
186
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
187
|
+
const a = remaining[(i + remaining.length - 1) % remaining.length];
|
|
188
|
+
const b = remaining[i];
|
|
189
|
+
const c = remaining[(i + 1) % remaining.length];
|
|
190
|
+
if (cross(points[a], points[b], points[c]) <= 1e-10) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const occupied = remaining.some(index => index !== a && index !== b && index !== c
|
|
194
|
+
&& cross(points[a], points[b], points[index]) >= -1e-10
|
|
195
|
+
&& cross(points[b], points[c], points[index]) >= -1e-10
|
|
196
|
+
&& cross(points[c], points[a], points[index]) >= -1e-10);
|
|
197
|
+
if (!occupied) {
|
|
198
|
+
triangles.push(a, b, c);
|
|
199
|
+
remaining.splice(i, 1);
|
|
200
|
+
clipped = true;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (!clipped) {
|
|
205
|
+
throw new Error("PrefViewer: invalid AR arrow boundary");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
triangles.push(...remaining);
|
|
209
|
+
return triangles;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Creates one connected, non-overlapping arrow fill and one exterior-only border ring.
|
|
214
|
+
* Retains a 10 cm head length and 24 cm head base. Trim the shaft at the head base and triangulate the complete concave silhouette as a single polygon.
|
|
215
|
+
* The contour is an offset of the complete silhouette, with no internal seam or black backing under the fill.
|
|
216
|
+
* @param {string} mode - Translation or rotation group.
|
|
217
|
+
* @param {Vector3[]} path - Shaft centreline continuing toward the head; only points behind its base are retained.
|
|
218
|
+
* @param {Vector3} tip - Triangle tip.
|
|
219
|
+
* @param {Vector3} tangent - Unit forward direction of the head.
|
|
220
|
+
* @param {number} direction - Translation quadrant or signed rotation sense.
|
|
221
|
+
* @returns {void} Owns planar fill/border meshes and shared-geometry occluded counterparts.
|
|
222
|
+
*/
|
|
223
|
+
#arrow(mode, path, tip, tangent, direction) {
|
|
224
|
+
const base = tip.subtract(tangent.scale(this.#arrowHeadLength));
|
|
225
|
+
// A sufficiently long final segment avoids tiny reflex edges whose border would fold at the neck.
|
|
226
|
+
const centres = path.filter(point => Vector3.Dot(tip.subtract(point), tangent) > this.#arrowHeadLength + 1e-6
|
|
227
|
+
&& Vector3.Distance(point, base) > this.#arrowStrokeHalfWidth * 2);
|
|
228
|
+
centres.push(base);
|
|
229
|
+
const side = new Vector3(-tangent.z, 0, tangent.x);
|
|
230
|
+
const vertices = [];
|
|
231
|
+
for (let i = 0; i < centres.length; i++) {
|
|
232
|
+
const previous = centres[Math.max(0, i - 1)];
|
|
233
|
+
const next = centres[Math.min(centres.length - 1, i + 1)];
|
|
234
|
+
const incoming = (i ? centres[i].subtract(previous) : next.subtract(centres[i])).normalize();
|
|
235
|
+
const outgoing = i === centres.length - 1 ? tangent : next.subtract(centres[i]).normalize();
|
|
236
|
+
const normal = new Vector3(-outgoing.z, 0, outgoing.x);
|
|
237
|
+
const miter = new Vector3(-incoming.z, 0, incoming.x).add(normal).normalize();
|
|
238
|
+
const offset = i === centres.length - 1 ? side.scale(this.#arrowStrokeHalfWidth)
|
|
239
|
+
: miter.scale(this.#arrowStrokeHalfWidth / Vector3.Dot(miter, normal));
|
|
240
|
+
vertices.push(centres[i].add(offset), centres[i].subtract(offset));
|
|
241
|
+
}
|
|
242
|
+
const head = vertices.length;
|
|
243
|
+
vertices.push(base.add(side.scale(this.#headHalfWidth)), tip, base.subtract(side.scale(this.#headHalfWidth)));
|
|
244
|
+
const boundaryIndices = [];
|
|
245
|
+
for (let i = 0; i < head; i += 2) {
|
|
246
|
+
boundaryIndices.push(i);
|
|
247
|
+
}
|
|
248
|
+
boundaryIndices.push(head, head + 1, head + 2);
|
|
249
|
+
for (let i = head - 1; i >= 1; i -= 2) {
|
|
250
|
+
boundaryIndices.push(i);
|
|
251
|
+
}
|
|
252
|
+
this.#silhouette(mode, vertices, boundaryIndices, direction, "arrow", this.#arrowOutlineOffset);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Triangulates one planar silhouette and its non-overlapping exterior contour.
|
|
257
|
+
* @param {string} mode - Gesture group.
|
|
258
|
+
* @param {Vector3[]} vertices - Fill vertices, including all ordered boundary points.
|
|
259
|
+
* @param {number[]} boundaryIndices - Boundary order into fill vertices.
|
|
260
|
+
* @param {?number} direction - Directional highlight identifier, or null for a reticle.
|
|
261
|
+
* @param {string} geometry - Semantic glyph kind.
|
|
262
|
+
* @param {number} offset - Perpendicular border distance in metres.
|
|
263
|
+
* @returns {void} Owns foreground/border mesh pairs with shared occluded geometry.
|
|
264
|
+
*/
|
|
265
|
+
#silhouette(mode, vertices, boundaryIndices, direction, geometry, offset) {
|
|
266
|
+
const inner = boundaryIndices.map(index => vertices[index]);
|
|
267
|
+
const indices = this.#triangulate(inner).map(index => boundaryIndices[index]);
|
|
268
|
+
const outer = this.#outerBoundary(inner, offset);
|
|
269
|
+
const borderVertices = [...inner, ...outer];
|
|
270
|
+
const borderIndices = [];
|
|
271
|
+
for (let i = 0; i < inner.length; i++) {
|
|
272
|
+
const next = (i + 1) % inner.length;
|
|
273
|
+
borderIndices.push(i, next, i + inner.length, next, next + inner.length, i + inner.length);
|
|
274
|
+
}
|
|
275
|
+
for (const [points, triangles, shade] of [[borderVertices, borderIndices, 0], [vertices, indices, 1]]) {
|
|
276
|
+
const data = new VertexData();
|
|
277
|
+
data.positions = points.flatMap(point => [point.x, 0, point.z]);
|
|
278
|
+
data.indices = triangles;
|
|
279
|
+
data.normals = points.flatMap(() => [0, 1, 0]);
|
|
280
|
+
const mesh = new Mesh(`PrefViewerARFeedback-${mode}-arrow`, this.#scene);
|
|
281
|
+
data.applyToMesh(mesh);
|
|
282
|
+
this.#attach(mode, mesh, shade, direction, geometry);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Allocates reusable glyphs and two depth-filtered material sets once.
|
|
288
|
+
* Visible parts use class-configured outline/foreground opacity; hidden parts multiply it by a faintness factor.
|
|
289
|
+
* LEQUAL/GREATER share scene depth. A private disabled image-processing configuration prevents inherited
|
|
290
|
+
* post-process linear output, exposure/ACES tone mapping and fog from altering UI sRGB colors.
|
|
291
|
+
* Model materials and the scene image-processing configuration remain unchanged.
|
|
292
|
+
* @returns {void} Does not change scene clearing, rendering groups or model materials.
|
|
293
|
+
*/
|
|
294
|
+
#ensure() {
|
|
295
|
+
if (this.#root) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
this.#root = new TransformNode("PrefViewerARFeedback", this.#scene);
|
|
299
|
+
this.#root.doNotSerialize = true;
|
|
300
|
+
this.#root.metadata = { prefViewerARFeedback: true };
|
|
301
|
+
const imageProcessing = new ImageProcessingConfiguration();
|
|
302
|
+
imageProcessing.isEnabled = false;
|
|
303
|
+
for (const [ghost, materials] of [[false, this.#materials], [true, this.#ghostMaterials]]) {
|
|
304
|
+
for (const [color, opacity] of [[this.#outlineColor, this.#outlineOpacity],
|
|
305
|
+
[this.#indicatorColor, this.#indicatorOpacity], [this.#highlightColor, this.#indicatorOpacity]]) {
|
|
306
|
+
const material = new StandardMaterial("PrefViewerARFeedback", this.#scene);
|
|
307
|
+
material.disableLighting = true;
|
|
308
|
+
material.fogEnabled = false;
|
|
309
|
+
material.imageProcessingConfiguration = imageProcessing;
|
|
310
|
+
material.emissiveColor = Color3.FromHexString(color);
|
|
311
|
+
material.alpha = opacity * (ghost ? BabylonJSARPlacementFeedback.#OCCLUDED_OPACITY_FACTOR : 1);
|
|
312
|
+
material.transparencyMode = Material.MATERIAL_ALPHABLEND;
|
|
313
|
+
material.disableDepthWrite = true;
|
|
314
|
+
material.backFaceCulling = false;
|
|
315
|
+
material.depthFunction = ghost ? Constants.GREATER : Constants.LEQUAL;
|
|
316
|
+
materials.push(material);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
for (const name of ["tap", "drag", "rotate"]) {
|
|
320
|
+
const group = new TransformNode(`PrefViewerARFeedback-${name}`, this.#scene);
|
|
321
|
+
group.parent = this.#root;
|
|
322
|
+
group.doNotSerialize = true;
|
|
323
|
+
group.metadata = { prefViewerARFeedback: true };
|
|
324
|
+
this.#groups[name] = group;
|
|
325
|
+
}
|
|
326
|
+
const circle = Array.from({ length: 49 }, (_, i) => new Vector3(Math.cos(i * Math.PI / 24) * .2, 0, Math.sin(i * Math.PI / 24) * .2));
|
|
327
|
+
this.#ring(circle);
|
|
328
|
+
this.#cross();
|
|
329
|
+
for (let i = 0; i < 4; i++) {
|
|
330
|
+
const angle = i * Math.PI / 2;
|
|
331
|
+
const tangent = new Vector3(Math.cos(angle), 0, Math.sin(angle));
|
|
332
|
+
this.#arrow("drag", [tangent.scale(.12), tangent.scale(.4)], tangent.scale(.4), tangent, i);
|
|
333
|
+
}
|
|
334
|
+
for (const direction of [-1, 1]) {
|
|
335
|
+
const arc = Array.from({ length: 25 }, (_, i) => {
|
|
336
|
+
const angle = direction * i * Math.PI / 30;
|
|
337
|
+
return new Vector3(Math.cos(angle) * .32, 0, Math.sin(angle) * .32);
|
|
338
|
+
});
|
|
339
|
+
const angle = direction * 24 * Math.PI / 30;
|
|
340
|
+
const tangent = new Vector3(-Math.sin(angle), 0, Math.cos(angle)).scale(direction);
|
|
341
|
+
this.#arrow("rotate", arc, arc.at(-1), tangent, direction);
|
|
342
|
+
}
|
|
343
|
+
this.hide();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Colors foreground and faint counterparts together while retaining dark outlines.
|
|
348
|
+
* @param {object} part - Owned visual pair.
|
|
349
|
+
* @param {boolean} highlighted - Whether this foreground uses the primary color.
|
|
350
|
+
* @returns {void}
|
|
351
|
+
*/
|
|
352
|
+
#shade(part, highlighted) {
|
|
353
|
+
const shade = part.shade && highlighted ? 2 : part.shade;
|
|
354
|
+
part.mesh.material = this.#materials[shade];
|
|
355
|
+
part.ghost.material = this.#ghostMaterials[shade];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Retains a frozen floor-axis yaw for every glyph without moving the model or opening feedback.
|
|
360
|
+
* @param {number} [yaw=0] - Scene-space floor yaw; invalid values use reference-space axes.
|
|
361
|
+
* @returns {void} Updates the existing root, if allocated; reset at session end.
|
|
362
|
+
*/
|
|
363
|
+
setFloorYaw(yaw = 0) {
|
|
364
|
+
this.#floorYaw = Number.isFinite(yaw) ? yaw : 0;
|
|
365
|
+
if (this.#root) {
|
|
366
|
+
this.#root.rotation.y = this.#floorYaw;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Shows a retained-plane destination/gesture; markers are not new physical surface evidence.
|
|
372
|
+
* @param {"tap"|"drag"|"rotate"} mode - Gesture group.
|
|
373
|
+
* @param {Vector3} point - Bottom anchor or floor candidate.
|
|
374
|
+
* @param {?Vector3} [movement=null] - Horizontal drag displacement.
|
|
375
|
+
* @param {number} [rotationDelta=0] - Applied scene-space yaw in radians; sign selects a curved arrow.
|
|
376
|
+
* Movement emphasis is transformed into frozen floor axes before choosing the matching arrow.
|
|
377
|
+
* @returns {void} Reads the owning canvas primary style (global fallback for standalone/headless scenes) and reuses geometry/materials, cancelling confirmation expiry.
|
|
378
|
+
*/
|
|
379
|
+
show(mode, point, movement = null, rotationDelta = 0) {
|
|
380
|
+
const canvas = this.#scene.getEngine().getRenderingCanvas?.();
|
|
381
|
+
const primary = canvas?.ownerDocument?.defaultView?.getComputedStyle(canvas).getPropertyValue("--pref-viewer-color-primary").trim();
|
|
382
|
+
this.#highlightColor = /^#[0-9a-f]{6}$/i.test(primary ?? "") ? primary : PrefViewerStyleVariables.colorPrimary;
|
|
383
|
+
this.#ensure();
|
|
384
|
+
for (const materials of [this.#materials, this.#ghostMaterials]) {
|
|
385
|
+
materials[2].emissiveColor.copyFrom(Color3.FromHexString(this.#highlightColor));
|
|
386
|
+
}
|
|
387
|
+
this.#expires = 0;
|
|
388
|
+
this.#root.rotation.y = this.#floorYaw;
|
|
389
|
+
this.#root.position.copyFrom(point);
|
|
390
|
+
this.#root.position.y += .035;
|
|
391
|
+
this.#root.setEnabled(true);
|
|
392
|
+
for (const [name, group] of Object.entries(this.#groups)) {
|
|
393
|
+
group.setEnabled(name === mode);
|
|
394
|
+
}
|
|
395
|
+
let direction = null;
|
|
396
|
+
if (mode === "drag" && movement && Math.hypot(movement.x, movement.z) > .0001) {
|
|
397
|
+
const x = Math.cos(this.#floorYaw) * movement.x - Math.sin(this.#floorYaw) * movement.z;
|
|
398
|
+
const z = Math.sin(this.#floorYaw) * movement.x + Math.cos(this.#floorYaw) * movement.z;
|
|
399
|
+
direction = (Math.round(Math.atan2(z, x) / (Math.PI / 2)) + 4) % 4;
|
|
400
|
+
} else if (mode === "rotate" && Math.abs(rotationDelta) > .0001) {
|
|
401
|
+
// Positive quaternion yaw turns +X toward -Z, opposite the arc's XZ parameter angle.
|
|
402
|
+
direction = -Math.sign(rotationDelta);
|
|
403
|
+
}
|
|
404
|
+
for (const part of this.#parts) {
|
|
405
|
+
this.#shade(part, mode !== "tap" && part.mode === mode && direction !== null && part.direction === direction);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Highlights a committed tap reticle with the primary style for 500 ms without scheduling timers.
|
|
411
|
+
* @param {Vector3} point - Committed floor destination.
|
|
412
|
+
* @returns {void}
|
|
413
|
+
*/
|
|
414
|
+
confirm(point) {
|
|
415
|
+
this.show("tap", point);
|
|
416
|
+
for (const part of this.#parts) {
|
|
417
|
+
this.#shade(part, part.mode === "tap");
|
|
418
|
+
}
|
|
419
|
+
this.#expires = performance.now() + 500;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** @returns {void} Expires confirmation during the owning XR frame loop. */
|
|
423
|
+
update() {
|
|
424
|
+
if (this.#expires && performance.now() >= this.#expires) {
|
|
425
|
+
this.hide();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** @returns {void} Clears visible and occluded passes on release/cancellation/tracking loss. */
|
|
430
|
+
hide() {
|
|
431
|
+
this.#expires = 0;
|
|
432
|
+
this.#root?.setEnabled(false);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** @returns {void} Releases owned mesh pairs and both material sets, retaining no model resources. */
|
|
436
|
+
dispose() {
|
|
437
|
+
this.#root?.dispose(false);
|
|
438
|
+
for (const material of [...this.#materials, ...this.#ghostMaterials]) {
|
|
439
|
+
material.dispose();
|
|
440
|
+
}
|
|
441
|
+
this.#root = null;
|
|
442
|
+
this.#groups = {};
|
|
443
|
+
this.#parts = [];
|
|
444
|
+
this.#materials = [];
|
|
445
|
+
this.#ghostMaterials = [];
|
|
446
|
+
this.#expires = 0;
|
|
447
|
+
this.#floorYaw = 0;
|
|
448
|
+
}
|
|
449
|
+
}
|