plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
34c3f06
— Peter John
2026-11-02T18:21:33+05:30
std/Scene: z-index, toFront/toBack, stable in-place sortChildren, and Scene2D-style culling areas
- plum-std/Scene.plum +294 -7
plum-std/Scene.plum
CHANGED
|
@@ -93,7 +93,9 @@ record Node(
|
|
|
93
93
|
tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float,
|
|
94
94
|
text: Str, size: Float,
|
|
95
95
|
acting: Bool,
|
|
96
|
-
act: fn(Node, Float)
|
|
96
|
+
act: fn(Node, Float),
|
|
97
|
+
cull: Bool, cullX: Float, cullY: Float, cullW: Float, cullH: Float,
|
|
98
|
+
scratch: List[Node]) =
|
|
97
99
|
|
|
98
100
|
fun make(kind: Int, w: Float, h: Float, c: Color) -> Node =
|
|
99
101
|
Node(
|
|
@@ -101,7 +103,8 @@ record Node(
|
|
|
101
103
|
scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
|
|
102
104
|
children: List[Node](), parent: null, kind: kind,
|
|
103
105
|
tex: -1, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0, text: "", size: 0.0,
|
|
104
|
-
acting: false, act: noAct
|
|
106
|
+
acting: false, act: noAct,
|
|
107
|
+
cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0, scratch: List[Node]())
|
|
105
108
|
|
|
106
109
|
# A node that only holds children.
|
|
107
110
|
fun group() -> Node =
|
|
@@ -169,6 +172,86 @@ record Node(
|
|
|
169
172
|
self.children.at(i).parent = null
|
|
170
173
|
self.children.clear()
|
|
171
174
|
|
|
175
|
+
# The node's index among its siblings (its draw order), -1 without a parent.
|
|
176
|
+
fun zIndex(self) -> Int =
|
|
177
|
+
if p := self.parent
|
|
178
|
+
for i := range p.children.length()
|
|
179
|
+
if p.children.at(i) == self
|
|
180
|
+
return i
|
|
181
|
+
-1
|
|
182
|
+
|
|
183
|
+
# Moves the node to index `i` of its parent's children (clamped to the valid
|
|
184
|
+
# range), shifting the siblings in between. Without a parent it does nothing.
|
|
185
|
+
fun setZIndex(self, i: Int) =
|
|
186
|
+
if p := self.parent
|
|
187
|
+
src := self.zIndex()
|
|
188
|
+
dst := i
|
|
189
|
+
if dst < 0
|
|
190
|
+
dst = 0
|
|
191
|
+
if dst > p.children.length() - 1
|
|
192
|
+
dst = p.children.length() - 1
|
|
193
|
+
d := p.children.data
|
|
194
|
+
base := p.children.start
|
|
195
|
+
while src < dst
|
|
196
|
+
d.set(base + src, d.get(base + src + 1))
|
|
197
|
+
src = src + 1
|
|
198
|
+
while src > dst
|
|
199
|
+
d.set(base + src, d.get(base + src - 1))
|
|
200
|
+
src = src - 1
|
|
201
|
+
d.set(base + dst, self)
|
|
202
|
+
|
|
203
|
+
# Draws last (on top) and is hit first.
|
|
204
|
+
fun toFront(self) =
|
|
205
|
+
if p := self.parent
|
|
206
|
+
self.setZIndex(p.children.length() - 1)
|
|
207
|
+
|
|
208
|
+
# Draws first (underneath) and is hit last.
|
|
209
|
+
fun toBack(self) =
|
|
210
|
+
self.setZIndex(0)
|
|
211
|
+
|
|
212
|
+
# Reorders the children in place so that `before(a, b)` puts a ahead of b;
|
|
213
|
+
# equal children (neither before the other) keep their order. A natural merge
|
|
214
|
+
# sort: it finds the ascending runs, then merges neighbouring runs, trimming
|
|
215
|
+
# the part of each merge that is already in place by binary search. A list that
|
|
216
|
+
# is already sorted costs n - 1 comparisons, a nearly sorted one about 2n (one
|
|
217
|
+
# scan to find the runs, one to confirm the merge) plus the distance each
|
|
218
|
+
# displaced child moves; the worst case is O(n log n). The merge buffer
|
|
219
|
+
# is the node's own `scratch` list, which keeps its capacity between calls, so
|
|
220
|
+
# sorting every frame allocates nothing once it has grown.
|
|
221
|
+
fun sortChildren(self, before: fn(Node, Node) -> Bool) =
|
|
222
|
+
n := self.children.length()
|
|
223
|
+
d := self.children.data
|
|
224
|
+
base := self.children.start
|
|
225
|
+
runs := 2
|
|
226
|
+
while runs > 1
|
|
227
|
+
runs = 0
|
|
228
|
+
i := 0
|
|
229
|
+
while i < n
|
|
230
|
+
a := sortRunEnd(d, base, i, n, before)
|
|
231
|
+
runs = runs + 1
|
|
232
|
+
if a >= n
|
|
233
|
+
i = n
|
|
234
|
+
else
|
|
235
|
+
b := sortRunEnd(d, base, a, n, before)
|
|
236
|
+
runs = runs + 1
|
|
237
|
+
sortMerge(self.scratch, d, base, i, a, b, before)
|
|
238
|
+
i = b
|
|
239
|
+
runs = {runs + 1} / 2
|
|
240
|
+
|
|
241
|
+
# Culls this node's direct children against the rectangle (x, y, w, h), in this
|
|
242
|
+
# node's own coordinates: a child whose box (position and size, scaled about its
|
|
243
|
+
# origin; rotation ignored) misses it is neither drawn nor hit, with its whole
|
|
244
|
+
# subtree. A child with no size (a plain group) is never culled.
|
|
245
|
+
fun setCullingArea(self, x: Float, y: Float, w: Float, h: Float) =
|
|
246
|
+
self.cull = true
|
|
247
|
+
self.cullX = x
|
|
248
|
+
self.cullY = y
|
|
249
|
+
self.cullW = w
|
|
250
|
+
self.cullH = h
|
|
251
|
+
|
|
252
|
+
fun clearCullingArea(self) =
|
|
253
|
+
self.cull = false
|
|
254
|
+
|
|
172
255
|
# A point in this node's coordinates, in the stage's.
|
|
173
256
|
fun localToStage(self, p: Vec2) -> Vec2 =
|
|
174
257
|
worldMatrix(self).apply(p)
|
|
@@ -180,6 +263,78 @@ record Node(
|
|
|
180
263
|
fun noAct(n: Node, dt: Float) =
|
|
181
264
|
n.x = n.x
|
|
182
265
|
|
|
266
|
+
# The end of the ascending run of `d` starting at `i` (exclusive), within n.
|
|
267
|
+
fun sortRunEnd(d: Array[Node], base: Int, i: Int, n: Int, before: fn(Node, Node) -> Bool) -> Int =
|
|
268
|
+
j := i + 1
|
|
269
|
+
while j < n && !before(d.get(base + j), d.get(base + j - 1))
|
|
270
|
+
j = j + 1
|
|
271
|
+
j
|
|
272
|
+
|
|
273
|
+
# Merges the sorted runs [lo, a) and [a, b) of `d`, stably. The left elements that
|
|
274
|
+
# already precede the first right one and the right elements that already follow
|
|
275
|
+
# the last left one are found by binary search and left alone.
|
|
276
|
+
fun sortMerge(scratch: List[Node], d: Array[Node], base: Int, lo: Int, a: Int, b: Int, before: fn(Node, Node) -> Bool) =
|
|
277
|
+
first := d.get(base + a)
|
|
278
|
+
l := lo
|
|
279
|
+
h := a
|
|
280
|
+
while l < h
|
|
281
|
+
mid := {l + h} / 2
|
|
282
|
+
if before(first, d.get(base + mid))
|
|
283
|
+
h = mid
|
|
284
|
+
else
|
|
285
|
+
l = mid + 1
|
|
286
|
+
lo2 := l
|
|
287
|
+
if lo2 >= a
|
|
288
|
+
return
|
|
289
|
+
last := d.get(base + a - 1)
|
|
290
|
+
l = a
|
|
291
|
+
h = b
|
|
292
|
+
while l < h
|
|
293
|
+
mid := {l + h} / 2
|
|
294
|
+
if before(d.get(base + mid), last)
|
|
295
|
+
l = mid + 1
|
|
296
|
+
else
|
|
297
|
+
h = mid
|
|
298
|
+
hi := l
|
|
299
|
+
scratch.clear()
|
|
300
|
+
for k := range a - lo2
|
|
301
|
+
scratch.push(d.get(base + lo2 + k))
|
|
302
|
+
ln := a - lo2
|
|
303
|
+
i := 0
|
|
304
|
+
j := a
|
|
305
|
+
o := lo2
|
|
306
|
+
while i < ln && j < hi
|
|
307
|
+
r := d.get(base + j)
|
|
308
|
+
s := scratch.at(i)
|
|
309
|
+
if before(r, s)
|
|
310
|
+
d.set(base + o, r)
|
|
311
|
+
j = j + 1
|
|
312
|
+
else
|
|
313
|
+
d.set(base + o, s)
|
|
314
|
+
i = i + 1
|
|
315
|
+
o = o + 1
|
|
316
|
+
while i < ln
|
|
317
|
+
d.set(base + o, scratch.at(i))
|
|
318
|
+
i = i + 1
|
|
319
|
+
o = o + 1
|
|
320
|
+
|
|
321
|
+
# Whether `parent` culls `c`: the child's box in the parent's coordinates lies
|
|
322
|
+
# outside the parent's culling area.
|
|
323
|
+
fun culled(parent: Node, c: Node) -> Bool =
|
|
324
|
+
if !parent.cull || c.width == 0.0 && c.height == 0.0
|
|
325
|
+
return false
|
|
326
|
+
w := c.width * c.scaleX
|
|
327
|
+
h := c.height * c.scaleY
|
|
328
|
+
x := c.x + c.originX - c.originX * c.scaleX
|
|
329
|
+
y := c.y + c.originY - c.originY * c.scaleY
|
|
330
|
+
if w < 0.0
|
|
331
|
+
x = x + w
|
|
332
|
+
w = 0.0 - w
|
|
333
|
+
if h < 0.0
|
|
334
|
+
y = y + h
|
|
335
|
+
h = 0.0 - h
|
|
336
|
+
x > parent.cullX + parent.cullW || x + w < parent.cullX || y > parent.cullY + parent.cullH || y + h < parent.cullY
|
|
337
|
+
|
|
183
338
|
# The node's transform into its parent's coordinates: translate to the position,
|
|
184
339
|
# rotate and scale about the origin.
|
|
185
340
|
fun localMatrix(n: Node) -> Affine =
|
|
@@ -211,10 +366,12 @@ fun collect(n: Node, parent: Affine, alpha: Float, out: List[Drawn]) =
|
|
|
211
366
|
a := alpha * n.color.a
|
|
212
367
|
out.push(Drawn(node: n, m: m, alpha: a))
|
|
213
368
|
for i := range n.children.length()
|
|
369
|
+
kid := n.children.at(i)
|
|
370
|
+
if !culled(n, kid)
|
|
214
|
-
|
|
371
|
+
collect(kid, m, a, out)
|
|
215
372
|
|
|
216
373
|
# What `draw` would draw, in order: a parent before its children, siblings in
|
|
217
|
-
#
|
|
374
|
+
# list order, invisible and culled subtrees left out.
|
|
218
375
|
fun flatten(root: Node) -> List[Drawn] =
|
|
219
376
|
out := List[Drawn]()
|
|
220
377
|
collect(root, Affine.Identity, 1.0, out)
|
|
@@ -243,7 +400,9 @@ fun drawNode(n: Node, parent: Affine, alpha: Float) =
|
|
|
243
400
|
p := m.apply(Vec2(x: 0.0, y: 0.0))
|
|
244
401
|
drawText(n.text, p.x, p.y, n.size * m.scaleY(), n.color.withAlpha(a))
|
|
245
402
|
for i := range n.children.length()
|
|
403
|
+
kid := n.children.at(i)
|
|
404
|
+
if !culled(n, kid)
|
|
246
|
-
|
|
405
|
+
drawNode(kid, m, a)
|
|
247
406
|
|
|
248
407
|
fun actNode(n: Node, dt: Float) =
|
|
249
408
|
if n.acting
|
|
@@ -263,8 +422,9 @@ fun hitNode(n: Node, p: Vec2) -> Node? =
|
|
|
263
422
|
q := localMatrix(n).inverse().apply(p)
|
|
264
423
|
for i := range n.children.length()
|
|
265
424
|
child := n.children.at(n.children.length() - 1 - i)
|
|
425
|
+
if !culled(n, child)
|
|
266
|
-
|
|
426
|
+
if found := hitNode(child, q)
|
|
267
|
-
|
|
427
|
+
return found
|
|
268
428
|
if n.width > 0.0 && n.height > 0.0 && q.x >= 0.0 && q.x <= n.width && q.y >= 0.0 && q.y <= n.height
|
|
269
429
|
return n
|
|
270
430
|
null
|
|
@@ -301,6 +461,18 @@ fun sceneNear(a: Float, b: Float) -> Bool =
|
|
|
301
461
|
fun sceneNearV(p: Vec2, x: Float, y: Float) -> Bool =
|
|
302
462
|
sceneNear(p.x, x) && sceneNear(p.y, y)
|
|
303
463
|
|
|
464
|
+
fun sceneKids(n: Int) -> Node =
|
|
465
|
+
g := Node.group()
|
|
466
|
+
for i := range n
|
|
467
|
+
g.add(Node.rect(1.0, 1.0, Color.Red).at(Float(i), 0.0))
|
|
468
|
+
g
|
|
469
|
+
|
|
470
|
+
fun sceneOrder(g: Node) -> Str =
|
|
471
|
+
s := ""
|
|
472
|
+
for i := range g.children.length()
|
|
473
|
+
s = "{s}{Int(g.children.at(i).x)},"
|
|
474
|
+
s
|
|
475
|
+
|
|
304
476
|
fun sceneSpin(n: Node, dt: Float) =
|
|
305
477
|
n.rotation = n.rotation + dt
|
|
306
478
|
|
|
@@ -455,3 +627,118 @@ test "sizes of sprites, circles and text"
|
|
|
455
627
|
assert s.tex == 3
|
|
456
628
|
assert s.kind == NodeKind.Sprite
|
|
457
629
|
assert Node.text("ab", 10.0, Color.White).height == 10.0
|
|
630
|
+
|
|
631
|
+
test "setZIndex, toFront and toBack reorder the siblings"
|
|
632
|
+
g := sceneKids(4)
|
|
633
|
+
a := g.children.at(0)
|
|
634
|
+
b := g.children.at(1)
|
|
635
|
+
d := g.children.at(3)
|
|
636
|
+
assert a.zIndex() == 0
|
|
637
|
+
assert d.zIndex() == 3
|
|
638
|
+
a.toFront()
|
|
639
|
+
assert sceneOrder(g) == "1,2,3,0,"
|
|
640
|
+
assert a.zIndex() == 3
|
|
641
|
+
d.toBack()
|
|
642
|
+
assert sceneOrder(g) == "3,1,2,0,"
|
|
643
|
+
b.setZIndex(2)
|
|
644
|
+
assert sceneOrder(g) == "3,2,1,0,"
|
|
645
|
+
b.setZIndex(99)
|
|
646
|
+
assert sceneOrder(g) == "3,2,0,1,"
|
|
647
|
+
b.setZIndex(-5)
|
|
648
|
+
assert sceneOrder(g) == "1,3,2,0,"
|
|
649
|
+
assert Node.group().zIndex() == -1
|
|
650
|
+
Node.group().toFront()
|
|
651
|
+
assert g.children.length() == 4
|
|
652
|
+
|
|
653
|
+
test "sortChildren is stable and keeps ties in order"
|
|
654
|
+
g := Node.group()
|
|
655
|
+
for i := range 10
|
|
656
|
+
k := Node.rect(1.0, 1.0, Color.Red).at(Float(i), Float(i * 7 % 3))
|
|
657
|
+
g.add(k)
|
|
658
|
+
g.sortChildren(|a, b| a.y < b.y)
|
|
659
|
+
assert sceneOrder(g) == "0,3,6,9,1,4,7,2,5,8,"
|
|
660
|
+
g.sortChildren(|a, b| a.y < b.y)
|
|
661
|
+
assert sceneOrder(g) == "0,3,6,9,1,4,7,2,5,8,"
|
|
662
|
+
|
|
663
|
+
test "sortChildren sorts 5000 random nodes and updates hit order"
|
|
664
|
+
g := Node.group()
|
|
665
|
+
for i := range 5000
|
|
666
|
+
g.add(Node.rect(1.0, 1.0, Color.Red).at(Float(i), Float({i * 7919 + 13} % 5003)))
|
|
667
|
+
g.sortChildren(|a, b| a.y < b.y)
|
|
668
|
+
assert g.children.length() == 5000
|
|
669
|
+
for i := range 4999
|
|
670
|
+
assert g.children.at(i).y <= g.children.at(i + 1).y
|
|
671
|
+
assert {g.children.at(10).parent ?: crash "orphan"} == g
|
|
672
|
+
|
|
673
|
+
test "sorting a nearly sorted list takes about n comparisons and reuses the scratch buffer"
|
|
674
|
+
g := Node.group()
|
|
675
|
+
for i := range 5000
|
|
676
|
+
g.add(Node.rect(1.0, 1.0, Color.Red).at(0.0, Float(i)))
|
|
677
|
+
c := List(0)
|
|
678
|
+
less: fn(Node, Node) -> Bool := |a, b|
|
|
679
|
+
_ := c.set(0, c.at(0) + 1)
|
|
680
|
+
a.y < b.y
|
|
681
|
+
g.sortChildren(less)
|
|
682
|
+
assert c.at(0) == 4999
|
|
683
|
+
g.children.at(2500).y = 2498.5
|
|
684
|
+
g.children.at(100).y = 105.5
|
|
685
|
+
_ := c.set(0, 0)
|
|
686
|
+
g.sortChildren(less)
|
|
687
|
+
assert c.at(0) < 10100
|
|
688
|
+
for i := range 4999
|
|
689
|
+
assert g.children.at(i).y <= g.children.at(i + 1).y
|
|
690
|
+
scratch := g.scratch
|
|
691
|
+
cap := scratch.cap
|
|
692
|
+
assert cap > 0
|
|
693
|
+
scratch.clear()
|
|
694
|
+
g.children.at(3000).y = 2990.5
|
|
695
|
+
g.sortChildren(less)
|
|
696
|
+
assert scratch.length() > 0
|
|
697
|
+
assert scratch.cap == cap
|
|
698
|
+
_ := c.set(0, 0)
|
|
699
|
+
g.children.clear()
|
|
700
|
+
for i := range 5000
|
|
701
|
+
g.add(Node.rect(1.0, 1.0, Color.Red).at(0.0, Float({i * 7919 + 13} % 5003)))
|
|
702
|
+
g.sortChildren(less)
|
|
703
|
+
assert c.at(0) < 130000
|
|
704
|
+
|
|
705
|
+
test "culling skips children outside the area in flatten and draw order"
|
|
706
|
+
g := Node.group()
|
|
707
|
+
inside := Node.rect(10.0, 10.0, Color.Red).at(20.0, 20.0)
|
|
708
|
+
outside := Node.rect(10.0, 10.0, Color.Red).at(500.0, 20.0)
|
|
709
|
+
g.add(inside)
|
|
710
|
+
g.add(outside)
|
|
711
|
+
outside.add(Node.rect(5.0, 5.0, Color.Red))
|
|
712
|
+
assert flatten(g).length() == 4
|
|
713
|
+
g.setCullingArea(0.0, 0.0, 100.0, 100.0)
|
|
714
|
+
list := flatten(g)
|
|
715
|
+
assert list.length() == 2
|
|
716
|
+
assert list.at(1).node == inside
|
|
717
|
+
g.setCullingArea(0.0, 100.0, 100.0, 100.0)
|
|
718
|
+
assert flatten(g).length() == 1
|
|
719
|
+
g.clearCullingArea()
|
|
720
|
+
assert flatten(g).length() == 4
|
|
721
|
+
|
|
722
|
+
test "hit does not return culled children, and a scaled child near the edge is kept"
|
|
723
|
+
stage := Stage.create()
|
|
724
|
+
k := Node.rect(10.0, 10.0, Color.Red).at(105.0, 5.0)
|
|
725
|
+
stage.root.add(k)
|
|
726
|
+
stage.root.setCullingArea(0.0, 0.0, 100.0, 100.0)
|
|
727
|
+
assert stage.hit(107.0, 7.0) == null
|
|
728
|
+
k.scaleX = 4.0
|
|
729
|
+
k.originX = 10.0
|
|
730
|
+
assert {stage.hit(90.0, 7.0) ?: crash "miss"} == k
|
|
731
|
+
assert flatten(stage.root).length() == 2
|
|
732
|
+
k.scaleX = 1.0
|
|
733
|
+
k.originX = 0.0
|
|
734
|
+
assert stage.hit(107.0, 7.0) == null
|
|
735
|
+
stage.root.clearCullingArea()
|
|
736
|
+
assert {stage.hit(107.0, 7.0) ?: crash "miss"} == k
|
|
737
|
+
|
|
738
|
+
test "a child without a size is never culled"
|
|
739
|
+
g := Node.group()
|
|
740
|
+
slot := Node.group().at(900.0, 900.0)
|
|
741
|
+
g.add(slot)
|
|
742
|
+
slot.add(Node.rect(10.0, 10.0, Color.Red).at(-890.0, -890.0))
|
|
743
|
+
g.setCullingArea(0.0, 0.0, 100.0, 100.0)
|
|
744
|
+
assert flatten(g).length() == 3
|