@wonderlandengine/ar-provider-zappar 1.1.0 → 1.2.0

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);
@@ -113,6 +123,7 @@ export class ZapparProvider extends ARProvider {
113
123
  this.ensurePipeline();
114
124
  this.ensureCameraSourcePreference();
115
125
  await this.ensureCameraRunning();
126
+ this.ensurePreRenderRegistered();
116
127
  this.startZapparDebugLogging();
117
128
  if (this._faceTracker) {
118
129
  this._faceTracker.enabled = true;
@@ -126,6 +137,15 @@ export class ZapparProvider extends ARProvider {
126
137
  if (this.instantTracker) {
127
138
  this.instantTracker.enabled = true;
128
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
+ }
129
149
  }
130
150
  ensurePreRenderRegistered() {
131
151
  if (!this.preRenderRegistered) {
@@ -205,7 +225,7 @@ export class ZapparProvider extends ARProvider {
205
225
  if (debugWorker) {
206
226
  console.log('[ZapparProvider] Creating CV worker:', workerUrl);
207
227
  }
208
- const worker = new Worker(workerUrl);
228
+ const worker = new Worker(workerUrl, { type: 'module' });
209
229
  // Keep a reference for debugging / inspection.
210
230
  ZapparProvider._cvWorker = worker;
211
231
  if (typeof window !== 'undefined') {
@@ -284,7 +304,6 @@ export class ZapparProvider extends ARProvider {
284
304
  }
285
305
  ensurePipeline() {
286
306
  if (this.pipeline) {
287
- this.ensurePreRenderRegistered();
288
307
  return;
289
308
  }
290
309
  if (!this._zappar) {
@@ -312,7 +331,6 @@ export class ZapparProvider extends ARProvider {
312
331
  }
313
332
  this.ensureCameraSourcePreference();
314
333
  this.instantTracker = new Zappar.InstantWorldTracker(pipeline);
315
- this.ensurePreRenderRegistered();
316
334
  }
317
335
  ensureCameraSourcePreference() {
318
336
  if (!this.pipeline || !this._zappar) {
@@ -400,6 +418,12 @@ export class ZapparProvider extends ARProvider {
400
418
  if (!options.name) {
401
419
  throw new Error('Image target registration requires a name.');
402
420
  }
421
+ // Deduplicate: if a descriptor with the same name is already registered,
422
+ // skip loading to avoid double-loading the same .zpt file when multiple
423
+ // scene components share the same imageId target.
424
+ if (this._imageTargetDescriptors.some((d) => d.name === options.name)) {
425
+ return;
426
+ }
403
427
  // Allow registering targets before an AR session starts.
404
428
  // This is useful so image targets are known for ImageScanningEvent
405
429
  // and so apps can prefetch/prepare targets without requesting camera access.
@@ -479,6 +503,60 @@ export class ZapparProvider extends ARProvider {
479
503
  getImageTargetDescriptor(index) {
480
504
  return this._imageTargetDescriptors[index];
481
505
  }
506
+ // -----------------------------------------------------------------------
507
+ // World tracking / plane anchors (Zappar SDK >= 4.x)
508
+ // -----------------------------------------------------------------------
509
+ /**
510
+ * Access the active {@link ZapparWorldTracker} instance, or `null` when
511
+ * plane tracking has not been requested or is unavailable.
512
+ *
513
+ * The instance is only non-null once {@link enableWorldTracker} has been
514
+ * called **and** an AR session is running.
515
+ */
516
+ get worldTracker() {
517
+ return this._worldTracker;
518
+ }
519
+ /**
520
+ * Opt in to Zappar plane tracking via the `WorldTracker` API introduced in
521
+ * `@zappar/zappar >= 4.x`.
522
+ *
523
+ * Call this once (e.g. from a component's `start()` hook) before or after
524
+ * a session starts. If the installed SDK does not expose `WorldTracker`
525
+ * (i.e. < 4.x) a warning is logged and the method returns `null`.
526
+ *
527
+ * The tracker is disabled again when the session ends and re-enabled on
528
+ * the next `startSession()` call.
529
+ *
530
+ * @returns The {@link ZapparWorldTracker} instance, or `null` on failure.
531
+ */
532
+ enableWorldTracker() {
533
+ if (this._worldTracker) {
534
+ this._worldTracker.enabled = true;
535
+ return this._worldTracker;
536
+ }
537
+ if (this.pipeline && this._zappar) {
538
+ return this._createWorldTracker();
539
+ }
540
+ // Session not yet started – remember the request.
541
+ this._worldTrackerRequested = true;
542
+ return null;
543
+ }
544
+ _createWorldTracker() {
545
+ if (!this.pipeline || !this._zappar)
546
+ return null;
547
+ // WorldTracker is only available in @zappar/zappar >= 4.x.
548
+ const ZapparAny = this._zappar;
549
+ if (typeof ZapparAny['WorldTracker'] !== 'function') {
550
+ console.warn('[ZapparProvider] WorldTracker not available in the installed' +
551
+ ' version of @zappar/zappar. Upgrade to >= 4.x to enable plane tracking.');
552
+ return null;
553
+ }
554
+ const TrackerCtor = ZapparAny['WorldTracker'];
555
+ this._worldTracker = new TrackerCtor(this.pipeline);
556
+ this._worldTracker.enabled = true;
557
+ this._worldTracker.horizontalPlaneDetectionEnabled = true;
558
+ return this._worldTracker;
559
+ }
482
560
  async ensureCameraRunning() {
483
561
  if (!this.cameraSource || this.cameraStarted)
484
562
  return;
@@ -572,6 +650,33 @@ export class ZapparProvider extends ARProvider {
572
650
  get hasSlamTrackingState() {
573
651
  return this._slamStateValid;
574
652
  }
653
+ /**
654
+ * `true` while the instant world anchor is being continuously repositioned
655
+ * in front of the camera (either during warmup or during user-placement
656
+ * mode). `false` once the anchor has been locked.
657
+ */
658
+ get isPlacingInstantAnchor() {
659
+ return this._userPlacementMode || this._anchorWarmupFramesRemaining > 0;
660
+ }
661
+ /**
662
+ * Enter user-placement mode: the instant world anchor tracks 5 m in front
663
+ * of the camera every frame until {@link placeInstantAnchor} is called.
664
+ *
665
+ * Typically called by a placement-UI component immediately after session
666
+ * start so the user can choose where to lock the world origin.
667
+ */
668
+ startUserPlacement() {
669
+ this._userPlacementMode = true;
670
+ }
671
+ /**
672
+ * Lock the instant world anchor at its current position and exit placement
673
+ * mode. After this call {@link isPlacingInstantAnchor} returns `false` and
674
+ * the anchor no longer follows the camera.
675
+ */
676
+ placeInstantAnchor() {
677
+ this._userPlacementMode = false;
678
+ this._anchorWarmupFramesRemaining = 0;
679
+ }
575
680
  get slamFrameNumber() {
576
681
  return this._slamFrameNumber;
577
682
  }
@@ -660,13 +765,14 @@ export class ZapparProvider extends ARProvider {
660
765
  // Let Zappar continuously refine a stable surface point briefly, then lock.
661
766
  // If we lock immediately at startup, we can end up with effectively 3DoF behavior.
662
767
  // If we *never* lock, the origin follows the camera and the camera appears frozen.
663
- if (this._anchorWarmupFramesRemaining > 0) {
664
- this.instantTracker.setAnchorPoseFromCameraOffset(0, 0, -5);
665
- this._anchorWarmupFramesRemaining--;
666
- this.hasInitializedAnchor = true;
667
- }
668
- else if (!this.hasInitializedAnchor) {
768
+ // _userPlacementMode keeps the anchor updating beyond the warmup window until the
769
+ // user explicitly confirms placement via placeInstantAnchor().
770
+ if (this._anchorWarmupFramesRemaining > 0 ||
771
+ this._userPlacementMode ||
772
+ !this.hasInitializedAnchor) {
669
773
  this.instantTracker.setAnchorPoseFromCameraOffset(0, 0, -5);
774
+ if (this._anchorWarmupFramesRemaining > 0)
775
+ this._anchorWarmupFramesRemaining--;
670
776
  this.hasInitializedAnchor = true;
671
777
  }
672
778
  // Use the active view's near/far when available; incorrect near/far can make tracking feel
@@ -676,7 +782,8 @@ export class ZapparProvider extends ARProvider {
676
782
  return;
677
783
  const zNear = activeView.near;
678
784
  const zFar = activeView.far;
679
- const projectionMatrix = Zappar.projectionMatrixFromCameraModel(this.pipeline.cameraModel(), this.engine.canvas.width, this.engine.canvas.height, zNear, zFar);
785
+ const [cameraDataWidth, cameraDataHeight] = this.pipeline.cameraDataSize();
786
+ const projectionMatrix = Zappar.projectionMatrixFromCameraModelAndSize(this.pipeline.cameraModel(), cameraDataWidth, cameraDataHeight, this.engine.canvas.width, this.engine.canvas.height, zNear, zFar);
680
787
  let origin;
681
788
  let cameraPoseMatrix;
682
789
  // Match zappar-threejs `CameraPoseMode` behavior.
@@ -829,6 +936,9 @@ export class ZapparProvider extends ARProvider {
829
936
  if (this.instantTracker) {
830
937
  this.instantTracker.enabled = false;
831
938
  }
939
+ if (this._worldTracker) {
940
+ this._worldTracker.enabled = false;
941
+ }
832
942
  if (this._xrSession) {
833
943
  try {
834
944
  await this._xrSession.end();
@@ -840,6 +950,7 @@ export class ZapparProvider extends ARProvider {
840
950
  }
841
951
  this.hasInitializedAnchor = false;
842
952
  this._anchorWarmupFramesRemaining = 0;
953
+ this._userPlacementMode = false;
843
954
  this._slamStateValid = false;
844
955
  this.onSessionEnd.notify(this);
845
956
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonderlandengine/ar-provider-zappar",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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);
@@ -108,51 +109,111 @@ function main() {
108
109
  const projectRoot = resolveProjectRoot();
109
110
  const outDir = path.resolve(projectRoot, 'static', 'zappar-cv');
110
111
 
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
- }
112
+ // -------------------------------------------------------------------------
113
+ // Detect package layout.
114
+ // @zappar/zappar < 4.x ships a pre-bundled UMD worker in `umd/`.
115
+ // @zappar/zappar >= 4.x ships only unbundled ESM; the worker must be bundled
116
+ // from `@zappar/zappar-cv/lib/worker.js` using esbuild.
117
+ // -------------------------------------------------------------------------
118
+ const isLegacyLayout = fs.existsSync(umdDir);
119
+
120
+ if (isLegacyLayout) {
121
+ // ---- Legacy (< 4.x) path ----
122
+ const workerSource =
123
+ findFirstFile(umdDir, (name) => name === 'zappar.worker.js') ??
124
+ findFirstFile(umdDir, (name) => name === 'zappar-cv.worker.js') ??
125
+ findFirstFile(zapparRoot, (name) => name === 'zappar.worker.js') ??
126
+ findFirstFile(zapparRoot, (name) => name === 'zappar-cv.worker.js') ??
127
+ null;
128
+
129
+ if (!workerSource) {
130
+ warn('Could not locate Zappar worker under', umdDir);
131
+ warn('Zappar root:', zapparRoot);
132
+ return;
133
+ }
123
134
 
124
- const wasmCandidates = listFiles(umdDir)
125
- .filter((e) => e.isFile() && e.name.endsWith('.wasm'))
126
- .map((e) => path.join(umdDir, e.name));
135
+ const wasmCandidates = listFiles(umdDir)
136
+ .filter((e) => e.isFile() && e.name.endsWith('.wasm'))
137
+ .map((e) => path.join(umdDir, e.name));
127
138
 
128
- let wasmSource = wasmCandidates.length === 1 ? wasmCandidates[0] : null;
129
- wasmSource = wasmSource ?? findWasmFromWorker(workerSource, umdDir);
130
- wasmSource = wasmSource ?? (wasmCandidates.length > 0 ? wasmCandidates[0] : null);
139
+ let wasmSource = wasmCandidates.length === 1 ? wasmCandidates[0] : null;
140
+ wasmSource = wasmSource ?? findWasmFromWorker(workerSource, umdDir);
141
+ wasmSource = wasmSource ?? (wasmCandidates.length > 0 ? wasmCandidates[0] : null);
131
142
 
132
- if (!wasmSource) {
133
- warn('Could not locate Zappar wasm under', umdDir);
134
- warn('Worker found at:', workerSource);
135
- return;
136
- }
143
+ if (!wasmSource) {
144
+ warn('Could not locate Zappar wasm under', umdDir);
145
+ warn('Worker found at:', workerSource);
146
+ return;
147
+ }
137
148
 
138
- fs.mkdirSync(outDir, {recursive: true});
149
+ fs.mkdirSync(outDir, {recursive: true});
139
150
 
140
- const workerDest = path.join(outDir, 'zappar-cv.worker.js');
141
- copyOrThrow(workerSource, workerDest);
151
+ const workerDest = path.join(outDir, 'zappar-cv.worker.js');
152
+ copyOrThrow(workerSource, workerDest);
142
153
 
143
- const wasmBasename = path.basename(wasmSource);
144
- const wasmDest = path.join(outDir, wasmBasename);
145
- copyOrThrow(wasmSource, wasmDest);
154
+ const wasmBasename = path.basename(wasmSource);
155
+ const wasmDest = path.join(outDir, wasmBasename);
156
+ copyOrThrow(wasmSource, wasmDest);
157
+ safeLinkOrCopy(wasmDest, path.join(outDir, 'zappar-cv.wasm'));
158
+ // Also copy wasm to static root so the bundled worker can find it at /zappar-cv.wasm.
159
+ copyOrThrow(wasmSource, path.join(path.dirname(outDir), 'zappar-cv.wasm'));
146
160
 
147
- // Create a stable alias that the Wonderland provider code points at.
148
- safeLinkOrCopy(wasmDest, path.join(outDir, 'zappar-cv.wasm'));
161
+ for (const entry of listFiles(umdDir)) {
162
+ if (!entry.isFile()) continue;
163
+ if (!entry.name.endsWith('.zbin')) continue;
164
+ copyOrThrow(path.join(umdDir, entry.name), path.join(outDir, entry.name));
165
+ }
166
+ } else {
167
+ // ---- Modern (>= 4.x) path ----
168
+ // Bundle the worker entry from @zappar/zappar-cv/lib/worker.js with esbuild.
169
+ const workerEntry = path.join(zapparCvLibDir, 'worker.js');
170
+ if (!fs.existsSync(workerEntry)) {
171
+ warn('Could not find worker entry at', workerEntry);
172
+ warn('Skipping worker bundling.');
173
+ return;
174
+ }
149
175
 
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));
176
+ fs.mkdirSync(outDir, {recursive: true});
177
+
178
+ const workerDest = path.join(outDir, 'zappar-cv.worker.js');
179
+
180
+ // Try esbuild; it may be installed locally in the consumer project or globally.
181
+ const esbuildPaths = [
182
+ // consumer project's own node_modules (most reliable)
183
+ path.resolve(projectRoot, 'node_modules', '.bin', 'esbuild'),
184
+ path.resolve(projectRoot, 'node_modules', '.bin', 'esbuild.cmd'),
185
+ // provider package's own node_modules (if esbuild is a devDep there)
186
+ path.resolve(__dirname, '..', 'node_modules', '.bin', 'esbuild'),
187
+ path.resolve(__dirname, '..', 'node_modules', '.bin', 'esbuild.cmd'),
188
+ ];
189
+ const esbuildBin = esbuildPaths.find((p) => fs.existsSync(p));
190
+
191
+ if (!esbuildBin) {
192
+ warn('esbuild not found — cannot bundle worker for @zappar/zappar >= 4.x.');
193
+ warn('Add esbuild as a devDependency in your project and re-run npm install.');
194
+ return;
195
+ }
196
+
197
+ log('Bundling Zappar CV worker with esbuild…');
198
+ try {
199
+ execSync(
200
+ `"${esbuildBin}" "${workerEntry}" --bundle --format=esm --platform=browser --outfile="${workerDest}"`,
201
+ {stdio: 'inherit'}
202
+ );
203
+ } catch (e) {
204
+ warn('esbuild failed to bundle the Zappar CV worker:', e.message ?? e);
205
+ return;
206
+ }
207
+
208
+ // Copy the wasm file next to the worker so relative URL resolution works.
209
+ const wasmSrc = path.join(zapparCvLibDir, 'zappar-cv.wasm');
210
+ if (!fs.existsSync(wasmSrc)) {
211
+ warn('Could not find zappar-cv.wasm at', wasmSrc);
212
+ } else {
213
+ copyOrThrow(wasmSrc, path.join(outDir, 'zappar-cv.wasm'));
214
+ // Also copy wasm to static root so the bundled worker can find it at /zappar-cv.wasm.
215
+ copyOrThrow(wasmSrc, path.join(path.dirname(outDir), 'zappar-cv.wasm'));
216
+ }
156
217
  }
157
218
 
158
219
  // Required for face tracking defaults (FaceTracker.loadDefaultModel / FaceMesh.loadDefault*).
@@ -174,6 +235,27 @@ function main() {
174
235
  }
175
236
 
176
237
  log('Copied Zappar CV assets into', outDir);
238
+
239
+ // -------------------------------------------------------------------------
240
+ // Patch @zappar/zappar-cv/lib/profile.js: the file guards on
241
+ // `typeof window !== "undefined"` but then accesses `window.location`
242
+ // unconditionally. In the WLE editor's bundler context `window` is shimmed
243
+ // but `window.location` is undefined, crashing the component parse pass.
244
+ // Replace the bare access with optional chaining so it safely returns
245
+ // undefined rather than throwing.
246
+ // -------------------------------------------------------------------------
247
+ const profilePath = path.join(zapparCvRoot, 'lib', 'profile.js');
248
+ if (fs.existsSync(profilePath)) {
249
+ let profileSrc = fs.readFileSync(profilePath, 'utf8');
250
+ const patched = profileSrc.replaceAll(
251
+ 'window.location.href',
252
+ 'window.location?.href?'
253
+ );
254
+ if (patched !== profileSrc) {
255
+ fs.writeFileSync(profilePath, patched, 'utf8');
256
+ log('Patched @zappar/zappar-cv/lib/profile.js (window.location guard)');
257
+ }
258
+ }
177
259
  }
178
260
 
179
261
  try {