@wonderlandengine/ar-provider-zappar 1.1.1 → 1.2.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.
@@ -123,7 +123,8 @@ export class FaceTracking_Zappar extends TrackingMode {
123
123
  const mirrorPoses = pipeline.cameraFrameUserFacing();
124
124
  const viewNear = this._view?.near;
125
125
  const viewFar = this._view?.far;
126
- const projectionMatrix = Zappar.projectionMatrixFromCameraModel(pipeline.cameraModel(), this.component.engine.canvas.width, this.component.engine.canvas.height, typeof viewNear === 'number' ? viewNear : undefined, typeof viewFar === 'number' ? viewFar : undefined);
126
+ const [cameraDataWidth, cameraDataHeight] = pipeline.cameraDataSize();
127
+ const projectionMatrix = Zappar.projectionMatrixFromCameraModelAndSize(pipeline.cameraModel(), cameraDataWidth, cameraDataHeight, this.component.engine.canvas.width, this.component.engine.canvas.height, typeof viewNear === 'number' ? viewNear : undefined, typeof viewFar === 'number' ? viewFar : undefined);
127
128
  if (this._view) {
128
129
  this._setProjectionMatrixWithEngineRemap(projectionMatrix);
129
130
  }
@@ -305,8 +306,7 @@ export class FaceTracking_Zappar extends TrackingMode {
305
306
  }
306
307
  return;
307
308
  }
