@playcanvas/web-components 0.18.0 → 0.20.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.
Files changed (56) hide show
  1. package/dist/app.d.cts +38 -3
  2. package/dist/app.d.ts +38 -3
  3. package/dist/components/anim-clip.d.cts +0 -2
  4. package/dist/components/anim-clip.d.ts +0 -2
  5. package/dist/components/button-component.d.cts +9 -5
  6. package/dist/components/button-component.d.ts +9 -5
  7. package/dist/components/joint-component.d.cts +24 -10
  8. package/dist/components/joint-component.d.ts +24 -10
  9. package/dist/components/script-component.d.cts +4 -2
  10. package/dist/components/script-component.d.ts +4 -2
  11. package/dist/components/script-instance.d.cts +14 -6
  12. package/dist/components/script-instance.d.ts +14 -6
  13. package/dist/components/scroll-view-component.d.cts +24 -12
  14. package/dist/components/scroll-view-component.d.ts +24 -12
  15. package/dist/components/scrollbar-component.d.cts +6 -3
  16. package/dist/components/scrollbar-component.d.ts +6 -3
  17. package/dist/custom-elements.json +93 -23
  18. package/dist/entity-base.d.cts +4 -3
  19. package/dist/entity-base.d.ts +4 -3
  20. package/dist/entity.d.cts +8 -2
  21. package/dist/entity.d.ts +8 -2
  22. package/dist/model.d.cts +6 -0
  23. package/dist/model.d.ts +6 -0
  24. package/dist/node.d.cts +6 -0
  25. package/dist/node.d.ts +6 -0
  26. package/dist/parse.d.cts +7 -2
  27. package/dist/parse.d.ts +7 -2
  28. package/dist/pwc.cjs +706 -289
  29. package/dist/pwc.cjs.map +1 -1
  30. package/dist/pwc.js +706 -289
  31. package/dist/pwc.js.map +1 -1
  32. package/dist/pwc.min.js +1 -1
  33. package/dist/pwc.min.js.map +1 -1
  34. package/dist/pwc.min.mjs +1 -1
  35. package/dist/pwc.min.mjs.map +1 -1
  36. package/dist/pwc.mjs +706 -289
  37. package/dist/pwc.mjs.map +1 -1
  38. package/dist/scene.d.cts +17 -1
  39. package/dist/scene.d.ts +17 -1
  40. package/dist/vscode.html-custom-data.json +34 -14
  41. package/dist/web-types.json +78 -24
  42. package/package.json +3 -3
  43. package/src/app.ts +197 -87
  44. package/src/components/anim-clip.ts +0 -2
  45. package/src/components/button-component.ts +18 -10
  46. package/src/components/joint-component.ts +29 -15
  47. package/src/components/script-component.ts +25 -12
  48. package/src/components/script-instance.ts +14 -6
  49. package/src/components/scroll-view-component.ts +49 -29
  50. package/src/components/scrollbar-component.ts +13 -8
  51. package/src/entity-base.ts +27 -15
  52. package/src/entity.ts +11 -4
  53. package/src/model.ts +9 -2
  54. package/src/node.ts +9 -2
  55. package/src/parse.ts +213 -16
  56. package/src/scene.ts +33 -2
