@skinhub/viewer 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/EMBED.md +442 -0
  2. package/README.md +153 -0
  3. package/dist/SkinViewer.d.ts +18 -0
  4. package/dist/SkinViewer.d.ts.map +1 -0
  5. package/dist/SkinViewer.js +404 -0
  6. package/dist/SkinViewer.js.map +1 -0
  7. package/dist/index.d.ts +53 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +51 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/item.d.ts +118 -0
  12. package/dist/item.d.ts.map +1 -0
  13. package/dist/item.js +319 -0
  14. package/dist/item.js.map +1 -0
  15. package/dist/link.d.ts +30 -0
  16. package/dist/link.d.ts.map +1 -0
  17. package/dist/link.js +18 -0
  18. package/dist/link.js.map +1 -0
  19. package/dist/protocol.d.ts +231 -0
  20. package/dist/protocol.d.ts.map +1 -0
  21. package/dist/protocol.js +128 -0
  22. package/dist/protocol.js.map +1 -0
  23. package/dist/state.d.ts +107 -0
  24. package/dist/state.d.ts.map +1 -0
  25. package/dist/state.js +351 -0
  26. package/dist/state.js.map +1 -0
  27. package/dist/types.d.ts +573 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +106 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/useSkinViewer.d.ts +3 -0
  32. package/dist/useSkinViewer.d.ts.map +1 -0
  33. package/dist/useSkinViewer.js +66 -0
  34. package/dist/useSkinViewer.js.map +1 -0
  35. package/dist/weapons.d.ts +109 -0
  36. package/dist/weapons.d.ts.map +1 -0
  37. package/dist/weapons.js +260 -0
  38. package/dist/weapons.js.map +1 -0
  39. package/package.json +65 -0
  40. package/src/SkinViewer.tsx +465 -0
  41. package/src/index.ts +88 -0
  42. package/src/item.ts +373 -0
  43. package/src/link.ts +33 -0
  44. package/src/protocol.ts +241 -0
  45. package/src/state.ts +389 -0
  46. package/src/types.ts +672 -0
  47. package/src/useSkinViewer.ts +80 -0
  48. package/src/weapons.ts +284 -0
