This repository was archived by the owner on Aug 14, 2026. It is now read-only.
forked from TeJota1337/DramaticShapeVoxelMod
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathStructures.lua
More file actions
3776 lines (3595 loc) · 155 KB
/
Copy pathStructures.lua
File metadata and controls
3776 lines (3595 loc) · 155 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- Voxel world mode: detect the map's structures and pick a 3D model for
-- each -- the 3dSen idea applied to a tile map. 3dSen turns flat NES
-- scenes into 3D by classifying every graphic into a geometry archetype
-- (floor, wall, box, voxelized sprite) and building real geometry that
-- keeps the original art as its texture; this module does the same with
-- the map's tile layer as the scene description:
--
-- 1. Flood-fill every connected region of solid (upright, unauthored)
-- tiles -- a house with its mailbox, the potted plant, a fence row,
-- a stretch of border forest.
--
-- 2. Decide which pixels of the region's art are BACKGROUND. Tileset
-- art carries no alpha and white is a paint color (window frames,
-- wall stripes), so whiteness alone says nothing. The map does: the
-- background is the white that CONNECTS TO WALKABLE GROUND in the
-- assembled scene. Seeding a flood from the surrounding ground
-- eats the air around a fence post or a plant's leaves but cannot
-- reach an interior wall's white stripes sealed behind its dark
-- trim -- exactly the distinction a human reads.
--
-- 3. Tiles whose art turned out mostly background are SPRITE-LIKE;
-- their connected clusters become per-pixel voxel OBJECTS at the
-- art's real drawn height (a 2-row plant is a 16px silhouette, a
-- fence a row of true posts with air between), thin voxel depth,
-- standing on synthesized ground. This splits mixed regions: the
-- mailbox voxelizes even where it touches the house.
--
-- 4. Everything else becomes a VOLUME: each column rises to the height
-- the structure is actually DRAWN. A column's run gives its extent,
-- repetition caps it -- the border forest repeats a 2-row canopy
-- for forty rows and must be rows of 16px trees, not a monolith --
-- and columns answer to their region: the column above a doorway
-- repeats internally but adopts its 48px house. The south face
-- folds the artwork up (ChunkMesher's band rule).
--
-- data/voxel_heights.lua is the PROFILE over this: a tile authored there
-- (ledges, or a mod pinning a shape) bypasses detection entirely, the way
-- a 3dSen game profile pins a pattern to a geometry type.
--
-- Everything here is derived per map and cached; pixel access (object
-- voxelization, void detection) degrades gracefully headless -- regions
-- simply stay volumes and the geometry tests keep passing.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Assets = require("src.render.Assets")
local Map = require("src.world.Map")
local Buildings = V.require("Buildings")
local TileShape = V.require("TileShape")
local Budget = V.require("BuildBudget")
local ImageCache = V.require("ImageCache")
local Structures = {}
-- must match ChunkMesher's ring (3 border blocks, in tiles)
local RING = 12
-- how far past the map body cells still get the hull. A route's ring is
-- nearly as big as its body; modelling all of it costs hundreds of
-- thousands of quads of border trees nobody walks near. Beyond this,
-- pinned cells simply are not claimed and fall through to the mesher's
-- plain box -- cheap distant scenery. (Declared up here rather than
-- beside buildCylinders because forMap's grid resolve reads it too.)
local ROUND_RING = 4
-- object-mode gates
local OBJECT_MAX_ROWS = 6 -- a prop is at most 48px of drawing
local OBJECT_MAX_QUADS = 4096 -- safety cap per cluster
local TILE_BG_RATIO = 0.20 -- art background for "sprite-like"
local CLUSTER_MIN_BG = 0.05 -- a silhouette must actually exist
local OBJECT_DEPTH = 6 -- voxel thickness of a detected prop
-- thickness of profile-pinned standees per class: a TV is a deliberate
-- object and reads better with body; `prop` doubles as the THIN pool
-- (plants, stools -- mostly silhouette); `cutout` is paper: one voxel,
-- pure profile; `post` matches the 6px the detector gives the fence
-- rows it finds on its own, so pinned and detected fences look alike;
-- `signpost` is a plate on a stick -- 2 voxels, the thinnest that still
-- shows an edge; `bike` is the same 2 for the same reason from the other
-- direction -- a bicycle drawn side-on is a LINE drawing whose negative
-- space is the drawing, and at the 5 voxels `prop` gives, the side faces
-- of neighbouring strokes close every gap in it off-axis
local PINNED_DEPTH = { billboard = 10, prop = 5, stool = 10, cutout = 1,
console = 10, post = 6, signpost = 2, bike = 2 }
local MAX_ROWS = 6 -- volume height cap: 48px
local cache = {}
-- ---------------------------------------------------------------- pixels --
local atlasData = {}
local function pixels(tileset)
local path = tileset.image
if atlasData[path] == nil then
local ok, data = pcall(ImageCache.get, path)
atlasData[path] = (ok and data and data.getPixel) and data or false
end
return atlasData[path] or nil
end
-- tiles whose art is entirely black or transparent (interior darkness):
-- these never extrude, whatever class they resolved to
local function voidTiles(tileset)
local data = pixels(tileset)
if not data then return nil end
local perRow = tileset.tilesPerRow or 16
local iw, ih = data:getDimensions()
local set = {}
for t = 0, (iw / 8) * (ih / 8) - 1 do
local ox = (t % perRow) * 8
local oy = math.floor(t / perRow) * 8
local void = true
for py = 0, 7 do
for px = 0, 7 do
local r, g, b, a = data:getPixel(ox + px, oy + py)
if a > 0 and math.max(r, g, b) > 0.17 then
void = false
break
end
end
if not void then break end
end
if void then set[t] = true end
end
return set
end
-- ----------------------------------------------------------------- build --
local DIRS4 = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
local function keyOf(tx, ty)
return (ty + 64) * 4096 + (tx + 64)
end
function Structures.forMap(map)
local S = cache[map.id]
if S then return S end
local tileset = map.tileset
local shapes = TileShape.forMap(map)
local void = voidTiles(tileset)
local perRow = tileset.tilesPerRow or 16
local def = map.def
local tw, th = def.width * 4, def.height * 4
local x0, x1 = -RING, tw + RING - 1
local y0, y1 = -RING, th + RING - 1
-- resolve the whole grid once: shape + tile per key. Ring positions use
-- the same border override the 2D renderer draws with
-- (TileRenderer.borderBlockFor: outdoor maps ring with the solid tree
-- wall, NOT their own borderBlock) -- a route's borderBlock is the GRASS
-- block, and meshing that grew a 12-tile apron of tall grass past every
-- route edge, which leaked into the neighbouring town's plaza.
-- BLACK void fill is not a block at all: borderBlockFor answers `false`,
-- and there is simply nothing out there to build. tileLookup then returns
-- nil past the body and the ring keys are never written, which the whole
-- file already copes with -- every neighbour query reaches one step
-- outside the analysed range and reads nil for its trouble, so an absent
-- cell is the shape "nothing" has always had here. (It used to add 1 to
-- that `false`, which threw, failed the mesh build for every map on the
-- route, and dropped the mode to the flat 2D path entirely.)
local TileRenderer = require("src.render.TileRenderer")
local borderId = TileRenderer.borderBlockFor(map)
local borderBlk = borderId and tileset.blocks[borderId + 1] or nil
-- TREES fill stops at ROUND_RING instead of running the full RING.
-- Only that far out does a tree cell get carved into a hull; past it
-- the cells fall through to the mesher's plain box, and a slab of
-- flat-topped boxes beside the modelled wall reads as a painted-on
-- plateau -- the wall looking like it was cut off with scissors. So
-- the far ring is simply not built: beyond ROUND_RING tileLookup
-- answers nil, which is the same "nothing out there" BLACK already
-- produces and every pass below already copes with. The cut lands on
-- the carve boundary exactly -- the 2x2-cell canopy scan starts at
-- floor(-RING/2) and RING, ROUND_RING and the body are all multiples
-- of 4 tiles, so no group is left half-resolved at the edge.
--
-- WATER and the other tilesets' own borders keep the full ring: a flat
-- sheet of water is what water looks like from above anyway, and an
-- interior's border is black already.
local hullRingOnly = borderBlk and def.tileset == "OVERWORLD"
and (TileRenderer.voidFill or "trees") == "trees"
local tw2, th2 = tw, th
local function tileLookup(tx, ty)
if tx >= 0 and ty >= 0 and tx < tw2 and ty < th2 then
return map:tileAt(tx, ty)
end
if not borderBlk then return nil end
if hullRingOnly and (tx < -ROUND_RING or ty < -ROUND_RING
or tx >= tw2 + ROUND_RING
or ty >= th2 + ROUND_RING) then
return nil
end
return borderBlk[(ty % 4) * 4 + (tx % 4) + 1] or 0
end
local shapeAt, tileAt = {}, {}
for ty = y0, y1 do
for tx = x0, x1 do
Budget.tick()
local tile = tileLookup(tx, ty)
if tile then
local k = keyOf(tx, ty)
local s = TileShape.at(map, shapes, tile, tx, ty)
if s and void and void[tile] and not s.authored then
s = shapes.classes.void
end
shapeAt[k], tileAt[k] = s, tile
end
end
end
-- ---- buildings: whole sprites voxelized band by band ----
--
-- Before anything else looks at this grid. A profiled building is a
-- drawing whose bands depict DIFFERENT 3D surfaces (roof from above,
-- facade face-on, ends sloped), and the passes below -- the door fold,
-- the region flood, the volume builder -- all assume one drawing is one
-- upright thing. Modelling the building first and claiming its tiles
-- keeps every one of them off it.
--
-- (grassQuads live apart from objectQuads: grass renders as its own mesh
-- AFTER the characters -- see VoxelScene -- so the southern tuft row
-- still overdraws a walker's feet even though characters stamp over
-- terrain.)
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
hideBareRing = hullRingOnly or nil,
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
Buildings.build(S, map, pixels(tileset), perRow)
-- Fold doors into their buildings. A door cell is WALKABLE (the player
-- steps onto it to warp), so it resolves to ground and punches a hole in
-- the facade: the door lies flat, the rows above it recess, and -- worse
-- -- the hole lets the background flood into the building's interior
-- whites, shredding it into misdetected sprite clusters. Visually the
-- door is part of the facade, so mark the door cell's tiles structural:
-- the fold then shows the door art standing at ground level in the
-- building's front face. Door graphics only (the tileset's doorTiles);
-- interior stair/mat warps stay flat.
--
-- A PROFILE PIN WINS over the fold. The fold is detection, and rule 1
-- of the resolution order is that an authored tile bypasses detection
-- -- but this used to overwrite shapeAt unconditionally, so a pin on
-- any tile the tileset also lists in doorTiles was dead on arrival.
-- Celadon Mansion is the case that found it: all four of its
-- staircases are door tiles, so `stair_e` / `stair_down_w` pins there
-- silently did nothing and the flights stayed painted on the floor.
for cy = math.floor(y0 / 2), math.floor(y1 / 2) do
for cx = math.floor(x0 / 2), math.floor(x1 / 2) do
if map.doorTiles[map:cellTile(cx, cy)] then
local northK = keyOf(cx * 2, cy * 2 - 1)
local ns = shapeAt[northK]
if ns and ns.art == "upright" then
for dy = 0, 1 do
for dx = 0, 1 do
local dk = keyOf(cx * 2 + dx, cy * 2 + dy)
local ds = shapeAt[dk]
if not (ds and ds.authored) then
shapeAt[dk] = shapes.classes.wall
-- remembered for buildVolume: a folded doorway column
-- answers to its REGION for height and top, not to its
-- own drawn extent (see the door adoption there)
S.doorFold[dk] = true
end
end
end
end
end
end
end
-- a structure cell: solid art the detector may model (authored tiles are
-- profile-pinned and keep their authored shape)
local function structural(k)
local s = shapeAt[k]
return s and s.art == "upright" and not s.authored
end
-- ---- cylinders: profile-pinned round graphics, one per 16x16 cell ----
-- the flat ground tiles this map actually places, for the hull's
-- ground matching: the ball's own drawn background picks its floor
local groundTiles = {}
do
local seenG = {}
for k, s in pairs(shapeAt) do
if s and s.flat and s.class == "ground" then
local t = tileAt[k]
if t and not seenG[t] then
seenG[t] = true
groundTiles[#groundTiles + 1] = t
end
end
end
end
Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
-- ---- stairs: profile-pinned cells that render as real steps ----
Structures.buildStairs(S, map, x0, x1, y0, y1)
-- ---- bookcases: pinned shelves collapsed to one cell of depth ----
-- The atlas comes along so the shelf front can carry its own measured
-- relief: the panes it seals behind its black frames sink a voxel.
Structures.buildBookcases(S, map, x0, x1, y0, y1, pixels(tileset), perRow)
-- ---- figures: a person drawn INTO furniture, lifted off it ----
-- Before the region flood and the volume pass, so everything after this
-- reads the tiles the profile says are there once the figure is gone.
-- (Its own tiles are authored furniture or walkable floor either way, so
-- no pass below would have claimed them -- but the repaint is what those
-- passes should see, and this needs no pixel access to do it.)
Structures.buildFigures(S, map, x0, x1, y0, y1)
-- ---- mounted: a thing drawn INTO a wall band, stood proud of it ----
-- Here for the same reason and with the same guarantee as the figures
-- above: the repaint hands every pass below the plain panel the profile
-- says is behind the object, so the wall band it was painted into keeps
-- resolving as the wall it is -- without a second copy of the drawing
-- flat on its face.
Structures.buildMounted(S, map, x0, x1, y0, y1)
-- ---- flood-fill regions of structural tiles ----
local seen = {}
local regions = {}
for ty = y0, y1 do
for tx = x0, x1 do
local k = keyOf(tx, ty)
if structural(k) and not seen[k] then
local region = { tiles = {}, minX = tx, maxX = tx,
minY = ty, maxY = ty }
local queue = { { tx, ty } }
seen[k] = true
while #queue > 0 do
Budget.tick()
local c = table.remove(queue)
local cx, cy = c[1], c[2]
region.tiles[#region.tiles + 1] = c
region.minX = math.min(region.minX, cx)
region.maxX = math.max(region.maxX, cx)
region.minY = math.min(region.minY, cy)
region.maxY = math.max(region.maxY, cy)
for _, d in ipairs(DIRS4) do
local nx, ny = cx + d[1], cy + d[2]
if nx >= x0 and nx <= x1 and ny >= y0 and ny <= y1 then
local nk = keyOf(nx, ny)
if structural(nk) and not seen[nk] then
seen[nk] = true
queue[#queue + 1] = { nx, ny }
end
end
end
end
regions[#regions + 1] = region
end
end
end
-- ---- model each region: carve out per-pixel objects, volume the rest --
local data = pixels(tileset)
for _, region in ipairs(regions) do
local leftover = region.tiles
if data then
leftover = Structures.extractObjects(S, map, region, data, perRow)
end
if #leftover > 0 then
Structures.buildVolume(S, map, leftover)
end
end
-- ---- profile-pinned billboards (signs): forced per-pixel slabs ----
if data then
local seenB = {}
for ty = y0, y1 do
for tx = x0, x1 do
local k = keyOf(tx, ty)
local s = shapeAt[k]
if s and s.art == "billboard" and not seenB[k] then
local reg = { tiles = {}, minX = tx, maxX = tx,
minY = ty, maxY = ty }
local queue = { { tx, ty } }
seenB[k] = true
while #queue > 0 do
local c = table.remove(queue)
reg.tiles[#reg.tiles + 1] = c
reg.minX = math.min(reg.minX, c[1])
reg.maxX = math.max(reg.maxX, c[1])
reg.minY = math.min(reg.minY, c[2])
reg.maxY = math.max(reg.maxY, c[2])
for _, d in ipairs(DIRS4) do
local nk = keyOf(c[1] + d[1], c[2] + d[2])
local ns = shapeAt[nk]
-- same CLASS, not just billboard art: `billboard` and
-- `prop` are two pools precisely so touching drawings (a TV
-- behind its console) become two standing objects instead
-- of one stacked cutout
if ns and ns.art == "billboard" and ns.class == s.class
and not seenB[nk] then
seenB[nk] = true
queue[#queue + 1] = { c[1] + d[1], c[2] + d[2] }
end
end
end
Structures.extractObjects(S, map, reg, data, perRow, true)
end
end
end
-- ---- profile-pinned fence posts: per-CELL standee slabs ----
-- A fence line repeats one drawing for a dozen cells, and its art
-- touches across cell seams. Pooled like a billboard the whole line
-- would stand as ONE drawing-tall tower at one depth (the detector's
-- vertical-repetition guard exists precisely to refuse that, which
-- is why undetected fence columns fell to the volume path as boxes).
-- Each CELL extracts alone instead: its posts stand in their own row
-- band and the fence marches north cell by cell.
local postCells = {}
for ty = y0, y1 do
for tx = x0, x1 do
local s = shapeAt[keyOf(tx, ty)]
if s and s.art == "post" then
local ck = keyOf(math.floor(tx / 2), math.floor(ty / 2))
postCells[ck] = postCells[ck] or {}
local list = postCells[ck]
list[#list + 1] = { tx, ty }
end
end
end
for _, tiles in pairs(postCells) do
local reg = { tiles = tiles,
minX = tiles[1][1], maxX = tiles[1][1],
minY = tiles[1][2], maxY = tiles[1][2] }
for _, c in ipairs(tiles) do
reg.minX = math.min(reg.minX, c[1])
reg.maxX = math.max(reg.maxX, c[1])
reg.minY = math.min(reg.minY, c[2])
reg.maxY = math.max(reg.maxY, c[2])
end
Structures.extractObjects(S, map, reg, data, perRow, "opaque")
end
-- ---- profile-pinned relief props: top-down drawings that extrude ----
local seenR = {}
for ty = y0, y1 do
for tx = x0, x1 do
local k = keyOf(tx, ty)
local s = shapeAt[k]
if s and s.art == "relief" and not seenR[k] then
local reg = { tiles = {}, minX = tx, maxX = tx,
minY = ty, maxY = ty }
local queue = { { tx, ty } }
seenR[k] = true
while #queue > 0 do
local c = table.remove(queue)
reg.tiles[#reg.tiles + 1] = c
reg.minX = math.min(reg.minX, c[1])
reg.maxX = math.max(reg.maxX, c[1])
reg.minY = math.min(reg.minY, c[2])
reg.maxY = math.max(reg.maxY, c[2])
for _, d in ipairs(DIRS4) do
local nk = keyOf(c[1] + d[1], c[2] + d[2])
local ns = shapeAt[nk]
if ns and ns.art == "relief" and ns.class == s.class
and not seenR[nk] then
seenR[nk] = true
queue[#queue + 1] = { c[1] + d[1], c[2] + d[2] }
end
end
end
for _, c in ipairs(reg.tiles) do
local ck = keyOf(c[1], c[2])
S.skip[ck] = true
S.ground[ck] = false
end
Structures.buildRelief(S, map, reg, data, perRow, s.h or 5)
end
end
end
-- ---- tall grass: two standing tuft rows per tile. BODY only: the 2D
-- renderer never draws a neighbour's ring, and standing scenery past a
-- map's edge would poke into the map next door ----
Structures.buildGrass(S, map, 0, tw - 1, 0, th - 1, data)
-- ---- flowers: the animated meadow tile stands as a 1px cutout ----
Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
end
-- ---- authored ground under pinned props ----
-- The profile can name the tile a pinned prop stands on (a tileset
-- entry's prop_ground: prop tile id -> ground tile id), overriding
-- the neighbour vote. The cuttable bush stands on the plain grass
-- Cut itself leaves behind, not on whatever path its neighbours
-- happen to vote in.
do
local okP, prof = pcall(V.data, "voxel_heights")
local entry = okP and type(prof) == "table" and prof.tilesets
and prof.tilesets[tileset.id]
local pg = entry and entry.prop_ground
if type(pg) == "table" then
for k, skipped in pairs(S.skip) do
if skipped then
local g = pg[S.tileAt[k]]
if g then S.ground[k] = g end
end
end
end
end
-- unresolved claimed ground (a hull with no art match, headless
-- cylinders): no flat neighbour to vote with, so fall back to the
-- map's commonest ground tile
local votes, best, bestN = {}, nil, 0
for k, s in pairs(shapeAt) do
if s and s.flat and s.class == "ground" then
local t = tileAt[k]
votes[t] = (votes[t] or 0) + 1
if votes[t] > bestN then best, bestN = t, votes[t] end
end
end
for k, g in pairs(S.ground) do
if g == false then S.ground[k] = best end
end
cache[map.id] = S
return S
end
-- ---- round scenery: outline-hulled voxel balls ----
-- Cells the profile pins as round (tree canopies -- the class keeps its
-- historical `cylinder` name in the data file) render as a VOXEL HULL cut
-- from the drawing itself. The first shipped attempt was a lathe -- the
-- per-row silhouette width revolved into a 12-segment column with the art
-- wrapped by sin(angle) -- and it read exactly like what it was: the
-- sprite pasted on a cylinder, with the wrap smearing the pixels into
-- vertical stripes. This replaces it with real voxels.
--
-- Segmentation first, silhouette-width second: the tree cell's art is a
-- ball drawn over background grass, and the background's mid greens pass
-- any brightness test (they inflated every lathe row to full width). The
-- ball's own DARKEST pixels are what bound it, so the mask is "the
-- darkest-shade outline plus everything it encloses": flood from the cell
-- border through every non-black pixel; what the flood cannot reach is
-- the tree, and the cast shadow under the canopy (dark but not enclosed)
-- floods away with the grass. Art with no closed black outline -- the
-- border tree wall is a dither of black and canopy with no drawn ring --
-- encloses nothing; there the flood passes only through the LIGHT shades
-- (the methodology doc's rule: black and dark together form the
-- boundary), and the dither mass itself becomes the mask, checker holes
-- and all, because a 4-connected flood cannot thread a diagonal checker.
--
-- Volume: each mask row is a disc. The row's span gives a center and
-- half-width, and every mask pixel's column runs that circle's chord in
-- z, quantized to whole voxels -- the front view IS the sprite, the plan
-- view is the sprite's own width profile turned in depth, and both step
-- pixel by pixel. Rows below the mask (the drawn shadow) repeat the
-- bottom row's discs down to the ground so the canopy stands on a short
-- dark foot instead of floating.
--
-- Skin: front and back faces carry the drawing per-pixel (the back reads
-- mirrored, sprite-pure); side and step faces take their column's own
-- texel, which puts the drawn outline exactly on the silhouette's rim;
-- and a fully exposed cap keeps its outline only on the rim cells while
-- the interior samples the canopy a couple of rows deeper -- painting the
-- whole cap with the outline row blacked out every dome on the first
-- attempt (the lathe hit the same bug with its top discs).
--
-- Tree walls repeat the same four tiles for hundreds of cells, so the
-- hull is built once per distinct art signature and stamped per cell.
local ROUND_SHADE = { front = 1.0, back = 0.68, side = 0.78,
top = 1.0, bottom = 0.55 }
-- The potted plant's ORGANIC HALF: the leaf crown (16 rows), then the
-- trunk, its root flare and the strands draping over the pot's rim (8
-- more) -- all of it stands as a slab this many voxels deep instead of
-- revolving. `depth` 5 is the thin standee pool's depth, what every other
-- interior plant already uses.
--
-- `rows` = 24 puts the slab/revolve boundary AT THE VESSEL'S RIM ROW, and
-- that placement is what makes the pot read as a pot. The first cut put
-- it at the cell seam (16), which let the root and drape rows revolve:
-- their drawn spans are 8-12 wide, so they stacked 8-12-deep discs on top
-- of the rim and the whole base read as one bulbous onion instead of a
-- flat-mouthed planter with a trunk standing out of it. Only rows 24-31
-- -- black rim edge, gold band, body, foot, the drawn flowerpot profile
-- -- are the vessel, and only they revolve.
local PLANTER_SPRAY = { rows = 24, depth = 5 }
-- `spray`, when given, caps the chord over the canvas's top `rows` rows to
-- `depth` voxels instead of revolving them.
--
-- Revolving a row turns its DRAWN WIDTH into depth, which only means
-- something when the drawing states a width to turn -- the pot's rows do
-- (a 3px stem opening to a 12px belly and closing to a 6px foot, an urn's
-- profile), and a tree canopy's do (the ball's outline is drawn). A leaf
-- crown's do NOT: the leaves are a spray that runs off all four sides of
-- its tile, so every row measures the full canvas and the revolve can only
-- produce a solid cylinder -- the "hedge column" a plant must never become,
-- with one row of texels smeared down its whole top face. Where the drawing
-- states no profile, the honest reading is the one the thin standee pools
-- exist for: the foliage stands as a per-pixel slab and keeps the airy
-- silhouette that makes it read as leaves.
-- `squash`, when given, is the PERCENT of its revolved depth every chord
-- keeps -- 100 (or nil) is the identity, 50 halves the hull front to back.
--
-- A full revolve assumes the drawing's width is also its depth, which is
-- true of a thing that really is round in plan (a hedge ball, a boulder,
-- a trash can). A TREE is round in its canopy and thin at every other
-- reading: the trunk is a stick, the crown is more air than wood, and the
-- drawing is scenery seen from one side. Revolved at full width the little
-- tree eats a whole cell of depth and reads as a boulder wearing bark, so
-- the plan stays a circle and shrinks toward an ellipse: still round in
-- section, still stepping pixel by pixel, just shallower. The chord is
-- re-centred on the mid-plane, so the model neither slides nor detaches
-- from the cells around it.
local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
NYin, spray, baseRows, bodyRows, wellRows,
taperVox, squash)
-- The canvas is NX wide and NX DEEP (a hull is round in plan, so its
-- depth is its width) by NY tall. NX = 16 is one cell, 32 a 2x2-cell
-- group; NY defaults to NX -- a ball -- and NY = 2 * NX is a drawing
-- STACKED two cells high on one cell of plot (the potted plant).
local NX = N or 16
local NY = NYin or NX
local N2 = NX / 2
local perRow = map.tileset.tilesPerRow or 16
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
-- cell-space art access (NX x NY, row 0 = top), anchored at cell (cx, cy)
local function tileOf(px, py)
return S.tileAt[keyOf(cx * 2 + math.floor(px / 8),
cy * 2 + math.floor(py / 8))]
end
local function texel(px, py)
local tile = tileOf(px, py)
return (tile % perRow) * 8 + px % 8,
math.floor(tile / perRow) * 8 + py % 8
end
-- shade class of every canvas pixel, indexed py * NX + px
local cls = {}
for py = 0, NY - 1 do
for px = 0, NX - 1 do
local ax, ay = texel(px, py)
local r, g, b, a = data:getPixel(ax, ay)
cls[py * NX + px] = a == 0 and "off"
or Structures.shadeClass(math.min(r, g, b))
end
end
-- 4-connected flood from a row band's border through `passable` classes
local function floodOutside(passable, y0, y1)
local out, stack = {}, {}
local function seed(i)
if not out[i] and passable[cls[i]] then
out[i] = true
stack[#stack + 1] = i
end
end
for px = 0, NX - 1 do
seed(y0 * NX + px); seed(y1 * NX + px)
end
for py = y0, y1 do
seed(py * NX); seed(py * NX + NX - 1)
end
while #stack > 0 do
local i = table.remove(stack)
local px, py = i % NX, math.floor(i / NX)
if px > 0 then seed(i - 1) end
if px < NX - 1 then seed(i + 1) end
if py > y0 then seed(i - NX) end
if py < y1 then seed(i + NX) end
end
return out
end
-- The mask -- darkest-pixel outline plus its enclosure, with the dither
-- rule as fallback -- computed per CELL BAND of NX rows.
--
-- A square canvas is ONE band, so this is exactly the whole-canvas rule
-- it replaces. A STACKED canvas needs it per band because its two halves
-- want opposite answers: the potted plant's leaf crown is a black-outlined
-- dither drawn over floor (outline enclosure keeps it), while its pot is a
-- solid DARK body whose base runs flush to the band's bottom edge (the
-- enclosure flood walks in through dark and guts it, and the fallback --
-- which the band's own `enclosed` count asks for -- keeps it). Measured on
-- the Center plant: one flood over both bands keeps 53% of the drawing and
-- leaves the pot a hollow black frame; per band keeps 68% and both read.
local mask = {}
for band = 0, NY / NX - 1 do
local y0, y1 = band * NX, band * NX + NX - 1
local out = floodOutside({ off = true, dark = true,
light = true, white = true }, y0, y1)
local enclosed = 0
for i = y0 * NX, (y1 + 1) * NX - 1 do
if not out[i] then
mask[i] = true
if cls[i] ~= "black" then enclosed = enclosed + 1 end
end
end
if enclosed < NX * NX / 8 then
out = floodOutside({ off = true, light = true, white = true }, y0, y1)
for i = y0 * NX, (y1 + 1) * NX - 1 do
mask[i] = (not out[i] and cls[i] ~= "off") or nil
end
end
end
local any = nil
for i = 0, NX * NY - 1 do any = any or mask[i] end
if not any then return {} end
-- a CAPPED hull (the stump): the top capRows rows of the mask are the
-- drawn cut face -- a surface seen at an angle, not body. Strip them
-- from the mask and remember their art span; the top-face quads below
-- project that ellipse across the round cap.
local capY0, capY1 = nil, nil
if capRows and capRows > 0 then
local top = nil
for iy = 0, NY - 1 do
for ix = 0, NX - 1 do
if mask[iy * NX + ix] then top = iy break end
end
if top then break end
end
if top then
capY0 = top
capY1 = math.min(top + capRows - 1, NY - 2)
for iy = capY0, capY1 do
for ix = 0, NX - 1 do mask[iy * NX + ix] = nil end
end
any = nil
for i = 0, NX * NY - 1 do any = any or mask[i] end
if not any then return {} end
end
end
-- a FLAT-BASED hull (the can): the bottom baseRows rows of the mask are
-- the BASE circle's front arc -- the drawing's mirror of the cut face
-- above, ground contact seen from above rather than body. A can is only
-- round in the horizontal plane, so the drop those rows make toward the
-- middle is DEPTH, not a narrowing of the plan: left as body they revolve
-- into ever smaller discs and the can ends up balanced on a stem three
-- voxels wide (which is exactly what the first build did). Strip them and
-- the foot rule below runs the last body row's full disc straight to the
-- floor; the rows keep their own texels there, so the front view is still
-- the drawing, base rim and all.
local baseArt = nil
if baseRows and baseRows > 0 then
local bot = nil
for iy = NY - 1, 0, -1 do
for ix = 0, NX - 1 do
if mask[iy * NX + ix] then bot = iy break end
end
if bot then break end
end
if bot then
baseArt = {}
for iy = math.max(bot - baseRows + 1, (capY1 or -1) + 2), bot do
for ix = 0, NX - 1 do
local i = iy * NX + ix
if mask[i] then baseArt[i] = true end
mask[i] = nil
end
end
any = nil
for i = 0, NX * NY - 1 do any = any or mask[i] end
if not any then return {} end
end
end
-- The can's HEIGHT, and the one place this file departs from the drawing
-- on purpose. Strictly un-projected, the drawing states a squat drum: cut
-- the mouth ellipse off the top and the base circle off the bottom and
-- barely two rows of straight side are left between them, because the GB
-- artist spent most of a 16px cell on the opening. A real bin is TALLER
-- than it is wide, and the flat game reads as one because the drawing is
-- 14px tall next to a 16px player -- so the height is authored (can_height
-- voxels) rather than measured, and the surviving body band is repeated
-- upward to fill it, bottom row first, which continues the drawn rib
-- rhythm instead of inventing a texel. Everything else still comes off
-- the pixels.
local artRow = {}
if bodyRows and bodyRows > 0 then
local body = {}
for iy = 0, NY - 1 do
for ix = 0, NX - 1 do
if mask[iy * NX + ix] then body[#body + 1] = iy break end
end
end
local nb = #body
if nb > 0 then
local top = body[1]
for iy = top - 1, math.max(NY - bodyRows, 0), -1 do
-- the LOWEST surviving body row, repeated: it is the widest and
-- plainest reading of the material (outline, shaded flank, lit
-- face) and stacks into a clean metal cylinder. Cycling the whole
-- surviving band instead stacks the drawn rim arcs into a barcode
-- of hoops, which is detail the drawing never states about the
-- side of the can.
local from = body[nb]
artRow[iy] = from
for ix = 0, NX - 1 do
mask[iy * NX + ix] = mask[from * NX + ix]
end
end
end
end
-- the ground the ball stands on: the drawing's own background names
-- it. Score every flat ground tile the map places against the cell's
-- unmasked light pixels and keep the closest -- mid-forest trees have
-- no flat neighbour to vote with, and the commonest-ground fallback
-- paints pale path under trees whose art sits on grass. Dark unmasked
-- pixels (the drawn cast shadow) stay out of the score: no ground
-- tile carries a shadow, and their darks would drag every match.
local bg = nil
if groundTiles and #groundTiles > 0 then
local bestScore = nil
for _, t in ipairs(groundTiles) do
local ox = (t % perRow) * 8
local oy = math.floor(t / perRow) * 8
local score, n = 0, 0
for py = 0, NY - 1 do
for px = 0, NX - 1 do
local i = py * NX + px
local c = cls[i]
-- a stripped base row is the OBJECT's own rim, not background:
-- scoring its whites against the floor tiles matches paper-white
-- ground under a can whose art stands on the gym's grey
if not mask[i] and not (baseArt and baseArt[i])
and (c == "light" or c == "white") then
local ax, ay = texel(px, py)
local r1, g1, b1 = data:getPixel(ax, ay)
local r2, g2, b2 = data:getPixel(ox + px % 8, oy + py % 8)
local dr, dg, db = r1 - r2, g1 - g2, b1 - b2
score = score + dr * dr + dg * dg + db * db
n = n + 1
end
end
end
if n > 0 then
score = score / n
if not bestScore or score < bestScore then bestScore, bg = score, t end
end
end
end
-- discs: per mask pixel a z chord [z0, z1), from its row's span circle.
-- z2/z3 is an optional SECOND chord for the same pixel, which only the
-- can's hollow mouth uses: a ring in plan needs a front wall and a back
-- wall at the same column, and one interval cannot say that.
local z0, z1, z2, z3, src, srcX = {}, {}, {}, {}, {}, {}
local loRow, hiRow = {}, {}
local yBot = nil
for iy = 0, NY - 1 do
local lo, hi = nil, nil
for ix = 0, NX - 1 do
if mask[iy * NX + ix] then
lo = lo or ix
hi = ix
end
end
if lo then
loRow[iy], hiRow[iy] = lo, hi
yBot = iy
local c = (lo + hi + 1) / 2
local hw = (hi - lo + 1) / 2
for ix = lo, hi do
local i = iy * NX + ix
if mask[i] then
local dx = ix + 0.5 - c
local n = 1
if hw * hw > dx * dx then
n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx)
+ 0.5))
end
if spray and iy < spray.rows then n = math.min(n, spray.depth) end
if squash then n = math.max(1, math.floor(n * squash / 100 + 0.5)) end
z0[i] = math.floor(N2 - n / 2 + 0.5)
z1[i] = z0[i] + n
-- a row the can's body band was repeated into wears the row it
-- was copied from, never a texel of its own
src[i] = artRow[iy] or iy
end
end
end
end
-- Spray-gap BACKING: the drawing's own gap pixels, one voxel deep at
-- the slab's mid-plane. The flat crown is full of floor showing
-- between leaves; carved as an open slab those gaps became TUNNELS --
-- the Center couch, the man sitting on it and the void wall all read
-- as pink/orange/black confetti INSIDE the foliage, and the sparse
-- bottom rows (lone drawn leaf tips) floated as disconnected specks
-- against them. The drawing itself backs every gap with its own
-- pixels, so the hull does the same: each in-span gap below drawn
-- foliage takes ITS OWN texel as a plate recessed behind the leaf
-- relief. Coverage is monotone down a column, so the first backed
-- cell always sits directly under a leaf chord -- and every chord
-- spans the mid-plane, so no plate ever caps the crown's top: columns
-- open to the sky stay open and the silhouette keeps its notches.
if spray then
for iy = 1, math.min(spray.rows, NY) - 1 do
if loRow[iy] then
for ix = loRow[iy], hiRow[iy] do
local i = iy * NX + ix
if not z0[i] then
local covered = false
for iy2 = 0, iy - 1 do
if mask[iy2 * NX + ix] then covered = true break end
end
if covered then
z0[i], z1[i], src[i] = N2, N2 + 1, iy
end
end
end
end
end
end
-- foot: rows under the mask repeat the bottom row's discs, wearing the
-- bottom row's (outline-dark) pixels -- except where a stripped base row
-- DREW something at that pixel, which keeps its own texel, so a can's
-- drawn base rim lands on the model's base instead of being painted over
-- by the body band above it
for iy = yBot + 1, NY - 1 do
loRow[iy], hiRow[iy] = loRow[yBot], hiRow[yBot]
for ix = loRow[yBot], hiRow[yBot] do
local b = yBot * NX + ix
if z0[b] then
local i = iy * NX + ix
z0[i], z1[i] = z0[b], z1[b]
src[i] = (baseArt and baseArt[i]) and iy or yBot
end
end
end
-- the TAPER: a bin is a truncated cone, not a tube -- wide at the rim,
-- drawn in a couple of voxels toward the base. The drawing agrees as far
-- as it can (its own base arc pulls in to 9px from the 11px flanks), but
-- it cannot state the whole run, so taperVox is the diameter the base
-- loses and the rows in between interpolate. Every row keeps its plan
-- ROUND: narrow the span, then re-cut the chords from the narrowed span,
-- or the model comes out a cylinder with its corners shaved.
local stepped = {}
if taperVox and taperVox > 0 then
local yTopRow = nil
for iy = 0, NY - 1 do
if loRow[iy] then yTopRow = iy break end
end
local span = NY - 1 - (yTopRow or 0)
if yTopRow and span > 0 then
for iy = yTopRow, NY - 1 do
local inset = math.floor(taperVox / 2 * (iy - yTopRow) / span + 0.5)
if inset > 0 and loRow[iy] then
local lo = loRow[iy] + inset
local hi = hiRow[iy] - inset
if hi - lo < 1 then
lo = math.floor((loRow[iy] + hiRow[iy]) / 2)
hi = lo + 1
end
for ix = loRow[iy], hiRow[iy] do
if ix < lo or ix > hi then
local i = iy * NX + ix
z0[i], z1[i], z2[i], z3[i] = nil, nil, nil, nil
end
end
-- squeeze the row's ART into the narrowed span rather than
-- clipping its ends off: the drawn outline is the last column
-- either side, and dropping it leaves the taper's new edge
-- wearing an interior texel -- a white chip down the rim
for ix = lo, hi do
srcX[iy * NX + ix] = loRow[iy]
+ math.floor((ix - lo) * (hiRow[iy] - loRow[iy])
/ (hi - lo) + 0.5)
end
loRow[iy], hiRow[iy] = lo, hi
stepped[iy] = true
local c = (lo + hi + 1) / 2
local hw = (hi - lo + 1) / 2
for ix = lo, hi do
local i = iy * NX + ix
if z0[i] then
local dx = ix + 0.5 - c
local n = 1
if hw * hw > dx * dx then
n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx)
+ 0.5))
end
if squash then
n = math.max(1, math.floor(n * squash / 100 + 0.5))
end
z0[i] = math.floor(N2 - n / 2 + 0.5)
z1[i] = z0[i] + n
end
end
end