@operato/scene-storage 10.0.0-beta.28 → 10.0.0-beta.30

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 (54) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/asrs-crane-3d.d.ts +10 -0
  3. package/dist/asrs-crane-3d.js +17 -0
  4. package/dist/asrs-crane-3d.js.map +1 -1
  5. package/dist/asrs-crane.d.ts +49 -13
  6. package/dist/asrs-crane.js +120 -16
  7. package/dist/asrs-crane.js.map +1 -1
  8. package/dist/asrs-rack.d.ts +49 -19
  9. package/dist/asrs-rack.js +108 -20
  10. package/dist/asrs-rack.js.map +1 -1
  11. package/dist/box.d.ts +3 -3
  12. package/dist/box.js +1 -2
  13. package/dist/box.js.map +1 -1
  14. package/dist/generic-container.d.ts +2 -2
  15. package/dist/generic-container.js +1 -2
  16. package/dist/generic-container.js.map +1 -1
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/pallet.d.ts +2 -2
  21. package/dist/pallet.js +1 -2
  22. package/dist/pallet.js.map +1 -1
  23. package/dist/parcel.d.ts +3 -3
  24. package/dist/parcel.js +1 -2
  25. package/dist/parcel.js.map +1 -1
  26. package/dist/rack-cell-3d.d.ts +25 -0
  27. package/dist/rack-cell-3d.js +88 -0
  28. package/dist/rack-cell-3d.js.map +1 -0
  29. package/dist/rack-cell.d.ts +56 -0
  30. package/dist/rack-cell.js +200 -0
  31. package/dist/rack-cell.js.map +1 -0
  32. package/dist/spot.d.ts +4 -11
  33. package/dist/spot.js +2 -3
  34. package/dist/spot.js.map +1 -1
  35. package/dist/templates/index.d.ts +42 -0
  36. package/dist/templates/index.js +43 -1
  37. package/dist/templates/index.js.map +1 -1
  38. package/package.json +9 -4
  39. package/src/asrs-crane-3d.ts +20 -0
  40. package/src/asrs-crane.ts +137 -16
  41. package/src/asrs-rack.ts +119 -20
  42. package/src/box.ts +2 -4
  43. package/src/generic-container.ts +1 -3
  44. package/src/index.ts +3 -0
  45. package/src/pallet.ts +1 -3
  46. package/src/parcel.ts +2 -4
  47. package/src/rack-cell-3d.ts +101 -0
  48. package/src/rack-cell.ts +228 -0
  49. package/src/spot.ts +4 -5
  50. package/src/templates/index.ts +43 -1
  51. package/test/setup.js +279 -0
  52. package/test/test-asrs-crane.ts +319 -0
  53. package/tsconfig.json +2 -1
  54. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,228 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * RackCell — a single storage slot within an AsrsRack.