package/src/state.ts ADDED
@@ -0,0 +1,389 @@
1
+ /**
2
+ * *** PROPS → A URL, AND THEN PROPS → PATCHES. ***
3
+ *
4
+ * ═════════════════════════════════════════════════════════════════════════════════════════════
5
+ * *** THE TWO DOORS, AND WHY THIS PACKAGE USES BOTH RATHER THAN PICKING ONE. ***
6
+ *
7
+ * `/frame` can be driven by a query string or by `set` messages, and both go through the same
8
+ * applier on the far side. This package uses the URL for the FIRST render and messages for every
9
+ * render after it, and the split is not an optimisation - it is the only arrangement that gets both
10
+ * properties an integrator will judge us on:
11
+ *
12
+ * THE FIRST PAINT IS THEIR ITEM, not ours swapped a tick later. A frame that booted on our default
13
+ * AK and was corrected by a message would show the wrong gun for one round trip, on a product page.
14
+ *
15
+ * NO LATER PROP CHANGE TOUCHES THE `src`. *** THIS IS THE LOAD-BEARING ONE. *** Rewriting `src`
16
+ * reloads the document, which throws away the GL context, re-downloads the model and re-runs every
17
+ * shader compile - i.e. it turns a float slider into a five-second stall. So the URL is built once,
18
+ * frozen, and everything after it is a patch. See `SkinViewer.tsx`, where the same rule is enforced
19
+ * on the element.
20
+ *
21
+ * ═════════════════════════════════════════════════════════════════════════════════════════════
22
+ * *** AND THE REASON {@link diffState} IS A DIFF RATHER THAN "SEND THE WHOLE STATE EVERY TIME". ***
23
+ *
24
+ * Sending everything would be correct - the frame's merge returns the SAME OBJECT when the values it
25
+ * is handed are the values it already has, so a restated `weaponType` costs nothing and does not
26
+ * reload. It would still be wrong, for one reason: `stickers` is an ARRAY WE REBUILD ON EVERY RENDER.
27
+ * The renderer holds its sticker draft against that array BY IDENTITY, so a fresh six-slot tuple
28
+ * arriving on every tick of somebody's float slider would re-seed the draft sixty times a second and
29
+ * fight the user's own drag. The frame's own protocol file warns about exactly this.
30
+ *
31
+ * So the diff is structural: a field is in the patch only if its VALUE moved.
32
+ */
33
+
34
+ import type { FrameInteractions, FrameItem, FramePatch, FrameSettings, PlacementSlots } from './protocol.js'
35
+ import { resolveSubject } from './item.js'
36
+ import type { SkinViewerError, SkinViewerProps, ViewerGloves, ViewerView } from './types.js'
37
+
38
+ /**
39
+ * Why the frame is showing its instruction card instead of an item. See `SkinViewer.tsx` and the
40
+ * `?help=` note in `EMBED.md`.
41
+ */
42
+ export type HelpReason = 'no-item' | 'bad-link' | 'unknown-weapon'
43
+
44
+ /**
45
+ * Everything this package can tell the frame, resolved from one render's props.
46
+ *
47
+ * *** ABSENT MEANS "SAY NOTHING", NOT "USE THE DEFAULT", *** all the way down. An integrator who never
48
+ * passes `settings.quality.bloom` must not have `bloom=1` written into their URL, because then OUR
49
+ * default is frozen into THEIR embed and the day we change it their picture does not move. The
50
+ * defaults live in one place - the frame - and this file's job is to be quiet about everything the
51
+ * caller did not mention.
52
+ */
53
+ export type DesiredState = {
54
+ /** `null` when the props named no renderable item; {@link help} then says why. */
55
+ item: FrameItem | null
56
+ /** The integrator's own inspect link, forwarded verbatim as `?i=`. See `item.ts`. */
57
+ inspectPayload: string | null
58
+ help: HelpReason | null
59
+ subjectError: SkinViewerError | null
60
+ view?: ViewerView
61
+ agent?: { id?: number; pose?: string | null }
62
+ /** `undefined` says nothing; `null` is the wearer's OWN default pair, which is a real value. */
63
+ gloves?: ViewerGloves | null
64
+ settings?: FrameSettings
65
+ interactions?: FrameInteractions
66
+ editingSlot?: number
67
+ }
68
+
69
+ const HELP_FOR: Record<SkinViewerError['code'], HelpReason | null> = {
70
+ 'no-item': 'no-item',
71
+ 'bad-inspect-link': 'bad-link',
72
+ 'unknown-weapon': 'unknown-weapon',
73
+ 'render-failed': null,
74
+ 'bad-message': null,
75
+ 'protocol-mismatch': null,
76
+ // There is no help card for an embed that never loaded: the card is drawn BY the frame, and the
77
+ // frame is the thing that did not arrive.
78
+ unreachable: null,
79
+ }
80
+
81
+ /**
82
+ * One render's props as {@link DesiredState}. Pure, synchronous, no React - so it can run in a
83
+ * `useState` initialiser during SSR as well as on every subsequent render.
84
+ *
85
+ * *** IT TAKES A `Partial<>` OF ITS OWN PROP TYPE ON PURPOSE. *** The types make a missing item a
86
+ * compile error; this function is what happens when the types were bypassed - plain JavaScript, an
87
+ * `any`, a query that had not resolved - and its whole value is that it has an answer for that case
88
+ * rather than a `TypeError`.
89
+ */
90
+ export const resolveState = (props: Partial<SkinViewerProps>): DesiredState => {
91
+ const subject = resolveSubject(props)
92
+ const settings = toFrameSettings(props.settings)
93
+
94
+ return {
95
+ item: subject.item,
96
+ inspectPayload: subject.inspectPayload,
97
+ help: subject.error ? HELP_FOR[subject.error.code] : null,
98
+ subjectError: subject.error,
99
+ ...(props.view !== undefined && { view: props.view }),
100
+ ...(props.agent !== undefined && { agent: props.agent }),
101
+ ...(props.gloves !== undefined && { gloves: props.gloves }),
102
+ ...(settings !== undefined && { settings }),
103
+ ...(props.interactions !== undefined && { interactions: { ...props.interactions } }),
104
+ ...(props.editingSlot !== undefined && { editingSlot: props.editingSlot }),
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The public settings as the wire's.
110
+ *
111
+ * A STRUCTURAL COPY RATHER THAN A PASS-THROUGH, so the object that goes on the wire is one this
112
+ * package built: a caller who mutates the object they passed us cannot change what a later diff
113
+ * compares against, and a field we do not know about cannot ride along into a `postMessage` where the
114
+ * frame would name it in `problems`.
115
+ */
116
+ const toFrameSettings = (settings: SkinViewerProps['settings']): FrameSettings | undefined => {
117
+ if (!settings) return undefined
118
+ const out: FrameSettings = {}
119
+ if (settings.camera) out.camera = { ...settings.camera }
120
+ if (settings.quality) out.quality = { ...settings.quality }
121
+ if (settings.environment) out.environment = { ...settings.environment }
122
+ if (settings.overlays)
123
+ out.overlays = {
124
+ ...settings.overlays,
125
+ ...(settings.overlays.gizmoStyle && { gizmoStyle: { ...settings.overlays.gizmoStyle } }),
126
+ }
127
+ return out
128
+ }
129
+
130
+ /* ═════════════════════════════════════════════════════════════════════════════════════════════
131
+ * THE URL
132
+ * ═══════════════════════════════════════════════════════════════════════════════════════════ */
133
+
134
+ /** `EMBED.md` §8: `0` is off, anything else present is on, absent leaves the field alone. */
135
+ const flag = (params: URLSearchParams, key: string, value: boolean | undefined) => {
136
+ if (value !== undefined) params.set(key, value ? '1' : '0')
137
+ }
138
+
139
+ const num = (params: URLSearchParams, key: string, value: number | undefined) => {
140
+ if (value !== undefined) params.set(key, String(value))
141
+ }
142
+
143
+ /**
144
+ * The initial `<iframe src>`, and the state that URL actually expressed.
145
+ *
146
+ * *** THE SECOND RETURN VALUE IS NOT BOOKKEEPING - IT IS THE BASELINE EVERY LATER PATCH IS MEASURED
147
+ * AGAINST. *** One field is deliberately NOT encodable: the stickers, which the query string can only
148
+ * carry inside an `?i=` payload. Rather than re-encode a placement into an inspect link here - a
149
+ * second implementation of a codec whose only job would be to disagree with the frame's decoder - the
150
+ * stickers are simply left out of the URL and reported as unsent, so the first diff after the frame
151
+ * announces itself carries them. They are a CHEAP field, so they arrive without a loading card, and
152
+ * in practice they arrive long before the model has finished downloading.
153
+ *
154
+ * When the integrator gave us an inspect link there is nothing to encode: their own payload goes
155
+ * through as `?i=` and carries the stickers with it.
156
+ */
157
+ export const frameUrl = (origin: string, desired: DesiredState): { src: string; expressed: DesiredState } => {
158
+ const params = new URLSearchParams()
159
+ const item = desired.item
160
+
161
+ if (desired.help) params.set('help', desired.help)
162
+
163
+ if (item) {
164
+ params.set('weapon', item.weaponType)
165
+ params.set('paint', String(item.paintIndex))
166
+ if (item.legacyModel !== undefined) flag(params, 'legacy', item.legacyModel)
167
+
168
+ if (desired.inspectPayload) params.set('i', desired.inspectPayload)
169
+
170
+ /*
171
+ * WRITTEN AFTER `?i=` AND THAT ORDER IS THE CONTRACT: a plain `float=`/`seed=`/`st=`/`nametag=`
172
+ * beats the same field inside `?i=`, because the explicit one is the more specific statement.
173
+ * Here they carry the same values anyway - both came out of the same decode - so the ordering
174
+ * only matters for a host that passes both, and it matters that we agree with the documented rule
175
+ * rather than relying on them not to.
176
+ */
177
+ num(params, 'float', item.float)
178
+ num(params, 'seed', item.seed)
179
+ // `st=0` is a real, freshly-minted counter; `st=-1` removes the module; absent means no module.
180
+ if (item.statTrak !== undefined) params.set('st', item.statTrak === false ? '-1' : String(item.statTrak))
181
+ if (item.nameTag !== undefined) params.set('nametag', item.nameTag ?? '')
182
+ }
183
+
184
+ if (desired.view) params.set('view', desired.view)
185
+ if (desired.agent?.id !== undefined) params.set('agent', String(desired.agent.id))
186
+ if (desired.agent?.pose) params.set('pose', desired.agent.pose)
187
+ if (desired.gloves !== undefined) params.set('glove', desired.gloves ? gloveParam(desired.gloves) : 'none')
188
+
189
+ const s = desired.settings
190
+ num(params, 'fov', s?.camera?.fov)
191
+ num(params, 'zoom', s?.camera?.defaultZoom)
192
+ num(params, 'bloom', s?.quality?.bloom)
193
+ num(params, 'spill', s?.quality?.bloomSpill)
194
+ num(params, 'scale', s?.quality?.renderScale)
195
+ flag(params, 'aa', s?.quality?.antialias)
196
+ flag(params, 'shadows', s?.quality?.shadows)
197
+ // `?map=none` is the calibrated reference rig, which is what `map: null` means on the prop.
198
+ if (s?.environment?.map !== undefined) params.set('map', s.environment.map ?? 'none')
199
+ if (s?.environment?.timeOfDay) params.set('time', s.environment.timeOfDay)
200
+ flag(params, 'rain', s?.environment?.rain)
201
+ if (s?.environment?.background) params.set('bg', s.environment.background)
202
+ flag(params, 'stickergizmo', s?.overlays?.stickerGizmo)
203
+ flag(params, 'charmgizmo', s?.overlays?.charmGizmo)
204
+ if (s?.overlays?.gizmoStyle?.color) params.set('gizmocolor', s.overlays.gizmoStyle.color)
205
+ if (s?.overlays?.gizmoStyle?.shadowColor) params.set('gizmoshadow', s.overlays.gizmoStyle.shadowColor)
206
+
207
+ flag(params, 'orbit', desired.interactions?.orbit)
208
+ // The wheel gesture is named after the input that performs it, because `?zoom=` is the camera's.
209
+ flag(params, 'wheel', desired.interactions?.zoom)
210
+ flag(params, 'dragstickers', desired.interactions?.dragStickers)
211
+ flag(params, 'dragcharm', desired.interactions?.dragCharm)
212
+ if (desired.editingSlot !== undefined) params.set('slot', String(desired.editingSlot))
213
+
214
+ const expressed: DesiredState =
215
+ item && !desired.inspectPayload && item.stickers ? { ...desired, item: { ...item, stickers: undefined } } : desired
216
+
217
+ return { src: `${origin.replace(/\/+$/, '')}/frame?${params.toString()}`, expressed }
218
+ }
219
+
220
+ /** `type:paintIndex[:float[:seed]]` - one param and not four, because they are one item. */
221
+ const gloveParam = (gloves: ViewerGloves) =>
222
+ [gloves.type, gloves.paintIndex, gloves.float, gloves.seed].filter(part => part !== undefined).join(':')
223
+
224
+ /* ═════════════════════════════════════════════════════════════════════════════════════════════
225
+ * THE DIFF
226
+ * ═══════════════════════════════════════════════════════════════════════════════════════════ */
227
+
228
+ /**
229
+ * *** WHICH FIELDS RELOAD THE FRAME. *** `EMBED.md`'s cheap-vs-reload table, as a value.
230
+ *
231
+ * The component reads this to decide when to raise a caller's `loading` slot, so the slot is raised
232
+ * on exactly the changes that actually cover the canvas rather than on a guess. `view` and the agent
233
+ * are the same class but are not fields of the item, so they are handled beside it.
234
+ */
235
+ export const IDENTITY_FIELDS = [
236
+ 'weaponType',
237
+ 'paintIndex',
238
+ 'legacyModel',
239
+ ] as const satisfies readonly (keyof FrameItem)[]
240
+
241
+ const shallowEqual = (a: Record<string, unknown> | undefined, b: Record<string, unknown> | undefined): boolean => {
242
+ if (a === b) return true
243
+ if (!a || !b) return false
244
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)])
245
+ for (const key of keys) if (!Object.is(a[key], b[key])) return false
246
+ return true
247
+ }
248
+
249
+ /** The six slots, field by field. The only deep comparison in this package, and it is fixed-size. */
250
+ const slotsEqual = (a: PlacementSlots | undefined, b: PlacementSlots | undefined): boolean => {
251
+ if (a === b) return true
252
+ if (!a || !b) return false
253
+ for (let i = 0; i < 6; i++)
254
+ if (!shallowEqual(a[i] as unknown as Record<string, unknown>, b[i] as unknown as Record<string, unknown>))
255
+ return false
256
+ return true
257
+ }
258
+
259
+ /** `undefined` when nothing moved, which is what lets the component skip the `postMessage` entirely. */
260
+ export const diffState = (previous: DesiredState, next: DesiredState): FramePatch | undefined => {
261
+ const patch: FramePatch = {}
262
+ let changed = false
263
+
264
+ /* ── THE ITEM ─────────────────────────────────────────────────────────────────────────────
265
+ *
266
+ * *** AN ITEM THAT DISAPPEARS DOES NOT BLANK THE VIEWER. *** `next.item === null` means this render
267
+ * had no renderable item - a host whose query briefly returned `undefined`, which in a real app is
268
+ * a re-render blip rather than an instruction. The picture is left alone and the component reports
269
+ * `no-item` through `onError`; there is no way to say "show nothing" over the wire and inventing one
270
+ * would make a transient state destructive.
271
+ */
272
+ if (next.item) {
273
+ const item: Partial<FrameItem> = {}
274
+ const before = previous.item
275
+ if (!before) {
276
+ Object.assign(item, next.item)
277
+ changed = true
278
+ } else {
279
+ for (const key of ['weaponType', 'paintIndex', 'legacyModel', 'float', 'seed', 'statTrak', 'nameTag'] as const)
280
+ if (!Object.is(before[key], next.item[key])) {
281
+ // `as never` narrows the union of value types down to the one this key holds; the loop is
282
+ // over a literal tuple so the pairing is checked, but TypeScript cannot see it per key.
283
+ item[key] = next.item[key] as never
284
+ changed = true
285
+ }
286
+ if (!slotsEqual(before.stickers, next.item.stickers) && next.item.stickers) {
287
+ item.stickers = next.item.stickers
288
+ changed = true
289
+ }
290
+ }
291
+ if (Object.keys(item).length > 0) patch.item = item
292
+ }
293
+
294
+ if (next.view !== undefined && next.view !== previous.view) {
295
+ patch.view = next.view
296
+ changed = true
297
+ }
298
+
299
+ if (next.agent && !shallowEqual(previous.agent, next.agent)) {
300
+ patch.agent = next.agent
301
+ changed = true
302
+ }
303
+
304
+ /*
305
+ * GLOVES ARE THE ONE FIELD SENT WHOLESALE. `null` there is not an absence - it is "the wearer's own
306
+ * default pair" - and a partial glove patch that left the pair without a `type` is a state the
307
+ * renderer cannot resolve, so the frame rejects it. Sending the whole pair keeps that impossible.
308
+ */
309
+ if (next.gloves !== undefined && !glovesEqual(previous.gloves, next.gloves)) {
310
+ patch.gloves = next.gloves ?? null
311
+ changed = true
312
+ }
313
+
314
+ const settings = diffSettings(previous.settings, next.settings)
315
+ if (settings) {
316
+ patch.settings = settings
317
+ changed = true
318
+ }
319
+
320
+ if (next.interactions && !shallowEqual(previous.interactions, next.interactions)) {
321
+ patch.interactions = next.interactions
322
+ changed = true
323
+ }
324
+
325
+ if (next.editingSlot !== undefined && next.editingSlot !== previous.editingSlot) {
326
+ patch.editingSlot = next.editingSlot
327
+ changed = true
328
+ }
329
+
330
+ return changed ? patch : undefined
331
+ }
332
+
333
+ const glovesEqual = (a: ViewerGloves | null | undefined, b: ViewerGloves | null | undefined) => {
334
+ if (a === b) return true
335
+ if (!a || !b) return false
336
+ return a.type === b.type && a.paintIndex === b.paintIndex && a.float === b.float && a.seed === b.seed
337
+ }
338
+
339
+ /**
340
+ * Per group, per field - which is the same rule the frame merges by, one level in.
341
+ *
342
+ * A GROUP THE CALLER DID NOT MENTION THIS RENDER IS NOT DIFFED AWAY. `settings={{ quality: {…} }}`
343
+ * after `settings={{ quality: {…}, camera: {…} }}` does not reset the camera: there is no way to say
344
+ * "unset" over the wire, and the alternative reading - that dropping a key means restore the default -
345
+ * would make a conditional `settings` object destructive in a way React developers do not expect.
346
+ */
347
+ const diffSettings = (
348
+ previous: FrameSettings | undefined,
349
+ next: FrameSettings | undefined,
350
+ ): FrameSettings | undefined => {
351
+ if (!next) return undefined
352
+ const out: FrameSettings = {}
353
+ let changed = false
354
+
355
+ if (next.camera && !shallowEqual(previous?.camera, next.camera)) {
356
+ out.camera = next.camera
357
+ changed = true
358
+ }
359
+ if (next.quality && !shallowEqual(previous?.quality, next.quality)) {
360
+ out.quality = next.quality
361
+ changed = true
362
+ }
363
+ if (next.environment && !shallowEqual(previous?.environment, next.environment)) {
364
+ out.environment = next.environment
365
+ changed = true
366
+ }
367
+ if (next.overlays && !overlaysEqual(previous?.overlays, next.overlays)) {
368
+ out.overlays = next.overlays
369
+ changed = true
370
+ }
371
+
372
+ return changed ? out : undefined
373
+ }
374
+
375
+ const overlaysEqual = (a: FrameSettings['overlays'], b: FrameSettings['overlays']) => {
376
+ if (a === b) return true
377
+ if (!a || !b) return false
378
+ return a.stickerGizmo === b.stickerGizmo && a.charmGizmo === b.charmGizmo && shallowEqual(a.gizmoStyle, b.gizmoStyle)
379
+ }
380
+
381
+ /** True when a patch would cover the canvas - see {@link IDENTITY_FIELDS}. */
382
+ export const coversCanvas = (patch: FramePatch, view: ViewerView | undefined): boolean => {
383
+ if (patch.view !== undefined) return true
384
+ if (patch.item && IDENTITY_FIELDS.some(field => patch.item?.[field] !== undefined)) return true
385
+ // The operator is identity in the `agent` view, where its `<Suspense>` tears the subtree down, and
386
+ // cheap in `hands`, where the arms are already mounted. The asymmetry is the renderer's, not ours.
387
+ if (patch.agent?.id !== undefined && view === 'agent') return true
388
+ return false
389
+ }