package/src/app.ts CHANGED
@@ -66,6 +66,7 @@ import {
66
66
 
67
67
  import type { AssetElement } from './asset';
68
68
  import { AsyncElement } from './async-element';
69
+ import { SYNTHESIZED_EVENTS } from './entity-base';
69
70
  import type { EntityBaseElement } from './entity-base';
70
71
  import type { EntityOwnerElement } from './entity-owner';
71
72
  import { LoadingBar } from './loading-bar';
@@ -73,9 +74,6 @@ import type { MaterialElement } from './material';
73
74
  import { parseBool, parseEnum, parseNumber } from './parse';
74
75
  import type { WasmElement } from './wasm';
75
76
 
76
- /** The pointer event types the application synthesizes on `<pc-entity>` elements via picking. */
77
- const pointerEventTypes = ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'] as const;
78
-
79
77
  /**
80
78
  * The event types whose listeners make an element a hover target. Hover resolution walks past
81
79
  * elements listening for none of them, so a silent element never swallows an ancestor's
@@ -83,6 +81,48 @@ const pointerEventTypes = ['pointermove', 'pointerdown', 'pointerup', 'pointeren
83
81
  */
84
82
  const hoverEventTypes = ['pointerenter', 'pointerleave', 'pointermove'] as const;
85
83
 
84
+ /**
85
+ * The canvas listeners each synthesized event type is driven by. Enter and leave are derived
86
+ * from move picks. A click is concluded from the down/up pair, with pointercancel discarding a
87
+ * press the browser takes back (for example a touch that becomes a scroll).
88
+ */
89
+ const canvasEventsFor: Record<(typeof SYNTHESIZED_EVENTS)[number], readonly string[]> = {
90
+ pointermove: ['pointermove'],
91
+ pointerenter: ['pointermove'],
92
+ pointerleave: ['pointermove'],
93
+ pointerdown: ['pointerdown'],
94
+ pointerup: ['pointerup'],
95
+ click: ['pointerdown', 'pointerup', 'pointercancel']
96
+ };
97
+
98
+ /**
99
+ * How long after a click a further click on the same target still raises the click count that
100
+ * `detail` carries, approximating the platform's double-click time.
101
+ */
102
+ const CLICK_CHAIN_MS = 500;
103
+
104
+ /**
105
+ * Finds the nearest common inclusive ancestor of two picked nodes - the node a click belongs to
106
+ * when the press and the release picked different geometry, exactly as the DOM assigns a click
107
+ * whose down and up have different targets.
108
+ *
109
+ * @param a - The node the press picked, or `null`.
110
+ * @param b - The node the release picked, or `null`.
111
+ * @returns The nearest common inclusive ancestor, or `null` when there is none.
112
+ */
113
+ const commonAncestor = (a: GraphNode | null, b: GraphNode | null): GraphNode | null => {
114
+ const ancestors = new Set<GraphNode>();
115
+ for (let node = a; node !== null; node = node.parent) {
116
+ ancestors.add(node);
117
+ }
118
+ for (let node = b; node !== null; node = node.parent) {
119
+ if (ancestors.has(node)) {
120
+ return node;
121
+ }
122
+ }
123
+ return null;
124
+ };
125
+
86
126
  /**
87
127
  * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
88
128
  * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
@@ -182,14 +222,6 @@ class AppElement extends AsyncElement {
182
222
 
183
223
  private _picker: Picker | null = null;
184
224
 
185
- private _hasPointerListeners: Record<string, boolean> = {
186
- pointerenter: false,
187
- pointerleave: false,
188
- pointerdown: false,
189
- pointerup: false,
190
- pointermove: false
191
- };
192
-
193
225
  private _hoveredEntity: EntityBaseElement | null = null;
194
226
 
195
227
  // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
@@ -198,9 +230,35 @@ class AppElement extends AsyncElement {
198
230
  private _pointerHandlers: Record<string, EventListener | null> = {
199
231
  pointermove: null,
200
232
  pointerdown: null,
201
- pointerup: null
233
+ pointerup: null,
234
+ pointercancel: null
202
235
  };
203
236
 
237
+ /**
238
+ * The pick of each pointer's primary-button press, keyed by pointerId and kept while a click
239
+ * may still conclude it. The promise is stored rather than its result, so a release can
240
+ * await a press pick that has not resolved yet. Entries are removed by the matching
241
+ * pointerup or pointercancel, and only ever stored while some element listens for click -
242
+ * which is also what keeps those two canvas listeners attached.
243
+ */
244
+ private _downPicks = new Map<number, Promise<GraphNode | null>>();
245
+
246
+ /** Whether any element in the tree listens for click. Maintained by _syncCanvasListeners. */
247
+ private _clickListened = false;
248
+
249
+ /**
250
+ * The previous click's target, time and count, for chaining successive clicks into the
251
+ * click count that `detail` carries. `null` until a click has fired.
252
+ */
253
+ private _lastClick: { element: EntityBaseElement; time: number; count: number } | null = null;
254
+
255
+ /**
256
+ * Serializes dispatch of the discrete synthesized events (pointerdown, pointerup, click),
257
+ * whose picks resolve in GPU order, not canvas-event order. Replaced on teardown, so a pick
258
+ * that never resolves cannot stall the dispatches of a later boot.
259
+ */
260
+ private _dispatchChain: Promise<void> = Promise.resolve();
261
+
204
262
  private _app: AppBase | null = null;
205
263
 
206
264
  private _loadProgress = 0;
@@ -241,12 +299,12 @@ class AppElement extends AsyncElement {
241
299
  constructor() {
242
300
  super();
243
301
 
244
- // Track pointer listeners being added to and removed from descendant entities.
245
- // Registered once here rather than on every boot - the handlers no-op while there is no
246
- // canvas, and a re-booted element must not stack a second set.
247
- pointerEventTypes.forEach((type) => {
248
- this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
249
- this.addEventListener(`${type}:disconnect`, () => this._onPointerListenerRemoved(type));
302
+ // Track listeners for the synthesized events being added to and removed from descendant
303
+ // entities. Registered once here rather than on every boot - the sync no-ops while there
304
+ // is no canvas, and a re-booted element must not stack a second set.
305
+ SYNTHESIZED_EVENTS.forEach((type) => {
306
+ this.addEventListener(`${type}:connect`, () => this._syncCanvasListeners());
307
+ this.addEventListener(`${type}:disconnect`, () => this._syncCanvasListeners());
250
308
  });
251
309
  }
252
310
 
@@ -597,10 +655,9 @@ class AppElement extends AsyncElement {
597
655
  const { width, height } = this.app!.graphicsDevice;
598
656
  this._picker = new Picker(this.app!, width, height);
599
657
 
600
- // Create bound handlers but don't attach them yet. The handlers pick asynchronously, so
601
- // each is wrapped to discard the promise - a listener must not return one, and nothing
602
- // awaits the result.
603
- const listener = (handler: (event: PointerEvent) => Promise<void>): EventListener => {
658
+ // Create bound handlers but don't attach them yet. The move handler is async, so it is
659
+ // wrapped to discard the promise - a listener must not return one.
660
+ const listener = (handler: (event: PointerEvent) => void | Promise<void>): EventListener => {
604
661
  return (event: Event) => {
605
662
  handler.call(this, event as PointerEvent);
606
663
  };
@@ -609,18 +666,14 @@ class AppElement extends AsyncElement {
609
666
  this._pointerHandlers.pointermove = listener(this._onPointerMove);
610
667
  this._pointerHandlers.pointerdown = listener(this._onPointerDown);
611
668
  this._pointerHandlers.pointerup = listener(this._onPointerUp);
669
+ this._pointerHandlers.pointercancel = (event: Event) => {
670
+ this._downPicks.delete((event as PointerEvent).pointerId);
671
+ };
612
672
 
613
- // Attach canvas handlers for listeners registered before this boot (e.g. handlers
614
- // created from onpointer* attributes when their elements were first upgraded, or
673
+ // Attach canvas listeners for element listeners registered before this boot (e.g.
674
+ // handlers created from inline attributes when their elements were first upgraded, or
615
675
  // listeners carried over from before a re-boot)
616
- pointerEventTypes.forEach((type) => {
617
- const anyListeners = Array.from(
618
- this.querySelectorAll<EntityBaseElement>('pc-entity, pc-model, pc-node')
619
- ).some((entity) => entity._hasListeners(type));
620
- if (anyListeners) {
621
- this._onPointerListenerAdded(type);
622
- }
623
- });
676
+ this._syncCanvasListeners();
624
677
  }
625
678
 
626
679
  private _pickerDestroy() {
@@ -637,15 +690,15 @@ class AppElement extends AsyncElement {
637
690
  this._pointerHandlers = {
638
691
  pointermove: null,
639
692
  pointerdown: null,
640
- pointerup: null
641
- };
642
- this._hasPointerListeners = {
643
- pointerenter: false,
644
- pointerleave: false,
645
- pointerdown: false,
646
- pointerup: false,
647
- pointermove: false
693
+ pointerup: null,
694
+ pointercancel: null
648
695
  };
696
+ this._downPicks.clear();
697
+ this._clickListened = false;
698
+ this._lastClick = null;
699
+
700
+ // Replace the chain: a pick that never resolves must not stall a later boot's dispatches
701
+ this._dispatchChain = Promise.resolve();
649
702
  }
650
703
 
651
704
  /**
@@ -868,69 +921,126 @@ class AppElement extends AsyncElement {
868
921
  }
869
922
  }
870
923
 
871
- private async _onPointerDown(event: PointerEvent) {
924
+ /**
925
+ * Appends a dispatch step to {@link _dispatchChain}. Must be called synchronously from the
926
+ * canvas event handler - the order of appends is what carries canvas-event order. A step
927
+ * that rejects is reported and released, so the steps queued behind it still dispatch.
928
+ *
929
+ * @param step - The dispatch work to run once every earlier step has finished.
930
+ */
931
+ private _chainDispatch(step: () => Promise<void>) {
932
+ this._dispatchChain = this._dispatchChain.then(step).catch((error) => {
933
+ console.error(error);
934
+ });
935
+ }
936
+
937
+ private _onPointerDown(event: PointerEvent) {
872
938
  if (!this._picker || !this.app) return;
873
939
 
874
- const node = await this._pickNode(event);
875
- if (!this._picker) return; // the element disconnected while the pick was in flight
940
+ // Picks stay concurrent - only the dispatch of the results is serialized
941
+ const pick = this._pickNode(event);
876
942
 
877
- const entityElement = this._elementWithListener(node, 'pointerdown');
878
- if (entityElement) {
879
- entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
943
+ // A click concludes on the matching pointerup, which needs to know what the press
944
+ // picked. Primary button only - the only button a click can conclude from - and only
945
+ // while click is listened for, since it is the click mapping that keeps the pointerup
946
+ // and pointercancel listeners attached to clean the entry up again.
947
+ if (this._clickListened && event.button === 0) {
948
+ this._downPicks.set(event.pointerId, pick);
880
949
  }
950
+
951
+ this._chainDispatch(async () => {
952
+ const node = await pick;
953
+ if (!this._picker) return; // the element disconnected while the pick was in flight
954
+
955
+ const entityElement = this._elementWithListener(node, 'pointerdown');
956
+ if (entityElement) {
957
+ entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
958
+ }
959
+ });
881
960
  }
882
961
 
883
- private async _onPointerUp(event: PointerEvent) {
962
+ private _onPointerUp(event: PointerEvent) {
884
963
  if (!this._picker || !this.app) return;
885
964
 
886
- const node = await this._pickNode(event);
887
- if (!this._picker) return; // the element disconnected while the pick was in flight
965
+ // The press pick this release may conclude as a click. Claimed synchronously, so the
966
+ // entry is gone before any other event for this pointer can be handled.
967
+ const downPick = this._downPicks.get(event.pointerId);
968
+ this._downPicks.delete(event.pointerId);
888
969
 
889
- const entityElement = this._elementWithListener(node, 'pointerup');
890
- if (entityElement) {
891
- entityElement.dispatchEvent(new PointerEvent('pointerup', event));
892
- }
893
- }
970
+ const pick = this._pickNode(event);
894
971
 
895
- private _onPointerListenerAdded(type: string) {
896
- if (!this._hasPointerListeners[type] && this._canvas) {
897
- this._hasPointerListeners[type] = true;
898
-
899
- // For enter/leave events, we need the move handler
900
- const handler =
901
- type === 'pointerenter' || type === 'pointerleave'
902
- ? this._pointerHandlers.pointermove
903
- : this._pointerHandlers[type];
904
-
905
- if (handler) {
906
- this._canvas.addEventListener(
907
- type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type,
908
- handler
909
- );
910
- }
911
- }
912
- }
972
+ this._chainDispatch(async () => {
973
+ const node = await pick;
974
+ if (!this._picker) return; // the element disconnected while the pick was in flight
913
975
 
914
- private _onPointerListenerRemoved(type: string) {
915
- const hasListeners = Array.from(
916
- this.querySelectorAll<EntityBaseElement>('pc-entity, pc-model, pc-node')
917
- ).some((entity) => entity._hasListeners(type));
976
+ const entityElement = this._elementWithListener(node, 'pointerup');
977
+ if (entityElement) {
978
+ entityElement.dispatchEvent(new PointerEvent('pointerup', event));
979
+ }
980
+ });
918
981
 
919
- if (!hasListeners && this._canvas) {
920
- this._hasPointerListeners[type] = false;
982
+ // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
983
+ // what the press and the release picked, for the primary button only. Appended after
984
+ // the release's own step, so it dispatches after the pointerup that concludes it.
985
+ if (!downPick || event.button !== 0) return;
986
+
987
+ this._chainDispatch(async () => {
988
+ // A rejected pick was already reported by the press or release step that awaited it;
989
+ // here it just means no click can conclude.
990
+ const picked = await Promise.all([downPick, pick]).catch(() => null);
991
+ if (!picked || !this._picker) return;
992
+
993
+ const [downNode, upNode] = picked;
994
+ const clickElement = this._elementWithListener(commonAncestor(downNode, upNode), 'click');
995
+ if (clickElement) {
996
+ const click = new PointerEvent('click', event);
997
+
998
+ // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
999
+ // at 0 - but click is exempt: its detail is the click count, chained here as the
1000
+ // platform chains it (same target, within the double-click window). Overridden
1001
+ // with defineProperty because an event instance used as an init dict cannot have
1002
+ // single fields replaced.
1003
+ const time = performance.now();
1004
+ const last = this._lastClick;
1005
+ const count =
1006
+ last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
1007
+ this._lastClick = { element: clickElement, time, count };
1008
+ Object.defineProperty(click, 'detail', { value: count });
1009
+
1010
+ clickElement.dispatchEvent(click);
1011
+ }
1012
+ });
1013
+ }
921
1014
 
922
- const handler =
923
- type === 'pointerenter' || type === 'pointerleave'
924
- ? this._pointerHandlers.pointermove
925
- : this._pointerHandlers[type];
1015
+ /**
1016
+ * Attaches exactly the canvas listeners the tree's current element listeners need, and
1017
+ * detaches the rest. Recomputed whenever a listener connects or disconnects anywhere under
1018
+ * this element: several synthesized types can need the same canvas listener (enter, leave
1019
+ * and move all ride the move pick; click rides the down/up pair), so one type's removal
1020
+ * must not detach a listener another type still uses. Re-attaching an attached listener is
1021
+ * a no-op by EventTarget semantics, so no attach state is kept.
1022
+ */
1023
+ private _syncCanvasListeners() {
1024
+ const canvas = this._canvas;
1025
+ if (!canvas) return; // not booted yet: _pickerCreate syncs once the handlers exist
926
1026
 
927
- if (handler) {
928
- this._canvas.removeEventListener(
929
- type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type,
930
- handler
931
- );
1027
+ const elements = Array.from(this.querySelectorAll<EntityBaseElement>('pc-entity, pc-model, pc-node'));
1028
+ const needed = new Set<string>();
1029
+ for (const type of SYNTHESIZED_EVENTS) {
1030
+ if (elements.some((element) => element._hasListeners(type))) {
1031
+ canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
932
1032
  }
933
1033
  }
1034
+ this._clickListened = elements.some((element) => element._hasListeners('click'));
1035
+
1036
+ Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1037
+ if (!handler) return;
1038
+ if (needed.has(canvasType)) {
1039
+ canvas.addEventListener(canvasType, handler);
1040
+ } else {
1041
+ canvas.removeEventListener(canvasType, handler);
1042
+ }
1043
+ });
934
1044
  }
935
1045
 
936
1046
  /**
@@ -25,8 +25,6 @@ import { AnimComponentElement } from './anim-component';
25
25
  * @elementSummary The `<pc-anim-clip>` element declares one named animation clip on its parent
26
26
  * `<pc-anim>`, taken from the `asset` it names or, without one, from the enclosing `<pc-model>`'s
27
27
  * own animations. Must be a direct child of `<pc-anim>`.
28
- *
29
- * @category Components
30
28
  */
31
29
  class AnimClipElement extends AsyncElement {
32
30
  /**
@@ -2,7 +2,7 @@ import type { ButtonComponent } from 'playcanvas';
2
2
  import { BUTTON_TRANSITION_MODE_SPRITE_CHANGE, BUTTON_TRANSITION_MODE_TINT, Color, Vec4 } from 'playcanvas';
3
3
 
4
4
  import { useAsset } from '../asset';
5
- import { getEntity, parseBool, parseColor, parseEnum, parseNumber, parseVec4 } from '../parse';
5
+ import { parseBool, parseColor, parseEnum, parseNumber, parseVec4, resolveEntity } from '../parse';
6
6
 
7
7
  import { ComponentElement } from './component';
8
8
 
@@ -76,7 +76,9 @@ class ButtonComponentElement extends ComponentElement {
76
76
 
77
77
  // The image entity defaults to the button's own entity (which carries the image element)
78
78
  // when no explicit reference is provided.
79
- const imageEntity = this._image ? getEntity(this._image) : this.closestEntity?.entity;
79
+ const imageEntity = this._image
80
+ ? resolveEntity(this._image, this, 'image', 'reference ignored')
81
+ : this.closestEntity?.entity;
80
82
  if (imageEntity) {
81
83
  data.imageEntity = imageEntity;
82
84
  }
@@ -127,22 +129,28 @@ class ButtonComponentElement extends ComponentElement {
127
129
  }
128
130
 
129
131
  /**
130
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` whose image
131
- * element is used for visual transitions. Defaults to the button's own entity — inside a
132
- * `<pc-model>`, that is the model's host entity, so supply an explicit reference to target a
133
- * UI entity instead.
132
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
133
+ * selector) to the entity whose image element is used for visual transitions. An exact name
134
+ * resolves against the nearest enclosing entity first, then outward, then the document.
135
+ * Defaults to the button's own entity — inside a `<pc-model>`, that is the model's host
136
+ * entity, so supply an explicit reference to target a UI entity instead. A non-empty
137
+ * reference that does not resolve warns and is ignored.
134
138
  * @param value - The image entity reference.
135
139
  */
136
140
  set image(value: string) {
137
141
  this._image = value;
138
- const entity = getEntity(value);
139
- if (this.component && entity) {
140
- this.component.imageEntity = entity;
142
+ if (this.component) {
143
+ const entity = resolveEntity(value, this, 'image', 'reference ignored');
144
+ if (entity) {
145
+ this.component.imageEntity = entity;
146
+ }
141
147
  }
142
148
  }
143
149
 
144
150
  /**
145
- * Gets the reference to the `<pc-entity>` whose image element is used for visual transitions.
151
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
152
+ * selector) to the entity whose image element is used for visual transitions, or empty for
153
+ * the button's own entity.
146
154
  * @returns The image entity reference.
147
155
  */
148
156
  get image() {
@@ -1,7 +1,7 @@
1
1
  import type { JointComponent } from 'playcanvas';
2
2
  import { Vec2, Vec3 } from 'playcanvas';
3
3
 
4
- import { getEntity, parseBool, parseEnum, parseNumber, parseVec2, parseVec3 } from '../parse';
4
+ import { parseBool, parseEnum, parseNumber, parseVec2, parseVec3, resolveEntity } from '../parse';
5
5
 
6
6
  import { ComponentElement } from './component';
7
7
 
@@ -25,7 +25,12 @@ export type MotionMode = 'locked' | 'limited' | 'free';
25
25
  * primary axis: a hinge rotates about it, a slider translates along it and a ball joint twists
26
26
  * about it. The constrained bodies are referenced by `entity-a` and `entity-b`, both of which need
27
27
  * a rigid body component; leaving `entity-b` empty constrains `entity-a` to a fixed point in world
28
- * space. The underlying engine component is in alpha, so its API may change.
28
+ * space. A reference can name any entity-fronting element `<pc-entity>`, `<pc-model>` or
29
+ * `<pc-node>`, so a ragdoll can join a model's own skeleton nodes by name — and a name resolves
30
+ * against the nearest enclosing entity first, then outward through the entity hierarchy, then the
31
+ * document, while a `#` selector resolves document-wide. A `<template>` prefab with one
32
+ * entity-fronting root can therefore wire its joints by name and stay self-contained when cloned.
33
+ * The underlying engine component is in alpha, so its API may change.
29
34
  *
30
35
  * @elementSummary The `<pc-joint>` element constrains two rigid bodies to each other — a hinged
31
36
  * door, a swinging chain, a sliding drawer. Its entity's transform is the joint frame, and
@@ -210,8 +215,8 @@ class JointComponentElement extends ComponentElement {
210
215
  breakImpulse: this._breakImpulse,
211
216
  enableCollision: this._enableCollision,
212
217
  enableLimits: this._enableLimits,
213
- entityA: getEntity(this._entityA),
214
- entityB: getEntity(this._entityB),
218
+ entityA: resolveEntity(this._entityA, this, 'entity-a', 'constraint not created'),
219
+ entityB: resolveEntity(this._entityB, this, 'entity-b', 'constraint not created'),
215
220
  limits: this._limits,
216
221
  linearDamping: this._linearDamping,
217
222
  linearEquilibrium: this._linearEquilibrium,
@@ -495,20 +500,24 @@ class JointComponentElement extends ComponentElement {
495
500
  }
496
501
 
497
502
  /**
498
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` providing
499
- * the first constrained body. The reference resolves when it is set, so an entity created
500
- * later is picked up by setting the attribute again.
503
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
504
+ * selector) to the element providing the first constrained body. An exact name resolves
505
+ * against the nearest enclosing entity first, then outward, then the document. The reference
506
+ * resolves when it is set, so an entity created later is picked up by setting the attribute
507
+ * again. A non-empty reference that does not resolve warns, naming which of the two causes it
508
+ * hit.
501
509
  * @param value - The first body's entity reference.
502
510
  */
503
511
  set entityA(value: string) {
504
512
  this._entityA = value;
505
513
  if (this.component) {
506
- this.component.entityA = getEntity(value);
514
+ this.component.entityA = resolveEntity(value, this, 'entity-a', 'constraint not created');
507
515
  }
508
516
  }
509
517
 
510
518
  /**
511
- * Gets the reference to the `<pc-entity>` providing the first constrained body.
519
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
520
+ * selector) to the element providing the first constrained body.
512
521
  * @returns The first body's entity reference.
513
522
  */
514
523
  get entityA() {
@@ -516,21 +525,26 @@ class JointComponentElement extends ComponentElement {
516
525
  }
517
526
 
518
527
  /**
519
- * Sets the reference (CSS selector, element id or entity name) to the `<pc-entity>` providing
520
- * the second constrained body, or empty to constrain the first body to a fixed point in world
521
- * space. The reference resolves when it is set, so an entity created later is picked up by
522
- * setting the attribute again.
528
+ * Sets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
529
+ * selector) to the element providing the second constrained body, or empty to constrain the
530
+ * first body to a fixed point in world space. An exact name resolves against the nearest
531
+ * enclosing entity first, then outward, then the document. The reference resolves when it is
532
+ * set, so an entity created later is picked up by setting the attribute again. A non-empty
533
+ * reference that does not resolve warns; an empty one is the documented world-space case and
534
+ * stays silent.
523
535
  * @param value - The second body's entity reference.
524
536
  */
525
537
  set entityB(value: string) {
526
538
  this._entityB = value;
527
539
  if (this.component) {
528
- this.component.entityB = getEntity(value);
540
+ this.component.entityB = resolveEntity(value, this, 'entity-b', 'constraint not created');
529
541
  }
530
542
  }
531
543
 
532
544
  /**
533
- * Gets the reference to the `<pc-entity>` providing the second constrained body.
545
+ * Gets the reference (a `pc-entity`, `pc-model` or `pc-node` name, or a document-wide `#`
546
+ * selector) to the element providing the second constrained body, or empty for the
547
+ * world-space case.
534
548
  * @returns The second body's entity reference.
535
549
  */
536
550
  get entityB() {
@@ -3,7 +3,9 @@ import { Color, Quat, Vec2, Vec3, Vec4 } from 'playcanvas';
3
3
 
4
4
  import { useAsset } from '../asset';
5
5
  import {
6
+ findEntityElement,
6
7
  getEntity,
8
+ idHint,
7
9
  parseBool,
8
10
  parseColor,
9
11
  parseComponents,
@@ -11,7 +13,8 @@ import {
11
13
  parseQuat,
12
14
  parseVec2,
13
15
  parseVec3,
14
- parseVec4
16
+ parseVec4,
17
+ unresolvedCause
15
18
  } from '../parse';
16
19
 
17
20
  import { ComponentElement } from './component';
@@ -107,10 +110,11 @@ const camelToKebab = (name: string): string => {
107
110
 
108
111
  /**
109
112
  * A conversion applied to a script attribute value carrying an explicit type prefix. Receives the
110
- * text after the prefix plus the raw value, and returns the raw value (having warned) when it
111
- * cannot resolve or parse it callers rely on that identity to tell failure from success.
113
+ * text after the prefix, the raw value, and the element the value is declared under — which
114
+ * scopes entity references and returns the raw value (having warned) when it cannot resolve or
115
+ * parse it — callers rely on that identity to tell failure from success.
112
116
  */
113
- type Conversion = (rest: string, raw: string) => any;
117
+ type Conversion = (rest: string, raw: string, from: Element) => any;
114
118
 
115
119
  /**
116
120
  * Resolves an `asset:` prefix to the Asset created by the `pc-asset` element with that id.
@@ -128,18 +132,25 @@ const assetConversion: Conversion = (rest, raw) => {
128
132
  };
129
133
 
130
134
  /**
131
- * Resolves an `entity:` prefix to the Entity backing a `pc-entity` element. The reference can be a
132
- * CSS selector, an element id or an entity name.
135
+ * Resolves an `entity:` prefix to the Entity backing a `pc-entity`, `pc-model` or `pc-node`
136
+ * element. The reference is a name resolved against the nearest enclosing entity first, then
137
+ * outward, then the document — or a document-wide `#` selector. The failure warning names which
138
+ * of the three causes ({@link unresolvedCause}) it hit.
133
139
  * @param rest - The entity reference.
134
140
  * @param raw - The raw value, returned unchanged when the reference does not resolve.
141
+ * @param from - The element the value is declared under, which scopes the reference.
135
142
  * @returns The entity, or `raw`.
136
143
  */
137
- const entityConversion: Conversion = (rest, raw) => {
138
- const entity = getEntity(rest);
144
+ const entityConversion: Conversion = (rest, raw, from) => {
145
+ const entity = getEntity(rest, from);
139
146
  if (entity) {
140
147
  return entity;
141
148
  }
142
- console.warn(`Unable to resolve '${raw}' in script attributes - no pc-entity found matching '${rest}'.`);
149
+ const element = findEntityElement(rest, from);
150
+ const hint = element ? '' : idHint(rest, 'entity:');
151
+ console.warn(
152
+ `Unable to resolve '${raw}' in script attributes - ${unresolvedCause(element)}.${hint ? ` ${hint}` : ''}`
153
+ );
143
154
  return raw;
144
155
  };
145
156
 
@@ -298,8 +309,10 @@ class ScriptComponentElement extends ComponentElement {
298
309
  /**
299
310
  * Recursively converts raw attribute data into proper PlayCanvas types. Supported conversions:
300
311
  * - "asset:id" → the Asset created by the `pc-asset` element with that id
301
- * - "entity:ref" → the Entity backing a `pc-entity` element. The reference can be a CSS
302
- * selector, an element id or an entity name.
312
+ * - "entity:ref" → the Entity backing a `pc-entity`, `pc-model` or `pc-node` element. The
313
+ * reference is a name, resolved against this element's nearest enclosing entity first,
314
+ * then outward, then the document — or a document-wide `#` selector (`entity:#id`). A bare
315
+ * value is always a name, never an id.
303
316
  * - "vec2:1 2" → new Vec2(1, 2)
304
317
  * - "vec3:1 2 3" → new Vec3(1, 2, 3)
305
318
  * - "vec4:1 2 3 4" → new Vec4(1, 2, 3, 4)
@@ -313,7 +326,7 @@ class ScriptComponentElement extends ComponentElement {
313
326
  private convertAttributes(item: any): any {
314
327
  if (typeof item === 'string') {
315
328
  const match = matchConversion(item);
316
- return match ? match.convert(match.rest, item) : item;
329
+ return match ? match.convert(match.rest, item, this) : item;
317
330
  }
318
331
 
319
332
  if (Array.isArray(item)) {