5
+ *
6
+ * A RackCell is a virtual component: it occupies a specific (bay, row, level)
7
+ * coordinate within the parent rack and acts as a CarrierHolder for one carrier
8
+ * (or several, depending on `cellType`).
9
+ *
10
+ * The crane (AsrsCrane) navigates toward a RackCell as its `place()` destination —
11
+ * because the rack cell is a discrete component, Mover.moveTo() can target it
12
+ * directly and arrive at exactly the right bay × level position.
13
+ *
14
+ * Visual: invisible in 2D (no visible 2D footprint — rack cells don't make
15
+ * sense as 2D top-down boxes). In 3D, each cell is an invisible Group
16
+ * positioned within the rack's coordinate space (RackCell3D handles
17
+ * this via updateTransform override).
18
+ *
19
+ * Lifecycle: AsrsRack._buildCells() instantiates RackCell children.
20
+ * Do not create RackCell components manually — they are managed by the rack.
21
+ *
22
+ * Domain aliases:
23
+ * cell.store(carrier) ← cell.receive(carrier)
24
+ * cell.retrieve(carrier, target) ← cell.dispatch(carrier, target)
25
+ */
26
+
27
+ import {
28
+ Component,
29
+ ComponentNature,
30
+ ContainerAbstract,
31
+ RealObject,
32
+ TRANSFER_SLOT_KEY,
33
+ sceneComponent
34
+ } from '@hatiolab/things-scene'
35
+ import { CarrierHolder, type AttachFrame } from '@operato/scene-base'
36
+
37
+ import { RackCell3D } from './rack-cell-3d.js'
38
+
39
+ /**
40
+ * How many carriers a cell can hold simultaneously.
41
+ * - single: exactly 1 (typical pallet bay)
42
+ * - multi: small stack (up to 4, e.g. a multi-deep tray)
43
+ * - bulk: unlimited (e.g. a floor area measured in slots)
44
+ */
45
+ export type RackCellType = 'single' | 'multi' | 'bulk'
46
+
47
+ const NATURE: ComponentNature = {
48
+ mutable: false,
49
+ resizable: false,
50
+ rotatable: false,
51
+ properties: [
52
+ {
53
+ type: 'string',
54
+ label: 'cell-id',
55
+ name: 'cellId',
56
+ placeholder: 'e.g. 0-0-0'
57
+ },
58
+ {
59
+ type: 'select',
60
+ label: 'cell-type',
61
+ name: 'cellType',
62
+ property: {
63
+ options: [
64
+ { display: 'Single', value: 'single' },
65
+ { display: 'Multi', value: 'multi' },
66
+ { display: 'Bulk', value: 'bulk' }
67
+ ]
68
+ }
69
+ }
70
+ ],
71
+ help: 'scene/component/rack-cell'
72
+ }
73
+
74
+ /**
75
+ * RackCell — single-slot storage cell inside an AsrsRack.
76
+ *
77
+ * Mixin chain: CarrierHolder(ContainerAbstract)
78
+ * - CarrierHolder: publishes attachPointFor(), gates containable() to Carriables
79
+ * - ContainerAbstract: manages child carrier components
80
+ *
81
+ * No Placeable mixin — RackCell3D self-positions from the parent rack's
82
+ * CellMap (via updateTransform override), bypassing things-scene's standard
83
+ * 2D→3D coordinate mapping which cannot express 3D levels.
84
+ */
85
+ @sceneComponent('rack-cell')
86
+ export default class RackCell extends CarrierHolder(ContainerAbstract) {
87
+ // ── Identification ────────────────────────────────────────────────────────
88
+
89
+ get cellId(): string {
90
+ return (this.state.cellId as string) || ''
91
+ }
92
+
93
+ get cellType(): RackCellType {
94
+ return ((this.state.cellType as RackCellType) || 'single')
95
+ }
96
+
97
+ /** Maximum carrier count for this cell based on cellType. */
98
+ get capacity(): number {
99
+ switch (this.cellType) {
100
+ case 'single': return 1
101
+ case 'multi': return 4
102
+ case 'bulk': return Infinity
103
+ }
104
+ }
105
+
106
+ // ── Interface ─────────────────────────────────────────────────────────────
107
+
108
+ get nature(): ComponentNature {
109
+ return NATURE
110
+ }
111
+
112
+ get anchors(): [] {
113
+ return []
114
+ }
115
+
116
+ // ── Transfer protocol ─────────────────────────────────────────────────────
117
+
118
+ /** True when fewer carriers are currently held than capacity. */
119
+ canReceive(_component?: any): boolean {
120
+ const occupied = ((this as any).components as Component[] | undefined)?.length ?? 0
121
+ return occupied < this.capacity
122
+ }
123
+
124
+ /**
125
+ * Accept a carrier into this cell.
126
+ * Sets TRANSFER_SLOT_KEY = cellId on the carrier, then reparents.
127
+ * Fires 'transfer-received' so monitors can react.
128
+ */
129
+ async receive(carrier: any, options: any = {}): Promise<void> {
130
+ if (!this.canReceive(carrier)) {
131
+ ;(this as any).trigger?.('transfer-rejected', {
132
+ type: 'transfer-rejected',
133
+ component: carrier,
134
+ container: this,
135
+ reason: 'no-slot'
136
+ })
137
+ return
138
+ }
139
+ carrier[TRANSFER_SLOT_KEY] = this.cellId
140
+ ;(this as any).reparent?.(carrier, options)
141
+ ;(this as any).trigger?.('transfer-received', {
142
+ type: 'transfer-received',
143
+ component: carrier,
144
+ container: this,
145
+ slotId: this.cellId
146
+ })
147
+ }
148
+
149
+ /**
150
+ * Release a carrier from this cell to `target`.
151
+ * Delegates to `target.receive()` if available, otherwise `target.reparent()`.
152
+ */
153
+ async dispatch(carrier: any, target: any, options: any = {}): Promise<void> {
154
+ if (target?.canReceive && !target.canReceive(carrier)) {
155
+ ;(this as any).trigger?.('transfer-rejected', {
156
+ type: 'transfer-rejected',
157
+ component: carrier,
158
+ container: this,
159
+ reason: 'target-full'
160
+ })
161
+ return
162
+ }
163
+ delete carrier[TRANSFER_SLOT_KEY]
164
+ if (typeof target?.receive === 'function') {
165
+ await target.receive(carrier, options)
166
+ } else {
167
+ ;(target as any).reparent?.(carrier, options)
168
+ }
169
+ ;(this as any).trigger?.('transfer-dispatched', {
170
+ type: 'transfer-dispatched',
171
+ component: carrier,
172
+ container: this,
173
+ target
174
+ })
175
+ }
176
+
177
+ // ── Domain aliases ────────────────────────────────────────────────────────
178
+
179
+ /** Alias for receive() — semantic sugar for the storage domain. */
180
+ store(carrier: any, options?: any): Promise<void> {
181
+ return this.receive(carrier, options)
182
+ }
183
+
184
+ /** Alias for dispatch() — semantic sugar for the storage domain. */
185
+ retrieve(carrier: any, target: any, options?: any): Promise<void> {
186
+ return this.dispatch(carrier, target, options)
187
+ }
188
+
189
+ // ── 3D attach frame ───────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Return the 3D attach frame for carriers placed in this cell.
193
+ * Carriers are lifted by their own halfDepth so the bottom face
194
+ * rests at the cell's Y-center (which is levelHeight/2 above the beam).
195
+ */
196
+ attachPointFor(carrier: Component): AttachFrame | null {
197
+ const root = (this as any)._realObject?.object3d
198
+ if (!root) return null
199
+ const carrierDepth = resolveCarrierDepth(carrier)
200
+ return {
201
+ attach: root,
202
+ localPosition: { x: 0, y: carrierDepth / 2, z: 0 }
203
+ }
204
+ }
205
+
206
+ // ── 2D rendering ──────────────────────────────────────────────────────────
207
+
208
+ /** RackCell has no 2D visual — the rack draws its own structure. */
209
+ render(_ctx: CanvasRenderingContext2D) {
210
+ // intentional no-op
211
+ }
212
+
213
+ // ── 3D ───────────────────────────────────────────────────────────────────
214
+
215
+ buildRealObject(): RealObject | undefined {
216
+ return new RackCell3D(this as any)
217
+ }
218
+ }
219
+
220
+ function resolveCarrierDepth(c: Component): number {
221
+ const eff = (c as any)._realObject?.effectiveDepth
222
+ if (typeof eff === 'number' && Number.isFinite(eff)) return eff
223
+ return numOr((c as any)?.state?.depth, 0)
224
+ }
225
+
226
+ function numOr(v: unknown, dflt: number): number {
227
+ return typeof v === 'number' && Number.isFinite(v) ? v : dflt
228
+ }
package/src/spot.ts CHANGED
@@ -33,6 +33,7 @@ import { Component, ComponentNature, ContainerAbstract, RealObject, sceneCompone
33
33
  import {
34
34
  CarrierHolder,
35
35
  Placeable,
36
+ type AttachFrame,
36
37
  type Alignment,
37
38
  type Heights,
38
39
  type PlacementArchetype
@@ -58,10 +59,8 @@ const NATURE: ComponentNature = {
58
59
  // `ContainerAbstract` (not `Container`) — Container = MixinHTMLElement(ContainerAbstract),
59
60
  // which forces `isHTMLElement(): true` and trips the 3D pipeline's
60
61
  // addObject DOM-skip gate. Spot is purely 3D.
61
- const Base = CarrierHolder(Placeable(ContainerAbstract)) as unknown as typeof Component
62
-
63
62
  @sceneComponent('spot')
64
- export default class Spot extends Base {
63
+ export default class Spot extends CarrierHolder(Placeable(ContainerAbstract)) {
65
64
  static placement: PlacementArchetype = 'floor'
66
65
  static align: Alignment = 'bottom'
67
66
  static defaultDepth = (_h: Heights) => 2 // a thin pad
@@ -147,10 +146,10 @@ export default class Spot extends Base {
147
146
  * falling back to raw `state.depth` for components built before
148
147
  * RealObject creation.
149
148
  */
150
- attachPointFor(carrier: Component) {
149
+ attachPointFor(carrier: Component): AttachFrame | null {
151
150
  const ro = (this as any)._realObject as Spot3D | undefined
152
151
  const frame = ro?.getAttachFrame?.()
153
- if (!frame) return undefined
152
+ if (!frame) return null
154
153
  const carrierDepth = resolveDepth(carrier)
155
154
  return {
156
155
  attach: frame,
@@ -1,6 +1,6 @@
1
1
  /*
2
2
  * things-scene catalog templates for the storage domain — pallet/box/parcel
3
- * variants, ASRS rack/crane, and the virtual `spot` placement marker.
3
+ * variants, ASRS rack/crane, ASRS aisle composite, and the virtual `spot` placement marker.
4
4
  */
5
5
  import spot from './spot.js'
6
6
  const pallet = new URL('../../icons/pallet.png', import.meta.url).href
@@ -109,5 +109,47 @@ export default [
109
109
  carriageHeight: 100
110
110
  }
111
111
  },
112
+ {
113
+ type: 'group',
114
+ description: 'AS/RS aisle — 4-level rack pair + stacker crane',
115
+ group: 'storage',
116
+ icon: asrsRack,
117
+ model: {
118
+ type: 'group',
119
+ top: 100,
120
+ left: 100,
121
+ width: 840,
122
+ height: 220,
123
+ components: [
124
+ {
125
+ type: 'asrs-rack',
126
+ top: 100,
127
+ left: 100,
128
+ width: 800,
129
+ height: 80,
130
+ levels: 4,
131
+ bays: 8
132
+ },
133
+ {
134
+ type: 'asrs-crane',
135
+ top: 140,
136
+ left: 420,
137
+ width: 60,
138
+ height: 220,
139
+ status: 'idle',
140
+ carriageHeight: 0
141
+ },
142
+ {
143
+ type: 'asrs-rack',
144
+ top: 320,
145
+ left: 100,
146
+ width: 800,
147
+ height: 80,
148
+ levels: 4,
149
+ bays: 8
150
+ }
151
+ ]
152
+ }
153
+ },
112
154
  spot
113
155
  ]
package/test/setup.js ADDED
@@ -0,0 +1,279 @@
1
+ /*
2
+ * Browser polyfills for Node.js test environment.
3
+ * Required when importing @hatiolab/things-scene bundle which uses browser globals at module scope.
4
+ */
5
+
6
+ var noop = function () {}
7
+
8
+ // Image polyfill (used at module scope in move-handle.ts)
9
+ if (typeof global.Image === 'undefined') {
10
+ global.Image = class Image {
11
+ constructor() {
12
+ this.onload = null
13
+ this.onerror = null
14
+ this.src = ''
15
+ this.width = 0
16
+ this.height = 0
17
+ }
18
+ set src(v) { this._src = v }
19
+ get src() { return this._src || '' }
20
+ addEventListener(evt, fn) {}
21
+ removeEventListener() {}
22
+ }
23
+ }
24
+
25
+ // HTMLCanvasElement polyfill
26
+ if (typeof global.HTMLCanvasElement === 'undefined') {
27
+ global.HTMLCanvasElement = class HTMLCanvasElement {
28
+ constructor() { this.width = 0; this.height = 0 }
29
+ getContext() { return null }
30
+ toDataURL() { return '' }
31
+ addEventListener() {}
32
+ removeEventListener() {}
33
+ }
34
+ }
35
+
36
+ // HTMLElement / HTMLVideoElement stubs
37
+ if (typeof global.HTMLElement === 'undefined') {
38
+ global.HTMLElement = class HTMLElement {}
39
+ }
40
+ if (typeof global.HTMLVideoElement === 'undefined') {
41
+ global.HTMLVideoElement = class HTMLVideoElement {}
42
+ }
43
+
44
+ // MutationObserver polyfill
45
+ if (typeof global.MutationObserver === 'undefined') {
46
+ global.MutationObserver = class MutationObserver {
47
+ constructor(callback) {}
48
+ observe() {}
49
+ disconnect() {}
50
+ takeRecords() { return [] }
51
+ }
52
+ }
53
+
54
+ // ResizeObserver polyfill
55
+ if (typeof global.ResizeObserver === 'undefined') {
56
+ global.ResizeObserver = class ResizeObserver {
57
+ constructor(callback) {}
58
+ observe() {}
59
+ unobserve() {}
60
+ disconnect() {}
61
+ }
62
+ }
63
+
64
+ // Mini-DOM Node implementation
65
+ function createNode(nodeType, tagName) {
66
+ var node = {
67
+ nodeType: nodeType,
68
+ tagName: tagName || '',
69
+ parentNode: null,
70
+ parentElement: null,
71
+ childNodes: [],
72
+ firstChild: null,
73
+ lastChild: null,
74
+ nextSibling: null,
75
+ previousSibling: null,
76
+ style: nodeType === 1 ? new Proxy({}, {
77
+ set: function(t,p,v){ t[p]=v; return true },
78
+ get: function(t,p){ if(p==='setProperty') return function(k,v){ t[k]=v }; return t[p] || '' }
79
+ }) : {},
80
+ textContent: '',
81
+ data: '',
82
+ setAttribute: noop,
83
+ getAttribute: function () { return null },
84
+ removeAttribute: noop,
85
+ addEventListener: noop,
86
+ removeEventListener: noop,
87
+ getContext: function() { return null },
88
+ contains: function () { return false },
89
+ querySelector: function() { return null },
90
+ querySelectorAll: function() { return [] },
91
+
92
+ appendChild: function (child) {
93
+ if (child.nodeType === 11) {
94
+ while (child.childNodes.length > 0) node.appendChild(child.childNodes[0])
95
+ return child
96
+ }
97
+ if (child.parentNode) child.parentNode.removeChild(child)
98
+ node.childNodes.push(child)
99
+ child.parentNode = node
100
+ child.parentElement = nodeType === 1 ? node : null
101
+ if (node.childNodes.length > 1) {
102
+ var prev = node.childNodes[node.childNodes.length - 2]
103
+ prev.nextSibling = child
104
+ child.previousSibling = prev
105
+ }
106
+ child.nextSibling = null
107
+ node.firstChild = node.childNodes[0]
108
+ node.lastChild = child
109
+ return child
110
+ },
111
+
112
+ removeChild: function (child) {
113
+ var idx = node.childNodes.indexOf(child)
114
+ if (idx === -1) return child
115
+ node.childNodes.splice(idx, 1)
116
+ if (child.previousSibling) child.previousSibling.nextSibling = child.nextSibling
117
+ if (child.nextSibling) child.nextSibling.previousSibling = child.previousSibling
118
+ child.parentNode = null; child.parentElement = null
119
+ child.previousSibling = null; child.nextSibling = null
120
+ node.firstChild = node.childNodes[0] || null
121
+ node.lastChild = node.childNodes[node.childNodes.length - 1] || null
122
+ return child
123
+ },
124
+
125
+ remove: function () { if (node.parentNode) node.parentNode.removeChild(node) },
126
+
127
+ insertBefore: function (newChild, refChild) {
128
+ if (!refChild) return node.appendChild(newChild)
129
+ if (newChild.nodeType === 11) {
130
+ var children = newChild.childNodes.slice()
131
+ for (var i = 0; i < children.length; i++) node.insertBefore(children[i], refChild)
132
+ return newChild
133
+ }
134
+ if (newChild.parentNode) newChild.parentNode.removeChild(newChild)
135
+ var idx = node.childNodes.indexOf(refChild)
136
+ if (idx === -1) return node.appendChild(newChild)
137
+ node.childNodes.splice(idx, 0, newChild)
138
+ newChild.parentNode = node
139
+ newChild.parentElement = nodeType === 1 ? node : null
140
+ newChild.nextSibling = refChild
141
+ newChild.previousSibling = refChild.previousSibling
142
+ if (refChild.previousSibling) refChild.previousSibling.nextSibling = newChild
143
+ refChild.previousSibling = newChild
144
+ node.firstChild = node.childNodes[0]
145
+ node.lastChild = node.childNodes[node.childNodes.length - 1]
146
+ return newChild
147
+ },
148
+
149
+ cloneNode: function (deep) {
150
+ var clone = createNode(nodeType, tagName)
151
+ clone.data = node.data; clone.textContent = node.textContent
152
+ if (deep && node.childNodes.length) {
153
+ for (var i = 0; i < node.childNodes.length; i++) clone.appendChild(node.childNodes[i].cloneNode(true))
154
+ }
155
+ return clone
156
+ },
157
+
158
+ replaceChild: function (newChild, oldChild) {
159
+ node.insertBefore(newChild, oldChild); node.removeChild(oldChild); return oldChild
160
+ }
161
+ }
162
+
163
+ if (nodeType === 1) {
164
+ node.children = []
165
+ if (tagName === 'template') {
166
+ node.content = createNode(11, '')
167
+ Object.defineProperty(node, 'innerHTML', {
168
+ get: function () { return '' },
169
+ set: function (html) { node.content = createNode(11, '') }
170
+ })
171
+ } else {
172
+ node.innerHTML = ''
173
+ }
174
+ }
175
+ return node
176
+ }
177
+
178
+ // document polyfill
179
+ if (typeof global.document === 'undefined') {
180
+ global.document = {
181
+ documentElement: {},
182
+ fonts: { ready: Promise.resolve() },
183
+ createElement: function (tagName) { return createNode(1, tagName) },
184
+ createComment: function (data) { var n = createNode(8, ''); n.data = data || ''; return n },
185
+ createTextNode: function (data) { var n = createNode(3, ''); n.data = data || ''; return n },
186
+ createDocumentFragment: function () { return createNode(11, '') },
187
+ createTreeWalker: function (root, whatToShow) {
188
+ var walker = {
189
+ currentNode: root,
190
+ nextNode: function () {
191
+ var cur = walker.currentNode
192
+ if (!cur) return null
193
+ if (cur.childNodes && cur.childNodes.length > 0) { walker.currentNode = cur.childNodes[0]; return walker.currentNode }
194
+ var node = cur
195
+ while (node && node !== root) {
196
+ if (node.nextSibling) { walker.currentNode = node.nextSibling; return walker.currentNode }
197
+ node = node.parentNode
198
+ }
199
+ return null
200
+ }
201
+ }
202
+ return walker
203
+ },
204
+ importNode: function (node, deep) { return node && node.cloneNode ? node.cloneNode(deep) : node },
205
+ adoptNode: function (node) { return node },
206
+ querySelector: function() { return null },
207
+ querySelectorAll: function() { return [] },
208
+ body: createNode(1, 'body'),
209
+ head: createNode(1, 'head')
210
+ }
211
+ }
212
+
213
+ // getComputedStyle polyfill
214
+ if (typeof global.getComputedStyle !== 'function') {
215
+ global.getComputedStyle = function () {
216
+ return { getPropertyValue: function () { return '' }, fontFamily: 'sans-serif' }
217
+ }
218
+ }
219
+
220
+ // window polyfill
221
+ if (typeof global.window === 'undefined') {
222
+ global.window = global
223
+ }
224
+
225
+ global.addEventListener = global.addEventListener || noop
226
+ global.removeEventListener = global.removeEventListener || noop
227
+
228
+ if (!global.window.location) {
229
+ global.window.location = { hostname: 'localhost', href: 'http://localhost/' }
230
+ }
231
+
232
+ // screen polyfill
233
+ if (typeof global.screen === 'undefined') {
234
+ global.screen = { width: 1280, height: 800 }
235
+ }
236
+
237
+ // requestAnimationFrame polyfill
238
+ if (typeof global.requestAnimationFrame === 'undefined') {
239
+ var lastTime = 0
240
+ global.requestAnimationFrame = function (callback) {
241
+ var currTime = Date.now()
242
+ var timeToCall = Math.max(0, 16 - (currTime - lastTime))
243
+ var id = setTimeout(function () { callback(currTime + timeToCall) }, timeToCall)
244
+ lastTime = currTime + timeToCall
245
+ return id
246
+ }
247
+ global.cancelAnimationFrame = function (id) { clearTimeout(id) }
248
+ }
249
+
250
+ // performance polyfill
251
+ if (typeof global.performance === 'undefined') {
252
+ global.performance = {}
253
+ }
254
+ if (!global.performance.now) {
255
+ var nowOffset = Date.now()
256
+ global.performance.now = function () { return Date.now() - nowOffset }
257
+ }
258
+
259
+ // WebGL stub (Three.js renderer check)
260
+ if (typeof global.WebGLRenderingContext === 'undefined') {
261
+ global.WebGLRenderingContext = class WebGLRenderingContext {}
262
+ }
263
+
264
+ // CSS polyfill
265
+ if (typeof global.CSS === 'undefined') {
266
+ global.CSS = { supports: function() { return false } }
267
+ }
268
+
269
+ // CustomEvent polyfill
270
+ if (typeof global.CustomEvent === 'undefined') {
271
+ global.CustomEvent = class CustomEvent {
272
+ constructor(type, init) { this.type = type; this.detail = (init || {}).detail }
273
+ }
274
+ }
275
+
276
+ // URL polyfill (Node.js has this but in some versions it may be missing from global)
277
+ if (typeof global.URL === 'undefined') {
278
+ global.URL = require('url').URL
279
+ }