308
- if (typeof engineAny.wasm?._wl_view_component_remapProjectionMatrix ===
309
- 'function') {
309
+ if (typeof engineAny.wasm?._wl_view_component_remapProjectionMatrix === 'function') {
310
310
  const ndcDepthIsZeroToOne = false;
311
311
  engineAny.wasm._wl_view_component_remapProjectionMatrix(view._id, engineAny.isReverseZEnabled, ndcDepthIsZeroToOne);
312
312
  if (debugWindow.__WLE_ZAPPAR_DEBUG__) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Hit-test reticle backed by Zappar plane anchors from `WorldTracker`
3
+ * (requires `@zappar/zappar >= 4.x`).
4
+ *
5
+ * Each frame the component casts a ray from the centre of `camera` into the
6
+ * set of planes detected by {@link ZapparProvider}'s `WorldTracker` and moves
7
+ * the owning object to the closest intersection point. A {@link MeshComponent}
8
+ * on the same object is shown while a valid hit is found and hidden otherwise.
9
+ *
10
+ * This is the Zappar equivalent of:
11
+ * - `hit-test-location-root` (WebXR Device API hit-test)
12
+ * - `hit-test-location-xr8` (8th Wall SLAM + XY-plane intersection)
13
+ *
14
+ * **Setup**
15
+ * 1. Attach this component to the reticle object (which should have a
16
+ * {@link MeshComponent} for visual feedback).
17
+ * 2. Set the `camera` property to the scene object that carries
18
+ * {@link ARSLAMCamera}.
19
+ * 3. The `SpawnMeshOnReticle` component works alongside this component –
20
+ * tapping the screen (or selecting on WebXR) will spawn content at this
21
+ * object's position.
22
+ *
23
+ * **Coordinate spaces**
24
+ * Zappar plane poses are expressed in Zappar world space. The WLE camera
25
+ * object's transform is driven directly from the Zappar camera pose matrix
26
+ * (via `WorldTracking_Zappar`), so `camera.getPositionWorld()` and
27
+ * `camera.getForwardWorld()` are already in the same coordinate space as the
28
+ * plane poses. No additional transform is required.
29
+ */
30
+ import { Component, Object as WLEObject } from '@wonderlandengine/api';
31
+ export declare class HitTestLocationZappar extends Component {
32
+ static TypeName: string;
33
+ /** The scene object that carries the {@link ARSLAMCamera} component. */
34
+ camera: WLEObject;
35
+ private _provider;
36
+ private _mesh;
37
+ private readonly _camPos;
38
+ private readonly _camFwd;
39
+ private readonly _planeNormal;
40
+ private readonly _planeOrigin;
41
+ private readonly _toOrigin;
42
+ private readonly _hitPoint;
43
+ start(): void;
44
+ update(): void;
45
+ private readonly _onSessionStart;
46
+ private readonly _onSessionEnd;
47
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Hit-test reticle backed by Zappar plane anchors from `WorldTracker`
3
+ * (requires `@zappar/zappar >= 4.x`).
4
+ *
5
+ * Each frame the component casts a ray from the centre of `camera` into the
6
+ * set of planes detected by {@link ZapparProvider}'s `WorldTracker` and moves
7
+ * the owning object to the closest intersection point. A {@link MeshComponent}
8
+ * on the same object is shown while a valid hit is found and hidden otherwise.
9
+ *
10
+ * This is the Zappar equivalent of:
11
+ * - `hit-test-location-root` (WebXR Device API hit-test)
12
+ * - `hit-test-location-xr8` (8th Wall SLAM + XY-plane intersection)
13
+ *
14
+ * **Setup**
15
+ * 1. Attach this component to the reticle object (which should have a
16
+ * {@link MeshComponent} for visual feedback).
17
+ * 2. Set the `camera` property to the scene object that carries
18
+ * {@link ARSLAMCamera}.
19
+ * 3. The `SpawnMeshOnReticle` component works alongside this component –
20
+ * tapping the screen (or selecting on WebXR) will spawn content at this
21
+ * object's position.
22
+ *
23
+ * **Coordinate spaces**
24
+ * Zappar plane poses are expressed in Zappar world space. The WLE camera
25
+ * object's transform is driven directly from the Zappar camera pose matrix
26
+ * (via `WorldTracking_Zappar`), so `camera.getPositionWorld()` and
27
+ * `camera.getForwardWorld()` are already in the same coordinate space as the
28
+ * plane poses. No additional transform is required.
29
+ */
30
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
31
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
32
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
33
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
34
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
35
+ };
36
+ import { Component, MeshComponent } from '@wonderlandengine/api';
37
+ import { property } from '@wonderlandengine/api/decorators.js';
38
+ import { vec3 } from 'gl-matrix';
39
+ import { ARSession } from '@wonderlandengine/ar-tracking';
40
+ import { ZapparProvider } from './zappar-provider.js';
41
+ export class HitTestLocationZappar extends Component {
42
+ static TypeName = 'hit-test-location-zappar';
43
+ /** The scene object that carries the {@link ARSLAMCamera} component. */
44
+ camera;
45
+ _provider = null;
46
+ _mesh = null;
47
+ // Pre-allocated scratch buffers to avoid GC pressure in the update loop.
48
+ _camPos = new Float32Array(3);
49
+ _camFwd = new Float32Array(3);
50
+ _planeNormal = vec3.create();
51
+ _planeOrigin = vec3.create();
52
+ _toOrigin = vec3.create();
53
+ _hitPoint = vec3.create();
54
+ start() {
55
+ const arSession = ARSession.getSessionForEngine(this.engine);
56
+ arSession.onSessionStart.add(this._onSessionStart);
57
+ arSession.onSessionEnd.add(this._onSessionEnd);
58
+ this._mesh = this.object.getComponent(MeshComponent) ?? null;
59
+ if (this._mesh)
60
+ this._mesh.active = false;
61
+ // Deactivate update loop until a valid Zappar session starts.
62
+ this.active = false;
63
+ }
64
+ update() {
65
+ const provider = this._provider;
66
+ if (!provider)
67
+ return;
68
+ if (!this.camera) {
69
+ console.warn(`[${this.type}] 'camera' property is not set.`);
70
+ return;
71
+ }
72
+ const worldTracker = provider.worldTracker;
73
+ if (!worldTracker || worldTracker.planes.size === 0) {
74
+ if (this._mesh)
75
+ this._mesh.active = false;
76
+ return;
77
+ }
78
+ const cameraPose = provider.slamCameraPoseMatrix;
79
+ if (!cameraPose) {
80
+ if (this._mesh)
81
+ this._mesh.active = false;
82
+ return;
83
+ }
84
+ // -------------------------------------------------------------------
85
+ // Ray definition in WLE / Zappar world space.
86
+ // The camera's world transform has already been set from the Zappar
87
+ // camera pose so these values are directly comparable to plane poses.
88
+ // -------------------------------------------------------------------
89
+ this.camera.getPositionWorld(this._camPos);
90
+ this.camera.getForwardWorld(this._camFwd);
91
+ let closestT = Infinity;
92
+ let hitFound = false;
93
+ for (const plane of worldTracker.planes.values()) {
94
+ // plane.pose() returns a column-major 4×4 matrix in Zappar world space.
95
+ const planeMat = plane.pose(cameraPose);
96
+ // Column 1 = local Y-axis = surface normal for horizontal planes.
97
+ this._planeNormal[0] = planeMat[4];
98
+ this._planeNormal[1] = planeMat[5];
99
+ this._planeNormal[2] = planeMat[6];
100
+ // Column 3 = translation = a point on the plane.
101
+ this._planeOrigin[0] = planeMat[12];
102
+ this._planeOrigin[1] = planeMat[13];
103
+ this._planeOrigin[2] = planeMat[14];
104
+ // Ray–plane intersection: t = dot(planeOrigin - camPos, N) / dot(D, N)
105
+ const denom = vec3.dot(this._camFwd, this._planeNormal);
106
+ // Skip planes that are nearly parallel to the ray.
107
+ if (Math.abs(denom) < 1e-6)
108
+ continue;
109
+ vec3.sub(this._toOrigin, this._planeOrigin, this._camPos);
110
+ const t = vec3.dot(this._toOrigin, this._planeNormal) / denom;
111
+ // Only accept intersections in front of the camera and closer than
112
+ // the best so far.
113
+ if (t > 1e-3 && t < closestT) {
114
+ closestT = t;
115
+ vec3.scaleAndAdd(this._hitPoint, this._camPos, this._camFwd, t);
116
+ hitFound = true;
117
+ }
118
+ }
119
+ if (hitFound) {
120
+ this.object.setPositionWorld(this._hitPoint);
121
+ if (this._mesh)
122
+ this._mesh.active = true;
123
+ }
124
+ else {
125
+ if (this._mesh)
126
+ this._mesh.active = false;
127
+ }
128
+ }
129
+ _onSessionStart = (provider) => {
130
+ if (!(provider instanceof ZapparProvider))
131
+ return;
132
+ this._provider = provider;
133
+ const wt = provider.enableWorldTracker();
134
+ if (!wt) {
135
+ // @zappar/zappar < 4.x or WorldTracker unavailable – degrade
136
+ // gracefully rather than throwing.
137
+ console.warn(`[${this.type}] Zappar WorldTracker (plane anchors) is not` +
138
+ ' available in the installed SDK version. Upgrade' +
139
+ ' @zappar/zappar to >= 4.x to enable plane-based hit tests.');
140
+ return;
141
+ }
142
+ this.active = true;
143
+ };
144
+ _onSessionEnd = (provider) => {
145
+ if (provider !== this._provider)
146
+ return;
147
+ this._provider = null;
148
+ this.active = false;
149
+ if (this._mesh)
150
+ this._mesh.active = false;
151
+ };
152
+ }
153
+ __decorate([
154
+ property.object()
155
+ ], HitTestLocationZappar.prototype, "camera", void 0);
@@ -20,6 +20,10 @@ export declare class ImageTracking_Zappar extends TrackingMode implements ImageT
20
20
  readonly onImageFound: Emitter<[event: ImageTrackedEvent]>;
21
21
  readonly onImageUpdate: Emitter<[event: ImageTrackedEvent]>;
22
22
  readonly onImageLost: Emitter<[event: ImageTrackedEvent]>;
23
+ registerTarget(source: string | ArrayBuffer, options: {
24
+ name: string;
25
+ physicalWidthInMeters?: number;
26
+ }): Promise<void>;
23
27
  init(): void;
24
28
  startSession(): void;
25
29
  endSession(): void;
@@ -21,6 +21,9 @@ export class ImageTracking_Zappar extends TrackingMode {
21
21
  onImageFound = new Emitter();
22
22
  onImageUpdate = new Emitter();
23
23
  onImageLost = new Emitter();
24
+ async registerTarget(source, options) {
25
+ await this.provider.registerImageTarget(source, options);
26
+ }
24
27
  init() {
25
28
  this._view = this.component.object.getComponent('view') ?? undefined;
26
29
  const provider = this.provider;
@@ -50,7 +53,8 @@ export class ImageTracking_Zappar extends TrackingMode {
50
53
  const pipeline = provider.getPipeline();
51
54
  const viewNear = this._view?.near;
52
55
  const viewFar = this._view?.far;
53
- const projectionMatrix = Zappar.projectionMatrixFromCameraModel(pipeline.cameraModel(), this.component.engine.canvas.width, this.component.engine.canvas.height, typeof viewNear === 'number' ? viewNear : undefined, typeof viewFar === 'number' ? viewFar : undefined);
56
+ const [cameraDataWidth, cameraDataHeight] = pipeline.cameraDataSize();
57
+ const projectionMatrix = Zappar.projectionMatrixFromCameraModelAndSize(pipeline.cameraModel(), cameraDataWidth, cameraDataHeight, this.component.engine.canvas.width, this.component.engine.canvas.height, typeof viewNear === 'number' ? viewNear : undefined, typeof viewFar === 'number' ? viewFar : undefined);
54
58
  if (this._view) {
55
59
  this._setProjectionMatrixWithEngineRemap(projectionMatrix);
56
60
  }
@@ -1,6 +1,6 @@
1
1
  /// <reference path="../../src/types/global.d.ts" />
2
2
  import { Component } from '@wonderlandengine/api';
3
- import { TrackingMode } from '@wonderlandengine/ar-tracking';
3
+ import { HitTestResult, TrackingMode } from '@wonderlandengine/ar-tracking';
4
4
  import { ZapparProvider } from './zappar-provider.js';
5
5
  /**
6
6
  * SLAM tracking implementation backed by Zappar.
@@ -14,4 +14,10 @@ export declare class WorldTracking_Zappar extends TrackingMode {
14
14
  getCameraTransformWorld(): ArrayLike<number> | null;
15
15
  getCameraProjectionMatrix(out: Float32Array): boolean;
16
16
  update(): void;
17
+ setupHitTest(): Promise<void>;
18
+ /**
19
+ * Cast a ray from the camera through the screen centre and return the closest
20
+ * intersection with any of the Zappar-detected plane anchors.
21
+ */
22
+ getHitTestResult(): HitTestResult | null;
17
23
  }
@@ -45,4 +45,54 @@ export class WorldTracking_Zappar extends TrackingMode {
45
45
  // Wonderland stores transforms as dual quaternions (8 floats): [rotation quat, translation dual part].
46
46
  quat2.fromMat4(this._cameraTransform, pose);
47
47
  }
48
+ async setupHitTest() {
49
+ const provider = this.provider;
50
+ const tracker = provider.enableWorldTracker();
51
+ if (!tracker) {
52
+ console.warn('[WorldTracking_Zappar] World tracker (plane detection) is not available. ' +
53
+ 'Ensure @zappar/zappar >= 4.x is installed.');
54
+ }
55
+ }
56
+ /**
57
+ * Cast a ray from the camera through the screen centre and return the closest
58
+ * intersection with any of the Zappar-detected plane anchors.
59
+ */
60
+ getHitTestResult() {
61
+ const provider = this.provider;
62
+ const worldTracker = provider.worldTracker;
63
+ if (!worldTracker)
64
+ return null;
65
+ const cameraPose = provider.slamCameraPoseMatrix;
66
+ if (!cameraPose)
67
+ return null;
68
+ // Camera position and forward vector in world space.
69
+ const cam = this.component.object;
70
+ const pos = cam.getPositionWorld(new Array(3));
71
+ const fwd = cam.getForwardWorld(new Array(3));
72
+ let closestT = Infinity;
73
+ let hit = null;
74
+ for (const plane of worldTracker.planes.values()) {
75
+ // pose() returns a column-major 4×4 matrix.
76
+ // Column 1 (elements [4,5,6]) = plane normal; column 3 (elements [12,13,14]) = plane origin.
77
+ const m = plane.pose(cameraPose);
78
+ const nx = m[4], ny = m[5], nz = m[6];
79
+ const ox = m[12], oy = m[13], oz = m[14];
80
+ const denom = fwd[0] * nx + fwd[1] * ny + fwd[2] * nz;
81
+ // Skip planes nearly parallel to the ray.
82
+ if (Math.abs(denom) < 1e-6)
83
+ continue;
84
+ const t = ((ox - pos[0]) * nx + (oy - pos[1]) * ny + (oz - pos[2]) * nz) / denom;
85
+ if (t <= 0 || t >= closestT)
86
+ continue;
87
+ closestT = t;
88
+ hit = {
89
+ position: {
90
+ x: pos[0] + fwd[0] * t,
91
+ y: pos[1] + fwd[1] * t,
92
+ z: pos[2] + fwd[2] * t,
93
+ },
94
+ };
95
+ }
96
+ return hit;
97
+ }
48
98
  }
@@ -6,6 +6,33 @@ import { ARProvider, ARSession, ITrackingMode, ImageScanningEvent, TrackingType
6
6
  import { loadZappar } from './zappar-module.js';
7
7
  import type { FaceMesh, FaceTracker, ImageTracker, Pipeline } from '@zappar/zappar';
8
8
  type ZapparImageTargetType = 'flat' | 'cylindrical' | 'conical';
9
+ /** A single detected horizontal plane produced by {@link ZapparWorldTracker}. */
10
+ export interface ZapparPlaneAnchor {
11
+ /** Pose matrix of this plane in world space. */
12
+ pose(cameraPose: Float32Array): Float32Array;
13
+ /** Ordered 2-D boundary vertices on the plane surface. */
14
+ readonly polygon: ReadonlyArray<readonly [number, number]>;
15
+ /** Increments each time `polygon` is updated. */
16
+ readonly polygonVersion: number;
17
+ }
18
+ /**
19
+ * Minimal interface for the Zappar `WorldTracker` class available in
20
+ * `@zappar/zappar >= 4.x`. Only the API surface used by this package is
21
+ * declared here.
22
+ */
23
+ export interface ZapparWorldTracker {
24
+ enabled: boolean;
25
+ horizontalPlaneDetectionEnabled: boolean;
26
+ verticalPlaneDetectionEnabled: boolean;
27
+ /** Map of all currently detected plane anchors keyed by their ID. */
28
+ readonly planes: ReadonlyMap<string, ZapparPlaneAnchor>;
29
+ readonly worldAnchor: {
30
+ pose(cameraPose: Float32Array): Float32Array;
31
+ };
32
+ readonly groundAnchor: {
33
+ pose(cameraPose: Float32Array): Float32Array;
34
+ };
35
+ }
9
36
  export interface ZapparImageTargetOptions {
10
37
  name: string;
11
38
  type?: ZapparImageTargetType;
@@ -41,9 +68,19 @@ export declare class ZapparProvider extends ARProvider {
41
68
  private _cameraSourceUserFacing;
42
69
  private _imageTargetDescriptors;
43
70
  private readonly _imageTargetsChanged;
71
+ private _worldTracker;
72
+ /** Set to `true` when a component requests world-tracker before the session starts. */
73
+ private _worldTrackerRequested;
44
74
  private cameraStarted;
45
75
  private hasInitializedAnchor;
46
76
  private _anchorWarmupFramesRemaining;
77
+ /**
78
+ * When `true`, {@link updateTracking} keeps calling
79
+ * `setAnchorPoseFromCameraOffset` every frame so the world anchor
80
+ * continuously follows the camera centre. Set via
81
+ * {@link startUserPlacement}; cleared by {@link placeInstantAnchor}.
82
+ */
83
+ private _userPlacementMode;
47
84
  private preRenderRegistered;
48
85
  private _slamStateValid;
49
86
  private readonly _slamProjectionMatrix;
@@ -104,6 +141,29 @@ export declare class ZapparProvider extends ARProvider {
104
141
  getImageScanningEvent(): ImageScanningEvent;
105
142
  getImageTargetDescriptors(): ReadonlyArray<ZapparImageTargetDescriptor>;
106
143
  getImageTargetDescriptor(index: number): ZapparImageTargetDescriptor | undefined;
144
+ /**
145
+ * Access the active {@link ZapparWorldTracker} instance, or `null` when
146
+ * plane tracking has not been requested or is unavailable.
147
+ *
148
+ * The instance is only non-null once {@link enableWorldTracker} has been
149
+ * called **and** an AR session is running.
150
+ */
151
+ get worldTracker(): ZapparWorldTracker | null;
152
+ /**
153
+ * Opt in to Zappar plane tracking via the `WorldTracker` API introduced in
154
+ * `@zappar/zappar >= 4.x`.
155
+ *
156
+ * Call this once (e.g. from a component's `start()` hook) before or after
157
+ * a session starts. If the installed SDK does not expose `WorldTracker`
158
+ * (i.e. < 4.x) a warning is logged and the method returns `null`.
159
+ *
160
+ * The tracker is disabled again when the session ends and re-enabled on
161
+ * the next `startSession()` call.
162
+ *
163
+ * @returns The {@link ZapparWorldTracker} instance, or `null` on failure.
164
+ */
165
+ enableWorldTracker(): ZapparWorldTracker | null;
166
+ private _createWorldTracker;
107
167
  private ensureCameraRunning;
108
168
  private onVisibilityChange;
109
169
  private onPreRender;
@@ -114,6 +174,26 @@ export declare class ZapparProvider extends ARProvider {
114
174
  /** Latest anchor pose matrix (valid only if {@link hasSlamTrackingState} is true). */
115
175
  get slamAnchorPoseMatrix(): Readonly<mat4> | null;
116
176
  get hasSlamTrackingState(): boolean;
177
+ /**
178
+ * `true` while the instant world anchor is being continuously repositioned
179
+ * in front of the camera (either during warmup or during user-placement
180
+ * mode). `false` once the anchor has been locked.
181
+ */
182
+ get isPlacingInstantAnchor(): boolean;
183
+ /**
184
+ * Enter user-placement mode: the instant world anchor tracks 5 m in front
185
+ * of the camera every frame until {@link placeInstantAnchor} is called.
186
+ *
187
+ * Typically called by a placement-UI component immediately after session
188
+ * start so the user can choose where to lock the world origin.
189
+ */
190
+ startUserPlacement(): void;
191
+ /**
192
+ * Lock the instant world anchor at its current position and exit placement
193
+ * mode. After this call {@link isPlacingInstantAnchor} returns `false` and
194
+ * the anchor no longer follows the camera.
195
+ */
196
+ placeInstantAnchor(): void;
117
197
  get slamFrameNumber(): number;
118
198
  private bindVideoTextureForSkyMaterial;
119
199
  updateTracking(): void;
@@ -28,9 +28,19 @@ export class ZapparProvider extends ARProvider {
28
28
  _cameraSourceUserFacing = null;
29
29
  _imageTargetDescriptors = [];
30
30
  _imageTargetsChanged = new Emitter();
31
+ _worldTracker = null;
32
+ /** Set to `true` when a component requests world-tracker before the session starts. */
33
+ _worldTrackerRequested = false;
31
34
  cameraStarted = false;
32
35
  hasInitializedAnchor = false;
33
36
  _anchorWarmupFramesRemaining = 0;
37
+ /**
38
+ * When `true`, {@link updateTracking} keeps calling
39
+ * `setAnchorPoseFromCameraOffset` every frame so the world anchor
40
+ * continuously follows the camera centre. Set via
41
+ * {@link startUserPlacement}; cleared by {@link placeInstantAnchor}.
42
+ */
43
+ _userPlacementMode = false;
34
44
  preRenderRegistered = false;
35
45
  _slamStateValid = false;
36
46
  _slamProjectionMatrix = new Float32Array(16);
@@ -127,6 +137,15 @@ export class ZapparProvider extends ARProvider {
127
137
  if (this.instantTracker) {
128
138
  this.instantTracker.enabled = true;
129
139
  }
140
+ // Create the WorldTracker if it was requested before the session started.
141
+ if (this._worldTrackerRequested && !this._worldTracker) {
142
+ this._createWorldTracker();
143
+ this._worldTrackerRequested = false;
144
+ }
145
+ else if (this._worldTracker) {
146
+ // Re-enable a pre-existing WorldTracker from a previous session.
147
+ this._worldTracker.enabled = true;
148
+ }
130
149
  }
131
150
  ensurePreRenderRegistered() {
132
151
  if (!this.preRenderRegistered) {
@@ -190,8 +209,8 @@ export class ZapparProvider extends ARProvider {
190
209
  if (ZapparProvider._cvWorkerConfigured)
191
210
  return;
192
211
  // Hard-coded paths for local development
193
- const workerUrl = './zappar-cv/zappar-cv.worker.js';
194
- const wasmUrl = './zappar-cv/zappar-cv.wasm';
212
+ const workerUrl = './zappar-cv.worker.js';
213
+ const wasmUrl = './zappar-cv.wasm';
195
214
  const debugWorker = this.isVerboseDebugLoggingEnabled;
196
215
  if (debugWorker) {
197
216
  console.log('[ZapparProvider] configureCvWorkerIfNeeded()', {
@@ -206,7 +225,7 @@ export class ZapparProvider extends ARProvider {
206
225
  if (debugWorker) {
207
226
  console.log('[ZapparProvider] Creating CV worker:', workerUrl);
208
227
  }
209
- const worker = new Worker(workerUrl);
228
+ const worker = new Worker(workerUrl, { type: 'module' });
210
229
  // Keep a reference for debugging / inspection.
211
230
  ZapparProvider._cvWorker = worker;
212
231
  if (typeof window !== 'undefined') {
@@ -265,12 +284,18 @@ export class ZapparProvider extends ARProvider {
265
284
  }, 5000);
266
285
  }
267
286
  if (wasmUrl) {
287
+ const resolvedWasmUrl = new URL(wasmUrl, window.location.href).toString();
268
288
  if (debugWorker) {
269
- console.log('[ZapparProvider] Sending WASM URL to worker:', wasmUrl);
289
+ console.log('[ZapparProvider] Compiling WASM for worker:', resolvedWasmUrl);
270
290
  }
291
+ // The worker's instantiateWasm callback expects a pre-compiled
292
+ // WebAssembly.Module (not just a URL). Compile it on the main
293
+ // thread so it can be structured-cloned into the worker.
294
+ const compiledModule = await WebAssembly.compileStreaming(fetch(resolvedWasmUrl));
271
295
  worker.postMessage({
272
296
  t: 'wasm',
273
- url: new URL(wasmUrl, window.location.href).toString(),
297
+ url: resolvedWasmUrl,
298
+ module: compiledModule,
274
299
  });
275
300
  }
276
301
  await zapparSetOptions({ worker });
@@ -399,6 +424,12 @@ export class ZapparProvider extends ARProvider {
399
424
  if (!options.name) {
400
425
  throw new Error('Image target registration requires a name.');
401
426
  }
427
+ // Deduplicate: if a descriptor with the same name is already registered,
428
+ // skip loading to avoid double-loading the same .zpt file when multiple
429
+ // scene components share the same imageId target.
430
+ if (this._imageTargetDescriptors.some((d) => d.name === options.name)) {
431
+ return;
432
+ }
402
433
  // Allow registering targets before an AR session starts.
403
434
  // This is useful so image targets are known for ImageScanningEvent
404
435
  // and so apps can prefetch/prepare targets without requesting camera access.
@@ -478,6 +509,60 @@ export class ZapparProvider extends ARProvider {
478
509
  getImageTargetDescriptor(index) {
479
510
  return this._imageTargetDescriptors[index];
480
511
  }
512
+ // -----------------------------------------------------------------------
513
+ // World tracking / plane anchors (Zappar SDK >= 4.x)
514
+ // -----------------------------------------------------------------------
515
+ /**
516
+ * Access the active {@link ZapparWorldTracker} instance, or `null` when
517
+ * plane tracking has not been requested or is unavailable.
518
+ *
519
+ * The instance is only non-null once {@link enableWorldTracker} has been
520
+ * called **and** an AR session is running.
521
+ */
522
+ get worldTracker() {
523
+ return this._worldTracker;
524
+ }
525
+ /**
526
+ * Opt in to Zappar plane tracking via the `WorldTracker` API introduced in
527
+ * `@zappar/zappar >= 4.x`.
528
+ *
529
+ * Call this once (e.g. from a component's `start()` hook) before or after
530
+ * a session starts. If the installed SDK does not expose `WorldTracker`
531
+ * (i.e. < 4.x) a warning is logged and the method returns `null`.
532
+ *
533
+ * The tracker is disabled again when the session ends and re-enabled on
534
+ * the next `startSession()` call.
535
+ *
536
+ * @returns The {@link ZapparWorldTracker} instance, or `null` on failure.
537
+ */
538
+ enableWorldTracker() {
539
+ if (this._worldTracker) {
540
+ this._worldTracker.enabled = true;
541
+ return this._worldTracker;
542
+ }
543
+ if (this.pipeline && this._zappar) {
544
+ return this._createWorldTracker();
545
+ }
546
+ // Session not yet started – remember the request.
547
+ this._worldTrackerRequested = true;
548
+ return null;
549
+ }
550
+ _createWorldTracker() {
551
+ if (!this.pipeline || !this._zappar)
552
+ return null;
553
+ // WorldTracker is only available in @zappar/zappar >= 4.x.
554
+ const ZapparAny = this._zappar;
555
+ if (typeof ZapparAny['WorldTracker'] !== 'function') {
556
+ console.warn('[ZapparProvider] WorldTracker not available in the installed' +
557
+ ' version of @zappar/zappar. Upgrade to >= 4.x to enable plane tracking.');
558
+ return null;
559
+ }
560
+ const TrackerCtor = ZapparAny['WorldTracker'];
561
+ this._worldTracker = new TrackerCtor(this.pipeline);
562
+ this._worldTracker.enabled = true;
563
+ this._worldTracker.horizontalPlaneDetectionEnabled = true;
564
+ return this._worldTracker;
565
+ }
481
566
  async ensureCameraRunning() {
482
567
  if (!this.cameraSource || this.cameraStarted)
483
568
  return;
@@ -571,6 +656,33 @@ export class ZapparProvider extends ARProvider {
571
656
  get hasSlamTrackingState() {
572
657
  return this._slamStateValid;
573
658
  }
659
+ /**
660
+ * `true` while the instant world anchor is being continuously repositioned
661
+ * in front of the camera (either during warmup or during user-placement
662
+ * mode). `false` once the anchor has been locked.
663
+ */
664
+ get isPlacingInstantAnchor() {
665
+ return this._userPlacementMode || this._anchorWarmupFramesRemaining > 0;
666
+ }
667
+ /**
668
+ * Enter user-placement mode: the instant world anchor tracks 5 m in front
669
+ * of the camera every frame until {@link placeInstantAnchor} is called.
670
+ *
671
+ * Typically called by a placement-UI component immediately after session
672
+ * start so the user can choose where to lock the world origin.
673
+ */
674
+ startUserPlacement() {
675
+ this._userPlacementMode = true;
676
+ }
677
+ /**
678
+ * Lock the instant world anchor at its current position and exit placement
679
+ * mode. After this call {@link isPlacingInstantAnchor} returns `false` and
680
+ * the anchor no longer follows the camera.
681
+ */
682
+ placeInstantAnchor() {
683
+ this._userPlacementMode = false;
684
+ this._anchorWarmupFramesRemaining = 0;
685
+ }
574
686
  get slamFrameNumber() {
575
687
  return this._slamFrameNumber;
576
688
  }
@@ -659,13 +771,14 @@ export class ZapparProvider extends ARProvider {
659
771
  // Let Zappar continuously refine a stable surface point briefly, then lock.
660
772
  // If we lock immediately at startup, we can end up with effectively 3DoF behavior.
661
773
  // If we *never* lock, the origin follows the camera and the camera appears frozen.
662
- if (this._anchorWarmupFramesRemaining > 0) {
663
- this.instantTracker.setAnchorPoseFromCameraOffset(0, 0, -5);
664
- this._anchorWarmupFramesRemaining--;
665
- this.hasInitializedAnchor = true;
666
- }
667
- else if (!this.hasInitializedAnchor) {
774
+ // _userPlacementMode keeps the anchor updating beyond the warmup window until the
775
+ // user explicitly confirms placement via placeInstantAnchor().
776
+ if (this._anchorWarmupFramesRemaining > 0 ||
777
+ this._userPlacementMode ||
778
+ !this.hasInitializedAnchor) {
668
779
  this.instantTracker.setAnchorPoseFromCameraOffset(0, 0, -5);
780
+ if (this._anchorWarmupFramesRemaining > 0)
781
+ this._anchorWarmupFramesRemaining--;
669
782
  this.hasInitializedAnchor = true;
670
783
  }
671
784
  // Use the active view's near/far when available; incorrect near/far can make tracking feel
@@ -675,7 +788,8 @@ export class ZapparProvider extends ARProvider {
675
788
  return;
676
789
  const zNear = activeView.near;
677
790
  const zFar = activeView.far;
678
- const projectionMatrix = Zappar.projectionMatrixFromCameraModel(this.pipeline.cameraModel(), this.engine.canvas.width, this.engine.canvas.height, zNear, zFar);
791
+ const [cameraDataWidth, cameraDataHeight] = this.pipeline.cameraDataSize();
792
+ const projectionMatrix = Zappar.projectionMatrixFromCameraModelAndSize(this.pipeline.cameraModel(), cameraDataWidth, cameraDataHeight, this.engine.canvas.width, this.engine.canvas.height, zNear, zFar);
679
793
  let origin;
680
794
  let cameraPoseMatrix;
681
795
  // Match zappar-threejs `CameraPoseMode` behavior.
@@ -828,6 +942,9 @@ export class ZapparProvider extends ARProvider {
828
942
  if (this.instantTracker) {
829
943
  this.instantTracker.enabled = false;
830
944
  }
945
+ if (this._worldTracker) {
946
+ this._worldTracker.enabled = false;
947
+ }
831
948
  if (this._xrSession) {
832
949
  try {
833
950
  await this._xrSession.end();
@@ -839,6 +956,7 @@ export class ZapparProvider extends ARProvider {
839
956
  }
840
957
  this.hasInitializedAnchor = false;
841
958
  this._anchorWarmupFramesRemaining = 0;
959
+ this._userPlacementMode = false;
842
960
  this._slamStateValid = false;
843
961
  this.onSessionEnd.notify(this);
844
962
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonderlandengine/ar-provider-zappar",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Wonderland Engine AR tracking provider based on Zappar",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -37,11 +37,11 @@
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@wonderlandengine/api": "^1.0.0",
40
- "@wonderlandengine/ar-tracking": "^1.0.0",
40
+ "@wonderlandengine/ar-tracking": "^1.2.0",
41
41
  "@wonderlandengine/components": "^1.0.0"
42
42
  },
43
43
  "dependencies": {
44
- "@zappar/zappar": "^2.2.7",
44
+ "@zappar/zappar": "^4.3.0",
45
45
  "gl-matrix": "^3.4.3"
46
46
  }
47
47
  }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import {createRequire} from 'node:module';
4
4
  import {fileURLToPath} from 'node:url';
5
+ import {execSync} from 'node:child_process';
5
6
 
6
7
  const require = createRequire(import.meta.url);
7
8
  const __filename = fileURLToPath(import.meta.url);
@@ -106,53 +107,114 @@ function main() {
106
107
  const zapparCvLibDir = path.join(zapparCvRoot, 'lib');
107
108
 
108
109
  const projectRoot = resolveProjectRoot();
109
- const outDir = path.resolve(projectRoot, 'static', 'zappar-cv');
110
-
111
- const workerSource =
112
- findFirstFile(umdDir, (name) => name === 'zappar.worker.js') ??
113
- findFirstFile(umdDir, (name) => name === 'zappar-cv.worker.js') ??
114
- findFirstFile(zapparRoot, (name) => name === 'zappar.worker.js') ??
115
- findFirstFile(zapparRoot, (name) => name === 'zappar-cv.worker.js') ??
116
- null;
117
-
118
- if (!workerSource) {
119
- warn('Could not locate Zappar worker under', umdDir);
120
- warn('Zappar root:', zapparRoot);
121
- return;
122
- }
110
+ const outDir = path.resolve(projectRoot, 'static');
111
+ // Face/CV model files (.zbin) are fetched by the Zappar runtime from the
112
+ // hardcoded path "zappar-cv/<filename>" relative to the page origin, so
113
+ // they must live in a zappar-cv/ subdirectory even though the worker and
114
+ // wasm sit at the static root.
115
+ const modelsDir = path.resolve(projectRoot, 'static', 'zappar-cv');
116
+
117
+ // -------------------------------------------------------------------------
118
+ // Detect package layout.
119
+ // @zappar/zappar < 4.x ships a pre-bundled UMD worker in `umd/`.
120
+ // @zappar/zappar >= 4.x ships only unbundled ESM; the worker must be bundled
121
+ // from `@zappar/zappar-cv/lib/worker.js` using esbuild.
122
+ // -------------------------------------------------------------------------
123
+ const isLegacyLayout = fs.existsSync(umdDir);
124
+
125
+ if (isLegacyLayout) {
126
+ // ---- Legacy (< 4.x) path ----
127
+ const workerSource =
128
+ findFirstFile(umdDir, (name) => name === 'zappar.worker.js') ??
129
+ findFirstFile(umdDir, (name) => name === 'zappar-cv.worker.js') ??
130
+ findFirstFile(zapparRoot, (name) => name === 'zappar.worker.js') ??
131
+ findFirstFile(zapparRoot, (name) => name === 'zappar-cv.worker.js') ??
132
+ null;
133
+
134
+ if (!workerSource) {
135
+ warn('Could not locate Zappar worker under', umdDir);
136
+ warn('Zappar root:', zapparRoot);
137
+ return;
138
+ }
123
139
 
124
- const wasmCandidates = listFiles(umdDir)
125
- .filter((e) => e.isFile() && e.name.endsWith('.wasm'))
126
- .map((e) => path.join(umdDir, e.name));
140
+ const wasmCandidates = listFiles(umdDir)
141
+ .filter((e) => e.isFile() && e.name.endsWith('.wasm'))
142
+ .map((e) => path.join(umdDir, e.name));
127
143
 
128
- let wasmSource = wasmCandidates.length === 1 ? wasmCandidates[0] : null;
129
- wasmSource = wasmSource ?? findWasmFromWorker(workerSource, umdDir);
130
- wasmSource = wasmSource ?? (wasmCandidates.length > 0 ? wasmCandidates[0] : null);
144
+ let wasmSource = wasmCandidates.length === 1 ? wasmCandidates[0] : null;
145
+ wasmSource = wasmSource ?? findWasmFromWorker(workerSource, umdDir);
146
+ wasmSource = wasmSource ?? (wasmCandidates.length > 0 ? wasmCandidates[0] : null);
131
147
 
132
- if (!wasmSource) {
133
- warn('Could not locate Zappar wasm under', umdDir);
134
- warn('Worker found at:', workerSource);
135
- return;
136
- }
148
+ if (!wasmSource) {
149
+ warn('Could not locate Zappar wasm under', umdDir);
150
+ warn('Worker found at:', workerSource);
151
+ return;
152
+ }
137
153
 
138
- fs.mkdirSync(outDir, {recursive: true});
154
+ fs.mkdirSync(outDir, {recursive: true});
139
155
 
140
- const workerDest = path.join(outDir, 'zappar-cv.worker.js');
141
- copyOrThrow(workerSource, workerDest);
156
+ const workerDest = path.join(outDir, 'zappar-cv.worker.js');
157
+ copyOrThrow(workerSource, workerDest);
142
158
 
143
- const wasmBasename = path.basename(wasmSource);
144
- const wasmDest = path.join(outDir, wasmBasename);
145
- copyOrThrow(wasmSource, wasmDest);
159
+ const wasmBasename = path.basename(wasmSource);
160
+ const wasmDest = path.join(outDir, wasmBasename);
161
+ copyOrThrow(wasmSource, wasmDest);
162
+ safeLinkOrCopy(wasmDest, path.join(outDir, 'zappar-cv.wasm'));
146
163
 
147
- // Create a stable alias that the Wonderland provider code points at.
148
- safeLinkOrCopy(wasmDest, path.join(outDir, 'zappar-cv.wasm'));
164
+ for (const entry of listFiles(umdDir)) {
165
+ if (!entry.isFile()) continue;
166
+ if (!entry.name.endsWith('.zbin')) continue;
167
+ copyOrThrow(path.join(umdDir, entry.name), path.join(modelsDir, entry.name));
168
+ }
169
+ } else {
170
+ // ---- Modern (>= 4.x) path ----
171
+ // Bundle the worker entry from @zappar/zappar-cv/lib/worker.js with esbuild.
172
+ const workerEntry = path.join(zapparCvLibDir, 'worker.js');
173
+ if (!fs.existsSync(workerEntry)) {
174
+ warn('Could not find worker entry at', workerEntry);
175
+ warn('Skipping worker bundling.');
176
+ return;
177
+ }
149
178
 
150
- // Optional: copy any Zappar binary blobs shipped alongside the wasm.
151
- // (Harmless if unused; avoids runtime 404s if Zappar expects them.)
152
- for (const entry of listFiles(umdDir)) {
153
- if (!entry.isFile()) continue;
154
- if (!entry.name.endsWith('.zbin')) continue;
155
- copyOrThrow(path.join(umdDir, entry.name), path.join(outDir, entry.name));
179
+ fs.mkdirSync(outDir, {recursive: true});
180
+
181
+ const workerDest = path.join(outDir, 'zappar-cv.worker.js');
182
+
183
+ // Try esbuild; it may be installed locally in the consumer project or globally.
184
+ const esbuildPaths = [
185
+ // consumer project's own node_modules (most reliable)
186
+ path.resolve(projectRoot, 'node_modules', '.bin', 'esbuild'),
187
+ path.resolve(projectRoot, 'node_modules', '.bin', 'esbuild.cmd'),
188
+ // provider package's own node_modules (if esbuild is a devDep there)
189
+ path.resolve(__dirname, '..', 'node_modules', '.bin', 'esbuild'),
190
+ path.resolve(__dirname, '..', 'node_modules', '.bin', 'esbuild.cmd'),
191
+ ];
192
+ const esbuildBin = esbuildPaths.find((p) => fs.existsSync(p));
193
+
194
+ if (!esbuildBin) {
195
+ warn('esbuild not found — cannot bundle worker for @zappar/zappar >= 4.x.');
196
+ warn('Add esbuild as a devDependency in your project and re-run npm install.');
197
+ return;
198
+ }
199
+
200
+ log('Bundling Zappar CV worker with esbuild…');
201
+ try {
202
+ execSync(
203
+ `"${esbuildBin}" "${workerEntry}" --bundle --format=esm --platform=browser --outfile="${workerDest}"`,
204
+ {stdio: 'inherit'}
205
+ );
206
+ } catch (e) {
207
+ warn('esbuild failed to bundle the Zappar CV worker:', e.message ?? e);
208
+ return;
209
+ }
210
+
211
+ // Copy the wasm file next to the worker so relative URL resolution works.
212
+ const wasmSrc = path.join(zapparCvLibDir, 'zappar-cv.wasm');
213
+ if (!fs.existsSync(wasmSrc)) {
214
+ warn('Could not find zappar-cv.wasm at', wasmSrc);
215
+ } else {
216
+ copyOrThrow(wasmSrc, path.join(outDir, 'zappar-cv.wasm'));
217
+ }
156
218
  }
157
219
 
158
220
  // Required for face tracking defaults (FaceTracker.loadDefaultModel / FaceMesh.loadDefault*).
@@ -164,7 +226,7 @@ function main() {
164
226
 
165
227
  for (const filename of requiredFaceModelFiles) {
166
228
  const src = path.join(zapparCvLibDir, filename);
167
- const dest = path.join(outDir, filename);
229
+ const dest = path.join(modelsDir, filename);
168
230
  if (!fs.existsSync(src)) {
169
231
  warn('Could not locate required Zappar face model:', filename);
170
232
  warn('Looked under:', zapparCvLibDir);
@@ -174,6 +236,27 @@ function main() {
174
236
  }
175
237
 
176
238
  log('Copied Zappar CV assets into', outDir);
239
+
240
+ // -------------------------------------------------------------------------
241
+ // Patch @zappar/zappar-cv/lib/profile.js: the file guards on
242
+ // `typeof window !== "undefined"` but then accesses `window.location`
243
+ // unconditionally. In the WLE editor's bundler context `window` is shimmed
244
+ // but `window.location` is undefined, crashing the component parse pass.
245
+ // Replace the bare access with optional chaining so it safely returns
246
+ // undefined rather than throwing.
247
+ // -------------------------------------------------------------------------
248
+ const profilePath = path.join(zapparCvRoot, 'lib', 'profile.js');
249
+ if (fs.existsSync(profilePath)) {
250
+ let profileSrc = fs.readFileSync(profilePath, 'utf8');
251
+ const patched = profileSrc.replaceAll(
252
+ 'window.location.href',
253
+ 'window.location?.href?'
254
+ );
255
+ if (patched !== profileSrc) {
256
+ fs.writeFileSync(profilePath, patched, 'utf8');
257
+ log('Patched @zappar/zappar-cv/lib/profile.js (window.location guard)');
258
+ }
259
+ }
177
260
  }
178
261
 
179
262
  try {