@solidrt/2d 0.0.66 → 0.0.68
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.
- package/AGENTS.md +246 -69
- package/README.md +33 -18
- package/examples/README.md +29 -0
- package/examples/anim.tsx +1 -1
- package/examples/atlases.tsx +151 -0
- package/examples/camera-probe.tsx +7 -7
- package/examples/camera.tsx +2 -3
- package/examples/markers.tsx +185 -0
- package/examples/parity.tsx +2 -2
- package/examples/pick.tsx +1 -1
- package/examples/split-screen.tsx +1 -1
- package/examples/springs.tsx +1 -1
- package/examples/sprites.tsx +1 -1
- package/examples/text.tsx +143 -0
- package/examples/tiles.tsx +1 -1
- package/examples/views.tsx +3 -3
- package/package.json +3 -3
- package/src/atlas.ts +75 -12
- package/src/camera2d.ts +47 -34
- package/src/collision.ts +20 -0
- package/src/components/camera2d.tsx +6 -13
- package/src/components/group.tsx +8 -11
- package/src/components/index.ts +3 -1
- package/src/components/sprite-layer.tsx +51 -14
- package/src/components/sprite.tsx +19 -9
- package/src/components/text2d.tsx +77 -0
- package/src/components/tile-layer.tsx +11 -5
- package/src/components/view2d.tsx +2 -0
- package/src/dispatch.ts +16 -4
- package/src/extrude.ts +156 -0
- package/src/font.ts +207 -0
- package/src/frames.ts +65 -55
- package/src/index.ts +37 -18
- package/src/layer.ts +273 -45
- package/src/pack.ts +70 -0
- package/src/pick.ts +25 -1
- package/src/records.ts +286 -121
- package/src/shaders.ts +224 -77
- package/src/shots.ts +23 -9
- package/src/text-layout.ts +163 -0
- package/src/text.ts +240 -0
- package/src/tiles.ts +111 -71
- package/src/views.ts +21 -13
package/src/layer.ts
CHANGED
|
@@ -13,10 +13,18 @@
|
|
|
13
13
|
// Two instance-buffer slots split ownership: slot 0 is the pose buffer,
|
|
14
14
|
// written ONLY by the core (one coalesced write per flush however many
|
|
15
15
|
// nodes moved); slot 1 is the style buffer [u0, v0, u1, v1, tint rgba,
|
|
16
|
-
// renderOrder], JS-owned and published
|
|
17
|
-
// the
|
|
18
|
-
//
|
|
19
|
-
// records layer (records.ts)
|
|
16
|
+
// renderOrder, minScreenPx, maxScreenPx, atlas], JS-owned and published
|
|
17
|
+
// through the zero-copy write lease. Never write the pose buffer from JS -
|
|
18
|
+
// the core's staging mirror is the owner and will overwrite. For motion
|
|
19
|
+
// only JS can compute at large populations, the records layer (records.ts)
|
|
20
|
+
// is the escape hatch.
|
|
21
|
+
//
|
|
22
|
+
// A layer draws from the ATLASES it declares at creation, bound together
|
|
23
|
+
// as one draw: every frame carries its texture, the style record stores
|
|
24
|
+
// that texture's index in the list, and the generated fragment stage
|
|
25
|
+
// (shaders.ts) picks the sampler per instance - so sprites from several
|
|
26
|
+
// sheets interleave freely in one draw, key order included, and a frame
|
|
27
|
+
// from a sheet the layer did not declare throws at the write.
|
|
20
28
|
//
|
|
21
29
|
// Sprites hold FIXED instance slots (freed slots recycle): draw order is
|
|
22
30
|
// slot order, so removal never shifts records and pose sinks never rebind.
|
|
@@ -25,10 +33,10 @@
|
|
|
25
33
|
// CameraUpdate in camera.ts) is a shared-params write (uCamera +
|
|
26
34
|
// uCameraRot) on that view's target, never per-sprite; pointer dispatch
|
|
27
35
|
// (dispatch.ts) undoes it with unprojectCamera.
|
|
28
|
-
import { getOwner, onCleanup } from "@solidrt/core"
|
|
36
|
+
import { getOwner, onBeforeRender, onCleanup, runWithOwner } from "@solidrt/core"
|
|
29
37
|
import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
|
|
30
|
-
import { beginBufferWrite, createBuffer, destroyBuffer, endBufferWrite } from "@solidrt/core/gpu"
|
|
31
|
-
import type { BufferId, TextureId } from "@solidrt/core/gpu"
|
|
38
|
+
import { beginBufferWrite, checkScreenSize, createBuffer, destroyBuffer, endBufferWrite, screenSizeScale } from "@solidrt/core/gpu"
|
|
39
|
+
import type { BlendMode, BufferId, TextureId } from "@solidrt/core/gpu"
|
|
32
40
|
import * as spatial from "flux:spatial"
|
|
33
41
|
import type {
|
|
34
42
|
Impact as CoreImpact,
|
|
@@ -41,10 +49,13 @@ import type {
|
|
|
41
49
|
QueryFilter,
|
|
42
50
|
} from "flux:spatial"
|
|
43
51
|
import { on } from "sol:events"
|
|
52
|
+
import { checkAtlases, frameIndex } from "./atlas.ts"
|
|
53
|
+
import type { Atlas } from "./atlas.ts"
|
|
44
54
|
import type { CameraState, CameraUpdate } from "./camera.ts"
|
|
45
55
|
import type { Frame } from "./frames.ts"
|
|
46
|
-
import {
|
|
56
|
+
import { fullFrame, isFrame, writeFrame } from "./frames.ts"
|
|
47
57
|
import type { RecordLayer } from "./records.ts"
|
|
58
|
+
import { floorReach, pointInSprite } from "./pick.ts"
|
|
48
59
|
import { createSpritePipeline, INSTANCE_LAYOUTS_SPLIT, VERTEX_SPLIT } from "./shaders.ts"
|
|
49
60
|
import { createViews } from "./views.ts"
|
|
50
61
|
import type { ViewHandle, ViewOptions } from "./views.ts"
|
|
@@ -55,12 +66,23 @@ export const POSE_FLOATS = 5
|
|
|
55
66
|
// Float offset of world y in a pose record - what `orderBy: "y"` keys on.
|
|
56
67
|
const POSE_Y_FIELD = 1
|
|
57
68
|
/** Floats per style record:
|
|
58
|
-
* [u0, v0, u1, v1, tintR, tintG, tintB, tintA, renderOrder
|
|
59
|
-
|
|
69
|
+
* [u0, v0, u1, v1, tintR, tintG, tintB, tintA, renderOrder, minScreenPx,
|
|
70
|
+
* maxScreenPx, atlas, outlineR, outlineG, outlineB, outlineWidth]. */
|
|
71
|
+
export const STYLE_FLOATS = 16
|
|
60
72
|
|
|
61
73
|
// Float offset of renderOrder in a style record - what `orderBy: "renderOrder"`
|
|
62
74
|
// keys on.
|
|
63
75
|
const STYLE_KEY_FIELD = 8
|
|
76
|
+
// Float offsets of the screen-size clamp in a style record.
|
|
77
|
+
const STYLE_MIN_PX_FIELD = 9
|
|
78
|
+
const STYLE_MAX_PX_FIELD = 10
|
|
79
|
+
// Float offset of the outline (rgb plus width in world pixels) a
|
|
80
|
+
// distance-field atlas draws under the sprite; text runs write it, a
|
|
81
|
+
// colour atlas ignores it.
|
|
82
|
+
const STYLE_OUTLINE_FIELD = 12
|
|
83
|
+
// Float offset of the atlas sampler index (the frame's texture's position
|
|
84
|
+
// in the layer's atlas list).
|
|
85
|
+
const STYLE_ATLAS_FIELD = 11
|
|
64
86
|
|
|
65
87
|
const RESOLVED = Promise.resolve()
|
|
66
88
|
|
|
@@ -183,6 +205,9 @@ export type Sprite = {
|
|
|
183
205
|
onPointerDown?: (event: SpritePointerEvent) => void
|
|
184
206
|
onPointerMove?: (event: SpritePointerEvent) => void
|
|
185
207
|
onPointerUp?: (event: SpritePointerEvent) => void
|
|
208
|
+
/** The pointer's other end: the system took it away (see core's
|
|
209
|
+
* onPointerCancel); bubbles like up, and the press it ends never taps. */
|
|
210
|
+
onPointerCancel?: (event: SpritePointerEvent) => void
|
|
186
211
|
onPointerEnter?: (event: SpritePointerEvent) => void
|
|
187
212
|
onPointerLeave?: (event: SpritePointerEvent) => void
|
|
188
213
|
/** A wheel notch over the sprite; bubbles like down/move/up. */
|
|
@@ -216,7 +241,9 @@ export type SpriteOptions = {
|
|
|
216
241
|
/** Drawn size in layer pixels. */
|
|
217
242
|
w?: number
|
|
218
243
|
h?: number
|
|
219
|
-
/** Atlas frame (
|
|
244
|
+
/** Atlas frame (a texture plus normalized UVs, from grid/namedFrames/
|
|
245
|
+
* fullFrame); default the whole first atlas of the layer. Its texture
|
|
246
|
+
* must be one of the layer's `atlases`, else the write throws. */
|
|
220
247
|
frame?: Frame
|
|
221
248
|
/** Mirror the frame horizontally / vertically about the sprite's center.
|
|
222
249
|
* A UV-side mirror: w/h stay the drawn size, a scale transition never
|
|
@@ -227,12 +254,33 @@ export type SpriteOptions = {
|
|
|
227
254
|
rotation?: number
|
|
228
255
|
/** RGBA multiplier 0..1 each; default opaque white (the texture as-is). */
|
|
229
256
|
tint?: [number, number, number, number]
|
|
257
|
+
/**
|
|
258
|
+
* The screen-size clamp's floor, view pixels (default 0: off). Whatever
|
|
259
|
+
* the camera's zoom, the sprite draws at least this big on its smaller
|
|
260
|
+
* axis, scaled uniformly so its aspect holds - a selection ring that
|
|
261
|
+
* stays findable at the overview, a map pin, a traffic dot that never
|
|
262
|
+
* falls under two device pixels. A floored sprite is world-sized until
|
|
263
|
+
* zoom shrinks it to the floor, then holds there. Applied in the vertex
|
|
264
|
+
* stage over the pose the core writes (core's screenSizeScale), so it
|
|
265
|
+
* comes after group scale and a size transition, and rotation stays
|
|
266
|
+
* world-space; `pick` honours it at the zoom it is asked for. A
|
|
267
|
+
* collapsed sprite (zero w or h) stays collapsed. Finite, >= 0.
|
|
268
|
+
*/
|
|
269
|
+
minScreenPx?: number
|
|
270
|
+
/**
|
|
271
|
+
* The clamp's ceiling, view pixels (default 0: off): the sprite draws
|
|
272
|
+
* at most this big on its smaller axis however far the camera zooms in
|
|
273
|
+
* - a label marker that must not balloon. Equal to `minScreenPx` it is
|
|
274
|
+
* a constant screen size, Three's `sizeAttenuation: false`, and `w`/`h`
|
|
275
|
+
* then carry the aspect alone. Never below a floor that is on.
|
|
276
|
+
*/
|
|
277
|
+
maxScreenPx?: number
|
|
230
278
|
/**
|
|
231
279
|
* Explicit draw-order key, read only by a layer created with `orderBy:
|
|
232
280
|
* "renderOrder"` (default 0; ties keep slot order, so untouched sprites draw
|
|
233
281
|
* as without one). The raise idiom: `setSprite(hit, { renderOrder: ++top })`
|
|
234
282
|
* on interaction, back to 0 to restore. Node layer only - a record
|
|
235
|
-
* layer's
|
|
283
|
+
* layer's 16-float record has no key field (order it by one of its own
|
|
236
284
|
* fields with `orderBy: { field }`); setting this there throws.
|
|
237
285
|
*/
|
|
238
286
|
renderOrder?: number
|
|
@@ -343,6 +391,7 @@ export type LayerPointerListener = {
|
|
|
343
391
|
onPointerDown?: (event: LayerPointerEvent) => void
|
|
344
392
|
onPointerMove?: (event: LayerPointerEvent) => void
|
|
345
393
|
onPointerUp?: (event: LayerPointerEvent) => void
|
|
394
|
+
onPointerCancel?: (event: LayerPointerEvent) => void
|
|
346
395
|
onWheel?: (event: LayerWheelEvent) => void
|
|
347
396
|
onTap?: (event: LayerTapEvent) => void
|
|
348
397
|
}
|
|
@@ -353,6 +402,7 @@ export type LayerHandlers = {
|
|
|
353
402
|
onPointerDown: (event: ElementPointerEvent) => void
|
|
354
403
|
onPointerMove: (event: ElementPointerEvent) => void
|
|
355
404
|
onPointerUp: (event: ElementPointerEvent) => void
|
|
405
|
+
onPointerCancel: (event: ElementPointerEvent) => void
|
|
356
406
|
onPointerLeave: (event: ElementPointerEvent) => void
|
|
357
407
|
onWheel: (event: ElementWheelEvent) => void
|
|
358
408
|
}
|
|
@@ -376,6 +426,14 @@ export type SpriteLayerOptions = {
|
|
|
376
426
|
* tint in every view; default opaque white (sprites as-is). See
|
|
377
427
|
* setTint. */
|
|
378
428
|
tint?: [number, number, number, number]
|
|
429
|
+
/**
|
|
430
|
+
* How the sprites blend into the layer's targets, core gpu's BlendMode;
|
|
431
|
+
* default "alpha" (premultiplied source-over in draw order). "add" for
|
|
432
|
+
* glows, explosions and additive particles, "multiply" to darken, "none"
|
|
433
|
+
* to overwrite. Pipeline state, fixed at creation: an additive layer is
|
|
434
|
+
* a second layer over the same atlas.
|
|
435
|
+
*/
|
|
436
|
+
blend?: BlendMode
|
|
379
437
|
/** Names the GPU resources (buffers, pipeline; views default to
|
|
380
438
|
* `<label>-view`); default "sprites". */
|
|
381
439
|
label?: string
|
|
@@ -447,6 +505,7 @@ export type SpriteGroup = {
|
|
|
447
505
|
onPointerDown?: (event: SpritePointerEvent) => void
|
|
448
506
|
onPointerMove?: (event: SpritePointerEvent) => void
|
|
449
507
|
onPointerUp?: (event: SpritePointerEvent) => void
|
|
508
|
+
onPointerCancel?: (event: SpritePointerEvent) => void
|
|
450
509
|
onWheel?: (event: SpriteWheelEvent) => void
|
|
451
510
|
onTap?: (event: SpriteTapEvent) => void
|
|
452
511
|
/** See Sprite.onTransitionEnd. */
|
|
@@ -483,6 +542,15 @@ export type GroupOptions = {
|
|
|
483
542
|
* and a Godot World2D only through Viewports.
|
|
484
543
|
*/
|
|
485
544
|
export type LayerBase = {
|
|
545
|
+
/**
|
|
546
|
+
* The atlases the layer draws from, as declared at creation: every
|
|
547
|
+
* frame written to the layer names one of their textures, and a
|
|
548
|
+
* record's `atlas` field is the texture's index in this list (what a
|
|
549
|
+
* raw record writer stores). Bound together as one draw per view -
|
|
550
|
+
* the multi-texture batch, so sprites from different sheets interleave
|
|
551
|
+
* in draw order.
|
|
552
|
+
*/
|
|
553
|
+
readonly atlases: readonly Atlas[]
|
|
486
554
|
/** Live sprite count. */
|
|
487
555
|
readonly count: number
|
|
488
556
|
/**
|
|
@@ -500,8 +568,11 @@ export type LayerBase = {
|
|
|
500
568
|
* Topmost means draw order (highest slot on the node layer, last added
|
|
501
569
|
* on a record layer); an `orderBy` key is not consulted (see
|
|
502
570
|
* SpriteLayerOptions.orderBy's known limitation). World space: a view
|
|
503
|
-
* undoes its camera before asking
|
|
504
|
-
|
|
571
|
+
* undoes its camera before asking, and passes its camera's `zoom` so a
|
|
572
|
+
* sprite's screen-size floor (SpriteOptions.minScreenPx) is hit at the
|
|
573
|
+
* size it draws; default 1 (the unzoomed world). The pointer walk and
|
|
574
|
+
* `view.pick` do both. */
|
|
575
|
+
pick(x: number, y: number, zoom?: number): Sprite[]
|
|
505
576
|
/**
|
|
506
577
|
* A rendering of the layer's world from a camera of its own - the main
|
|
507
578
|
* view, a minimap, a zoomed inset, one split-screen pane -
|
|
@@ -595,7 +666,8 @@ export type SpriteLayer = LayerBase & {
|
|
|
595
666
|
* Every shown sprite whose rotated rect overlaps the layer-pixel rect
|
|
596
667
|
* (the core BVH overlap query, exact for rotated sprites), unordered -
|
|
597
668
|
* the marquee query: `overlap` over an unrotated rect, sprites only.
|
|
598
|
-
* Node layer only.
|
|
669
|
+
* Node layer only. A world-space query like overlap and sweep: the
|
|
670
|
+
* sprites' world rects, never a screen-size floor (that is `pick`'s).
|
|
599
671
|
*/
|
|
600
672
|
pickRect(x: number, y: number, width: number, height: number): Sprite[]
|
|
601
673
|
/**
|
|
@@ -651,15 +723,20 @@ export type SpriteLayer = LayerBase & {
|
|
|
651
723
|
* scopes the layer's queries in the arena shared with 3d scenes. */
|
|
652
724
|
_root: NodeId
|
|
653
725
|
_destroyGroup(group: GroupState): void
|
|
726
|
+
/** Write a sprite's outline (rgb 0..1 and a width in world pixels)
|
|
727
|
+
* for a distance-field atlas to draw under it; internal, the text
|
|
728
|
+
* runs' write (text.ts). A colour atlas ignores the field. */
|
|
729
|
+
_outline(sprite: Sprite, r: number, g: number, b: number, width: number): void
|
|
654
730
|
}
|
|
655
731
|
|
|
656
|
-
/** The stored UVs at `at` un-mirrored by the sprite's flags
|
|
657
|
-
* the
|
|
658
|
-
|
|
732
|
+
/** The stored UVs at `at` un-mirrored by the sprite's flags, over the
|
|
733
|
+
* texture the record's atlas index resolved to: the frame as the caller
|
|
734
|
+
* gave it. Internal - records.ts reads through it too. */
|
|
735
|
+
export function readFrame(data: Float32Array, at: number, sprite: Sprite, texture: TextureId): Frame {
|
|
659
736
|
let u0 = data[at]!, v0 = data[at + 1]!, u1 = data[at + 2]!, v1 = data[at + 3]!
|
|
660
737
|
return sprite._flipX || sprite._flipY
|
|
661
|
-
? { u0: sprite._flipX ? u1 : u0, v0: sprite._flipY ? v1 : v0, u1: sprite._flipX ? u0 : u1, v1: sprite._flipY ? v0 : v1 }
|
|
662
|
-
: { u0, v0, u1, v1 }
|
|
738
|
+
? { texture, u0: sprite._flipX ? u1 : u0, v0: sprite._flipY ? v1 : v0, u1: sprite._flipX ? u0 : u1, v1: sprite._flipY ? v0 : v1 }
|
|
739
|
+
: { texture, u0, v0, u1, v1 }
|
|
663
740
|
}
|
|
664
741
|
|
|
665
742
|
/** Fill the shared transform scratch: xy translation, z rotation, xy scale
|
|
@@ -729,14 +806,19 @@ function writeTransform(sprite: Sprite): void {
|
|
|
729
806
|
}
|
|
730
807
|
|
|
731
808
|
/**
|
|
732
|
-
* Create a sprite layer over
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
* (
|
|
809
|
+
* Create a sprite layer over its atlases: the list of sheets (createAtlas
|
|
810
|
+
* records, or `{ texture, width, height }` literals) every frame written
|
|
811
|
+
* to it must come from, bound together as ONE draw - up to
|
|
812
|
+
* `limits.maxTextureUnits` of them (16 on every GLES 3.0 device), of any
|
|
813
|
+
* sizes and sampler states, interleaving freely in draw order. It
|
|
814
|
+
* renders nothing by itself: `layer.createView({ width, height })` (or a
|
|
815
|
+
* `<SpriteLayer>` / `<View2d>`) is where it shows, once or many times.
|
|
816
|
+
* Disposed automatically with the owning reactive scope (opt out with
|
|
817
|
+
* `{ autoFree: false }`); the atlases are NOT owned - dispose them
|
|
818
|
+
* yourself (they commonly outlive layers).
|
|
738
819
|
*/
|
|
739
|
-
export function createSpriteLayer(
|
|
820
|
+
export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions): SpriteLayer {
|
|
821
|
+
let atlasIndex = checkAtlases("createSpriteLayer", atlases)
|
|
740
822
|
let capacity = opts?.capacity ?? 1024
|
|
741
823
|
if (!(capacity > 0 && Number.isInteger(capacity))) {
|
|
742
824
|
throw new Error(`createSpriteLayer: capacity must be a positive integer, got ${capacity}`)
|
|
@@ -746,12 +828,46 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
746
828
|
checkTint("createSpriteLayer", tint)
|
|
747
829
|
let pose: BufferId = createBuffer(capacity * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
|
|
748
830
|
let style: BufferId = createBuffer(capacity * STYLE_FLOATS * 4, { label: `${label}-style`, autoFree: false })
|
|
749
|
-
let gpu = createSpritePipeline(label, VERTEX_SPLIT, INSTANCE_LAYOUTS_SPLIT)
|
|
831
|
+
let gpu = createSpritePipeline(label, VERTEX_SPLIT, INSTANCE_LAYOUTS_SPLIT, opts?.blend ?? "alpha", atlases)
|
|
832
|
+
// The sprites with a screen-size clamp (either bound on), and the
|
|
833
|
+
// furthest any of them reaches from its center at its floor (floorReach,
|
|
834
|
+
// view pixels): the candidate box a pick searches around the pointer. A
|
|
835
|
+
// write that could only widen the reach raises it in place; one that may
|
|
836
|
+
// have shrunk it (a clamped sprite leaving, its floor or aspect dropping)
|
|
837
|
+
// marks it dirty and the next pick recomputes over the set - exact, and
|
|
838
|
+
// no cost while the clamps are untouched. Empty set: the raycast fast
|
|
839
|
+
// path, and no exact test at all.
|
|
840
|
+
let clamped = new Set<SpriteState>()
|
|
841
|
+
let reach = 0
|
|
842
|
+
let reachDirty = false
|
|
843
|
+
let dropClamp = (sprite: SpriteState): void => {
|
|
844
|
+
if (clamped.delete(sprite)) reachDirty = true
|
|
845
|
+
}
|
|
846
|
+
// The pair check against the STORED bounds, before any write lands: a
|
|
847
|
+
// ceiling set alone must not drop below the floor already there.
|
|
848
|
+
let checkClamp = (verb: string, sprite: SpriteState, opts: SpriteOptions): void => {
|
|
849
|
+
if (opts.minScreenPx === undefined && opts.maxScreenPx === undefined) return
|
|
850
|
+
let at = sprite._slot * STYLE_FLOATS
|
|
851
|
+
checkScreenSize(verb, opts.minScreenPx ?? styleData[at + STYLE_MIN_PX_FIELD]!, opts.maxScreenPx ?? styleData[at + STYLE_MAX_PX_FIELD]!)
|
|
852
|
+
}
|
|
750
853
|
|
|
751
854
|
let disposed = false
|
|
752
855
|
let scheduled = false
|
|
753
856
|
let styleDirty = false
|
|
754
857
|
let published = 0
|
|
858
|
+
// The publish pass of every frame runs the pending flush ahead of the
|
|
859
|
+
// paint, so a write made anywhere in the frame's JS is in that frame's
|
|
860
|
+
// picture; the microtask stays for writes made outside a frame.
|
|
861
|
+
// Registered outside any owner: dispose unhooks it (an autoFree: false
|
|
862
|
+
// layer outlives the owner it was created in).
|
|
863
|
+
let unhook = runWithOwner(null, () =>
|
|
864
|
+
onBeforeRender(
|
|
865
|
+
() => {
|
|
866
|
+
if (scheduled) flush()
|
|
867
|
+
},
|
|
868
|
+
{ publish: true },
|
|
869
|
+
),
|
|
870
|
+
)
|
|
755
871
|
|
|
756
872
|
// Slot allocation: freed slots recycle, the high-water mark is the
|
|
757
873
|
// published instance count (a freed slot's pose zeroes - zero scale
|
|
@@ -829,7 +945,16 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
829
945
|
styleDirty = true
|
|
830
946
|
}
|
|
831
947
|
|
|
832
|
-
|
|
948
|
+
// A frame's atlas index, or a throw: run before ANY field of a write
|
|
949
|
+
// lands (pose included), so a frame from an undeclared sheet leaves the
|
|
950
|
+
// sprite as it was.
|
|
951
|
+
let frameAtlas = (verb: string, opts: SpriteOptions): number => {
|
|
952
|
+
if (opts.frame === undefined) return -1
|
|
953
|
+
if (!isFrame(opts.frame)) throw new Error(`${verb}: not a frame, got ${JSON.stringify(opts.frame)}`)
|
|
954
|
+
return frameIndex(verb, atlasIndex, opts.frame)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
let writeStyle = (sprite: SpriteState, opts: SpriteOptions, atlas: number) => {
|
|
833
958
|
let at = sprite._slot * STYLE_FLOATS
|
|
834
959
|
let flipX = opts.flipX !== undefined && opts.flipX !== sprite._flipX
|
|
835
960
|
let flipY = opts.flipY !== undefined && opts.flipY !== sprite._flipY
|
|
@@ -838,6 +963,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
838
963
|
if (opts.frame !== undefined) {
|
|
839
964
|
let f = opts.frame
|
|
840
965
|
writeFrame(styleData, at, f.u0, f.v0, f.u1, f.v1, sprite._flipX, sprite._flipY)
|
|
966
|
+
styleData[at + STYLE_ATLAS_FIELD] = atlas
|
|
841
967
|
styleDirty = true
|
|
842
968
|
} else if (flipX || flipY) {
|
|
843
969
|
// No new frame: toggle the changed axes on the stored UVs.
|
|
@@ -855,17 +981,34 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
855
981
|
styleData[at + STYLE_KEY_FIELD] = opts.renderOrder
|
|
856
982
|
styleDirty = true
|
|
857
983
|
}
|
|
984
|
+
if (opts.minScreenPx !== undefined) {
|
|
985
|
+
styleData[at + STYLE_MIN_PX_FIELD] = opts.minScreenPx
|
|
986
|
+
styleDirty = true
|
|
987
|
+
}
|
|
988
|
+
if (opts.maxScreenPx !== undefined) {
|
|
989
|
+
styleData[at + STYLE_MAX_PX_FIELD] = opts.maxScreenPx
|
|
990
|
+
styleDirty = true
|
|
991
|
+
}
|
|
992
|
+
let min = styleData[at + STYLE_MIN_PX_FIELD]!
|
|
993
|
+
if (min > 0 || styleData[at + STYLE_MAX_PX_FIELD]! > 0) {
|
|
994
|
+
clamped.add(sprite)
|
|
995
|
+
let r = floorReach(sprite._w, sprite._h, min)
|
|
996
|
+
if (r >= reach) reach = r
|
|
997
|
+
else reachDirty = true
|
|
998
|
+
} else {
|
|
999
|
+
dropClamp(sprite)
|
|
1000
|
+
}
|
|
858
1001
|
}
|
|
859
1002
|
|
|
860
1003
|
let views = createViews({
|
|
861
1004
|
label,
|
|
862
1005
|
pipeline: gpu.pipeline,
|
|
863
1006
|
quad: gpu.quad,
|
|
864
|
-
|
|
1007
|
+
atlases: atlases.map(a => a.texture),
|
|
865
1008
|
buffers: () => [pose, style],
|
|
866
1009
|
count: () => published,
|
|
867
1010
|
tint: () => tint,
|
|
868
|
-
pick: (x, y) => layer.pick(x, y),
|
|
1011
|
+
pick: (x, y, zoom) => layer.pick(x, y, zoom),
|
|
869
1012
|
// The order key one view entry declares: "y" on world y in the pose
|
|
870
1013
|
// record (slot 0), "renderOrder" on the app-owned key in the style
|
|
871
1014
|
// record (slot 1); either way the core gathers BOTH buffers under the
|
|
@@ -880,6 +1023,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
880
1023
|
})
|
|
881
1024
|
|
|
882
1025
|
let layer: SpriteLayer = {
|
|
1026
|
+
atlases,
|
|
883
1027
|
get count() {
|
|
884
1028
|
return byNode.size
|
|
885
1029
|
},
|
|
@@ -893,21 +1037,57 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
893
1037
|
if (disposed) throw new Error("createView: layer is disposed")
|
|
894
1038
|
return views.create(vopts)
|
|
895
1039
|
},
|
|
896
|
-
pick(x, y) {
|
|
1040
|
+
pick(x, y, zoom = 1) {
|
|
897
1041
|
// The index reads as of the last core flush; run any pending batch
|
|
898
1042
|
// first so a write followed by a pick sees the write.
|
|
899
1043
|
if (scheduled) flush()
|
|
1044
|
+
filter.nodes = undefined
|
|
1045
|
+
if (!(zoom > 0)) throw new Error(`pick: zoom must be positive, got ${zoom}`)
|
|
1046
|
+
let out: Sprite[] = []
|
|
900
1047
|
RAY_ORIGIN[0] = x
|
|
901
1048
|
RAY_ORIGIN[1] = y
|
|
902
1049
|
RAY_ORIGIN[2] = PICK_RAY_START
|
|
903
1050
|
RAY_DIR[0] = 0
|
|
904
1051
|
RAY_DIR[1] = 0
|
|
905
1052
|
RAY_DIR[2] = 1
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1053
|
+
if (clamped.size === 0) {
|
|
1054
|
+
for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR, filter)) {
|
|
1055
|
+
let sprite = byNode.get(hit.node)
|
|
1056
|
+
if (sprite) out.push(sprite)
|
|
1057
|
+
}
|
|
1058
|
+
} else {
|
|
1059
|
+
// A clamped sprite draws at another size than its index column:
|
|
1060
|
+
// larger under a floor, smaller under a ceiling. The candidates
|
|
1061
|
+
// are every column containing the point (the ray) plus, when a
|
|
1062
|
+
// floor is on, every column within the widest floor's reach of it
|
|
1063
|
+
// (a box overlap - a SURFACE contact in the core, so a box lying
|
|
1064
|
+
// inside a big column reports nothing, which is why the ray stays
|
|
1065
|
+
// in the union), each then tested exactly against its drawn rect:
|
|
1066
|
+
// the world pose from the node's world matrix (the in-flight pose,
|
|
1067
|
+
// group scale composed), scaled by its clamp at this zoom - what
|
|
1068
|
+
// the vertex stage draws.
|
|
1069
|
+
if (reachDirty) {
|
|
1070
|
+
reach = 0
|
|
1071
|
+
for (let s of clamped) reach = Math.max(reach, floorReach(s._w, s._h, styleData[s._slot * STYLE_FLOATS + STYLE_MIN_PX_FIELD]!))
|
|
1072
|
+
reachDirty = false
|
|
1073
|
+
}
|
|
1074
|
+
let candidates = new Set<NodeId>()
|
|
1075
|
+
for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR, filter)) candidates.add(hit.node)
|
|
1076
|
+
if (reach > 0) {
|
|
1077
|
+
let half = reach / zoom
|
|
1078
|
+
packVolume({ x: x - half, y: y - half, width: 2 * half, height: 2 * half }, "pick")
|
|
1079
|
+
for (let hit of spatial.overlap("box", BOX, filter)) candidates.add(hit.node)
|
|
1080
|
+
}
|
|
1081
|
+
for (let node of candidates) {
|
|
1082
|
+
let sprite = byNode.get(node)
|
|
1083
|
+
if (!sprite) continue
|
|
1084
|
+
spatial.worldMatrix(sprite.node!, WORLD)
|
|
1085
|
+
let w = Math.hypot(WORLD[0]!, WORLD[1]!)
|
|
1086
|
+
let h = Math.hypot(WORLD[4]!, WORLD[5]!)
|
|
1087
|
+
let at = sprite._slot * STYLE_FLOATS
|
|
1088
|
+
let scale = screenSizeScale(w, h, zoom, styleData[at + STYLE_MIN_PX_FIELD]!, styleData[at + STYLE_MAX_PX_FIELD]!)
|
|
1089
|
+
if (pointInSprite(x, y, WORLD[12]!, WORLD[13]!, w * scale, h * scale, Math.atan2(WORLD[1]!, WORLD[0]!))) out.push(sprite)
|
|
1090
|
+
}
|
|
911
1091
|
}
|
|
912
1092
|
// Topmost first: higher slot = drawn later = on top.
|
|
913
1093
|
return out.sort((a, b) => b._slot - a._slot)
|
|
@@ -989,6 +1169,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
989
1169
|
dispose() {
|
|
990
1170
|
if (disposed) return
|
|
991
1171
|
disposed = true
|
|
1172
|
+
unhook()
|
|
992
1173
|
for (let sprite of byNode.values()) {
|
|
993
1174
|
sprite.layer = null
|
|
994
1175
|
declared.delete(sprite.node!)
|
|
@@ -1001,6 +1182,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
1001
1182
|
spatial.destroyNode(group.node)
|
|
1002
1183
|
}
|
|
1003
1184
|
layer._groups.clear()
|
|
1185
|
+
clamped.clear()
|
|
1004
1186
|
// Corpses mid-exit go now: their slots must not outlive the buffer.
|
|
1005
1187
|
for (let node of leaving) {
|
|
1006
1188
|
freeing.delete(node)
|
|
@@ -1018,6 +1200,22 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
1018
1200
|
},
|
|
1019
1201
|
_add(opts) {
|
|
1020
1202
|
if (disposed) throw new Error("addSprite: layer is disposed")
|
|
1203
|
+
// The style bag with its defaults - renderOrder and the clamp at 0
|
|
1204
|
+
// explicitly, since a recycled slot holds the previous occupant's
|
|
1205
|
+
// values otherwise - resolved before a slot or node is taken, so a
|
|
1206
|
+
// bad frame allocates nothing. An absent key and an explicit
|
|
1207
|
+
// undefined both take the default (the options convention
|
|
1208
|
+
// everywhere; a plain spread would let undefined win).
|
|
1209
|
+
let style: SpriteOptions = {
|
|
1210
|
+
...opts,
|
|
1211
|
+
frame: opts?.frame ?? fullFrame(atlases[0]!),
|
|
1212
|
+
tint: opts?.tint ?? [1, 1, 1, 1],
|
|
1213
|
+
renderOrder: opts?.renderOrder ?? 0,
|
|
1214
|
+
minScreenPx: opts?.minScreenPx ?? 0,
|
|
1215
|
+
maxScreenPx: opts?.maxScreenPx ?? 0,
|
|
1216
|
+
}
|
|
1217
|
+
let atlas = frameAtlas("addSprite", style)
|
|
1218
|
+
if (opts?.parent && opts.parent.layer !== layer) throw new Error("addSprite: parent group belongs to another layer")
|
|
1021
1219
|
let slot = freeSlots.pop() ?? highWater++
|
|
1022
1220
|
if (slot >= gpuCapacity) grow(gpuCapacity * 2)
|
|
1023
1221
|
let sprite: SpriteState = {
|
|
@@ -1038,7 +1236,6 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
1038
1236
|
let node = spatial.createNode(TRANSFORM, sprite._visible)
|
|
1039
1237
|
sprite.node = node
|
|
1040
1238
|
if (opts?.parent) {
|
|
1041
|
-
if (opts.parent.layer !== layer) throw new Error("addSprite: parent group belongs to another layer")
|
|
1042
1239
|
spatial.setParent(node, opts.parent.node)
|
|
1043
1240
|
opts.parent._children.add(sprite)
|
|
1044
1241
|
} else {
|
|
@@ -1047,13 +1244,25 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
1047
1244
|
spatial.setBounds(node, COLUMN_BOUNDS)
|
|
1048
1245
|
spatial.bindPoseRecord(node, pose, slot)
|
|
1049
1246
|
byNode.set(node, sprite)
|
|
1050
|
-
|
|
1051
|
-
//
|
|
1052
|
-
|
|
1247
|
+
writeStyle(sprite, style, atlas)
|
|
1248
|
+
// No outline until a text run sets one: a recycled slot would
|
|
1249
|
+
// otherwise leak the previous occupant's.
|
|
1250
|
+
styleData.fill(0, slot * STYLE_FLOATS + STYLE_OUTLINE_FIELD, slot * STYLE_FLOATS + STYLE_OUTLINE_FIELD + 4)
|
|
1053
1251
|
layer._schedule()
|
|
1054
1252
|
return sprite
|
|
1055
1253
|
},
|
|
1254
|
+
_outline(sprite, r, g, b, width) {
|
|
1255
|
+
let at = sprite._slot * STYLE_FLOATS + STYLE_OUTLINE_FIELD
|
|
1256
|
+
styleData[at] = r
|
|
1257
|
+
styleData[at + 1] = g
|
|
1258
|
+
styleData[at + 2] = b
|
|
1259
|
+
styleData[at + 3] = width
|
|
1260
|
+
styleDirty = true
|
|
1261
|
+
layer._schedule()
|
|
1262
|
+
},
|
|
1056
1263
|
_write(sprite, opts) {
|
|
1264
|
+
checkClamp("setSprite", sprite, opts)
|
|
1265
|
+
let atlas = frameAtlas("setSprite", opts)
|
|
1057
1266
|
let moved = false
|
|
1058
1267
|
if (opts.x !== undefined && opts.x !== sprite._x) (sprite._x = opts.x), (moved = true)
|
|
1059
1268
|
if (opts.y !== undefined && opts.y !== sprite._y) (sprite._y = opts.y), (moved = true)
|
|
@@ -1071,9 +1280,12 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
1071
1280
|
opts.tint !== undefined ||
|
|
1072
1281
|
opts.flipX !== undefined ||
|
|
1073
1282
|
opts.flipY !== undefined ||
|
|
1074
|
-
opts.renderOrder !== undefined
|
|
1283
|
+
opts.renderOrder !== undefined ||
|
|
1284
|
+
opts.minScreenPx !== undefined ||
|
|
1285
|
+
opts.maxScreenPx !== undefined ||
|
|
1286
|
+
(moved && clamped.has(sprite))
|
|
1075
1287
|
) {
|
|
1076
|
-
writeStyle(sprite, opts)
|
|
1288
|
+
writeStyle(sprite, opts, atlas)
|
|
1077
1289
|
}
|
|
1078
1290
|
if (moved || styleDirty) layer._schedule()
|
|
1079
1291
|
},
|
|
@@ -1084,17 +1296,20 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
1084
1296
|
y: sprite._y,
|
|
1085
1297
|
w: sprite._w,
|
|
1086
1298
|
h: sprite._h,
|
|
1087
|
-
frame: readFrame(styleData, at, sprite),
|
|
1299
|
+
frame: readFrame(styleData, at, sprite, atlases[styleData[at + STYLE_ATLAS_FIELD]!]!.texture),
|
|
1088
1300
|
flipX: sprite._flipX,
|
|
1089
1301
|
flipY: sprite._flipY,
|
|
1090
1302
|
rotation: sprite._rot,
|
|
1091
1303
|
tint: [styleData[at + 4]!, styleData[at + 5]!, styleData[at + 6]!, styleData[at + 7]!],
|
|
1304
|
+
minScreenPx: styleData[at + STYLE_MIN_PX_FIELD]!,
|
|
1305
|
+
maxScreenPx: styleData[at + STYLE_MAX_PX_FIELD]!,
|
|
1092
1306
|
renderOrder: styleData[at + STYLE_KEY_FIELD]!,
|
|
1093
1307
|
visible: sprite._visible,
|
|
1094
1308
|
}
|
|
1095
1309
|
},
|
|
1096
1310
|
_destroy(sprite) {
|
|
1097
1311
|
sprite.layer = null
|
|
1312
|
+
dropClamp(sprite)
|
|
1098
1313
|
if (sprite._parent) {
|
|
1099
1314
|
sprite._parent._children.delete(sprite)
|
|
1100
1315
|
sprite._parent = null
|
|
@@ -1152,17 +1367,30 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
|
|
|
1152
1367
|
* notices; a scene that needs depth order sorts by world y with the
|
|
1153
1368
|
* layer's `orderBy: "y"` (core-produced, zero JS per frame). Past the
|
|
1154
1369
|
* layer's reservation both instance buffers double (pose sinks move in one
|
|
1155
|
-
* core retarget); reserve with `capacity` to avoid the copies.
|
|
1370
|
+
* core retarget); reserve with `capacity` to avoid the copies. An option
|
|
1371
|
+
* left out or passed as undefined takes its default (zero pose and size,
|
|
1372
|
+
* the first atlas whole, an opaque white tint).
|
|
1156
1373
|
*/
|
|
1157
1374
|
export function addSprite(layer: SpriteLayer | RecordLayer, opts?: AddSpriteOptions): Sprite {
|
|
1375
|
+
if (opts) checkSpriteOptions("addSprite", opts)
|
|
1158
1376
|
return layer._add(opts)
|
|
1159
1377
|
}
|
|
1160
1378
|
|
|
1161
1379
|
/** The one write path: absent keys keep their values (the params rule). */
|
|
1162
1380
|
export function setSprite(sprite: Sprite, opts: SpriteOptions): void {
|
|
1381
|
+
checkSpriteOptions("setSprite", opts)
|
|
1163
1382
|
sprite.layer?._write(sprite, opts)
|
|
1164
1383
|
}
|
|
1165
1384
|
|
|
1385
|
+
/** The validated sprite fields (throws - the dev validation policy),
|
|
1386
|
+
* checked before any state changes: a NaN tint channel or clamp bound
|
|
1387
|
+
* would otherwise blank the sprite with no error. The clamp pair is also
|
|
1388
|
+
* checked against the stored bounds by the layer's write. */
|
|
1389
|
+
function checkSpriteOptions(verb: string, opts: SpriteOptions): void {
|
|
1390
|
+
if (opts.tint !== undefined) checkTint(verb, opts.tint)
|
|
1391
|
+
if (opts.minScreenPx !== undefined || opts.maxScreenPx !== undefined) checkScreenSize(verb, opts.minScreenPx ?? 0, opts.maxScreenPx ?? 0)
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1166
1394
|
/** Read a sprite's current fields (a fresh object; mutating it does nothing). */
|
|
1167
1395
|
export function getSprite(sprite: Sprite): Required<SpriteOptions> | null {
|
|
1168
1396
|
return sprite.layer ? sprite.layer._read(sprite) : null
|
package/src/pack.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Rect packing, pure: the shelf packer (next-fit decreasing height) that
|
|
2
|
+
// places sizes on a sheet of a given width - what extrudeRects re-places a
|
|
3
|
+
// hand-packed sheet with, and what a runtime atlas packer allocates from.
|
|
4
|
+
// Simple on purpose: a sprite sheet is hundreds of rects, not millions,
|
|
5
|
+
// and shelves keep every placement's x and y a sum of the sizes placed
|
|
6
|
+
// before it, so sizes that are multiples of g land on multiples of g (the
|
|
7
|
+
// alignment extrusion relies on). No imports, so tests/pack.test.ts runs
|
|
8
|
+
// headless on the flux binary.
|
|
9
|
+
|
|
10
|
+
export type Size = { width: number; height: number }
|
|
11
|
+
export type Placement = { x: number; y: number }
|
|
12
|
+
/** A packing: the sheet size and one placement per input size, in INPUT
|
|
13
|
+
* order (the packer sorts internally and maps back). */
|
|
14
|
+
export type Packing = { width: number; height: number; rects: Placement[] }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pack `sizes` onto a sheet `width` wide: tallest first, filled left to
|
|
18
|
+
* right in shelves as tall as their first (tallest) rect, a new shelf when
|
|
19
|
+
* the current one is full. Returns the placements in input order and the
|
|
20
|
+
* height the shelves reach. Throws on a non-positive-integer size or one
|
|
21
|
+
* wider than the sheet.
|
|
22
|
+
*/
|
|
23
|
+
export function packRects(sizes: readonly Size[], width: number): Packing {
|
|
24
|
+
if (!(Number.isInteger(width) && width > 0)) throw new Error(`packRects: width must be a positive integer, got ${width}`)
|
|
25
|
+
sizes.forEach((s, i) => {
|
|
26
|
+
if (!(Number.isInteger(s.width) && Number.isInteger(s.height) && s.width > 0 && s.height > 0)) {
|
|
27
|
+
throw new Error(`packRects: sizes[${i}] must be positive integers, got ${s.width} x ${s.height}`)
|
|
28
|
+
}
|
|
29
|
+
if (s.width > width) throw new Error(`packRects: sizes[${i}] is ${s.width} wide, past the ${width} sheet`)
|
|
30
|
+
})
|
|
31
|
+
// Tallest first, widest among equals, then input order: a total order,
|
|
32
|
+
// so the packing is the same whatever the input's order of ties.
|
|
33
|
+
let order = sizes.map((_, i) => i).sort((a, b) => sizes[b]!.height - sizes[a]!.height || sizes[b]!.width - sizes[a]!.width || a - b)
|
|
34
|
+
let rects: Placement[] = new Array(sizes.length)
|
|
35
|
+
let x = 0
|
|
36
|
+
let y = 0
|
|
37
|
+
let shelf = 0
|
|
38
|
+
for (let i of order) {
|
|
39
|
+
let s = sizes[i]!
|
|
40
|
+
if (x + s.width > width) {
|
|
41
|
+
y += shelf
|
|
42
|
+
x = 0
|
|
43
|
+
shelf = 0
|
|
44
|
+
}
|
|
45
|
+
rects[i] = { x, y }
|
|
46
|
+
x += s.width
|
|
47
|
+
if (s.height > shelf) shelf = s.height
|
|
48
|
+
}
|
|
49
|
+
return { width, height: y + shelf, rects }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A sheet width for `sizes`: the smallest power of two that fits the
|
|
54
|
+
* widest size and at least the square root of the total area, so the
|
|
55
|
+
* packing comes out roughly square - the width TexturePacker-class tools
|
|
56
|
+
* default to. A power of two is a multiple of every smaller power of two,
|
|
57
|
+
* which keeps an extruded sheet's edges on mip texel edges.
|
|
58
|
+
*/
|
|
59
|
+
export function packWidth(sizes: readonly Size[]): number {
|
|
60
|
+
let widest = 0
|
|
61
|
+
let area = 0
|
|
62
|
+
for (let s of sizes) {
|
|
63
|
+
widest = Math.max(widest, s.width)
|
|
64
|
+
area += s.width * s.height
|
|
65
|
+
}
|
|
66
|
+
let side = Math.sqrt(area)
|
|
67
|
+
let width = 1
|
|
68
|
+
while (width < widest || width < side) width *= 2
|
|
69
|
+
return width
|
|
70
|
+
}
|