diff --git a/assetsraw/chunks/chunk_0_0_0.json b/assetsraw/chunks/chunk_0_0_0.json
index 7e3e7af2..78d4c53e 100644
--- a/assetsraw/chunks/chunk_0_0_0.json
+++ b/assetsraw/chunks/chunk_0_0_0.json
@@ -1,1215 +1,2183 @@
{
- "meshes": [
- {
- "file": "house_5_3.dmf",
- "pos": [ 1, 1, 0 ]
- }
- ],
"tiles": [
{
- "pos": [ 0, 0, 0 ],
+ "pos": [
+ 0,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 0, 0 ],
+ "pos": [
+ 1,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 0, 0 ],
+ "pos": [
+ 2,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 0, 0 ],
+ "pos": [
+ 3,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 0, 0 ],
+ "pos": [
+ 4,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 0, 0 ],
+ "pos": [
+ 5,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 0, 0 ],
+ "pos": [
+ 6,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 0, 0 ],
+ "pos": [
+ 7,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 0, 0 ],
+ "pos": [
+ 8,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 0, 0 ],
+ "pos": [
+ 9,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 0, 0 ],
+ "pos": [
+ 10,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 0, 0 ],
+ "pos": [
+ 11,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 0, 0 ],
+ "pos": [
+ 12,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 0, 0 ],
+ "pos": [
+ 13,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 0, 0 ],
+ "pos": [
+ 14,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 0, 0 ],
+ "pos": [
+ 15,
+ 0,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 1, 0 ],
+ "pos": [
+ 0,
+ 1,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 1, 0 ],
+ "pos": [
+ 6,
+ 1,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 1, 0 ],
+ "pos": [
+ 7,
+ 1,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 1, 0 ],
+ "pos": [
+ 8,
+ 1,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 1, 0 ],
+ "pos": [
+ 9,
+ 1,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 1, 0 ],
+ "pos": [
+ 10,
+ 1,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 1, 0 ],
+ "pos": [
+ 11,
+ 1,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 1, 0 ],
+ "pos": [
+ 12,
+ 1,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 1, 0 ],
+ "pos": [
+ 13,
+ 1,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 1, 0 ],
+ "pos": [
+ 14,
+ 1,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 1, 0 ],
+ "pos": [
+ 15,
+ 1,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 2, 0 ],
+ "pos": [
+ 0,
+ 2,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 2, 0 ],
+ "pos": [
+ 6,
+ 2,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 2, 0 ],
+ "pos": [
+ 7,
+ 2,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 2, 0 ],
+ "pos": [
+ 8,
+ 2,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 2, 0 ],
+ "pos": [
+ 9,
+ 2,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 2, 0 ],
+ "pos": [
+ 10,
+ 2,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 2, 0 ],
+ "pos": [
+ 11,
+ 2,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 2, 0 ],
+ "pos": [
+ 12,
+ 2,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 2, 0 ],
+ "pos": [
+ 13,
+ 2,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 2, 0 ],
+ "pos": [
+ 14,
+ 2,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 2, 0 ],
+ "pos": [
+ 15,
+ 2,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 3, 0 ],
+ "pos": [
+ 0,
+ 3,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 3, 0 ],
+ "pos": [
+ 6,
+ 3,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 3, 0 ],
+ "pos": [
+ 7,
+ 3,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 3, 0 ],
+ "pos": [
+ 8,
+ 3,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 3, 0 ],
+ "pos": [
+ 9,
+ 3,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 3, 0 ],
+ "pos": [
+ 10,
+ 3,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 3, 0 ],
+ "pos": [
+ 11,
+ 3,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 3, 0 ],
+ "pos": [
+ 12,
+ 3,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 3, 0 ],
+ "pos": [
+ 13,
+ 3,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 3, 0 ],
+ "pos": [
+ 14,
+ 3,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 3, 0 ],
+ "pos": [
+ 15,
+ 3,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 4, 0 ],
+ "pos": [
+ 0,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 4, 0 ],
+ "pos": [
+ 1,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 4, 0 ],
+ "pos": [
+ 2,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 4, 0 ],
+ "pos": [
+ 3,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 4, 0 ],
+ "pos": [
+ 4,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 4, 0 ],
+ "pos": [
+ 5,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 4, 0 ],
+ "pos": [
+ 6,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 4, 0 ],
+ "pos": [
+ 7,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 4, 0 ],
+ "pos": [
+ 8,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 4, 0 ],
+ "pos": [
+ 9,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 4, 0 ],
+ "pos": [
+ 10,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 4, 0 ],
+ "pos": [
+ 11,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 4, 0 ],
+ "pos": [
+ 12,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 4, 0 ],
+ "pos": [
+ 13,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 4, 0 ],
+ "pos": [
+ 14,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 4, 0 ],
+ "pos": [
+ 15,
+ 4,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 5, 0 ],
+ "pos": [
+ 0,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 5, 0 ],
+ "pos": [
+ 1,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 5, 0 ],
+ "pos": [
+ 2,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 5, 0 ],
+ "pos": [
+ 3,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 5, 0 ],
+ "pos": [
+ 4,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 5, 0 ],
+ "pos": [
+ 5,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 5, 0 ],
+ "pos": [
+ 6,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 5, 0 ],
+ "pos": [
+ 7,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 5, 0 ],
+ "pos": [
+ 8,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 5, 0 ],
+ "pos": [
+ 9,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 5, 0 ],
+ "pos": [
+ 10,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 5, 0 ],
+ "pos": [
+ 11,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 5, 0 ],
+ "pos": [
+ 12,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 5, 0 ],
+ "pos": [
+ 13,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 5, 0 ],
+ "pos": [
+ 14,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 5, 0 ],
+ "pos": [
+ 15,
+ 5,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 6, 0 ],
+ "pos": [
+ 0,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 6, 0 ],
+ "pos": [
+ 1,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 6, 0 ],
+ "pos": [
+ 2,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 6, 0 ],
+ "pos": [
+ 3,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 6, 0 ],
+ "pos": [
+ 4,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 6, 0 ],
+ "pos": [
+ 5,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 6, 0 ],
+ "pos": [
+ 6,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 6, 0 ],
+ "pos": [
+ 7,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 6, 0 ],
+ "pos": [
+ 8,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 6, 0 ],
+ "pos": [
+ 9,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 6, 0 ],
+ "pos": [
+ 10,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 6, 0 ],
+ "pos": [
+ 11,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 6, 0 ],
+ "pos": [
+ 12,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 6, 0 ],
+ "pos": [
+ 13,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 6, 0 ],
+ "pos": [
+ 14,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 6, 0 ],
+ "pos": [
+ 15,
+ 6,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 7, 0 ],
+ "pos": [
+ 0,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 7, 0 ],
+ "pos": [
+ 1,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 7, 0 ],
+ "pos": [
+ 2,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 7, 0 ],
+ "pos": [
+ 3,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 7, 0 ],
+ "pos": [
+ 4,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 7, 0 ],
+ "pos": [
+ 5,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 7, 0 ],
+ "pos": [
+ 6,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 7, 0 ],
+ "pos": [
+ 7,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 7, 0 ],
+ "pos": [
+ 8,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 7, 0 ],
+ "pos": [
+ 9,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 7, 0 ],
+ "pos": [
+ 10,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 7, 0 ],
+ "pos": [
+ 11,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 7, 0 ],
+ "pos": [
+ 12,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 7, 0 ],
+ "pos": [
+ 13,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 7, 0 ],
+ "pos": [
+ 14,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 7, 0 ],
+ "pos": [
+ 15,
+ 7,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 8, 0 ],
+ "pos": [
+ 0,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 8, 0 ],
+ "pos": [
+ 1,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 8, 0 ],
+ "pos": [
+ 2,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 8, 0 ],
+ "pos": [
+ 3,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 8, 0 ],
+ "pos": [
+ 4,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 8, 0 ],
+ "pos": [
+ 5,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 8, 0 ],
+ "pos": [
+ 6,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 8, 0 ],
+ "pos": [
+ 7,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 8, 0 ],
+ "pos": [
+ 8,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 8, 0 ],
+ "pos": [
+ 9,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 8, 0 ],
+ "pos": [
+ 10,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 8, 0 ],
+ "pos": [
+ 11,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 8, 0 ],
+ "pos": [
+ 12,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 8, 0 ],
+ "pos": [
+ 13,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 8, 0 ],
+ "pos": [
+ 14,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 8, 0 ],
+ "pos": [
+ 15,
+ 8,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 9, 0 ],
+ "pos": [
+ 0,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 9, 0 ],
+ "pos": [
+ 1,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 9, 0 ],
+ "pos": [
+ 2,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 9, 0 ],
+ "pos": [
+ 3,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 9, 0 ],
+ "pos": [
+ 4,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 9, 0 ],
+ "pos": [
+ 5,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 9, 0 ],
+ "pos": [
+ 6,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 9, 0 ],
+ "pos": [
+ 7,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 9, 0 ],
+ "pos": [
+ 8,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 9, 0 ],
+ "pos": [
+ 9,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 9, 0 ],
+ "pos": [
+ 10,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 9, 0 ],
+ "pos": [
+ 11,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 9, 0 ],
+ "pos": [
+ 12,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 9, 0 ],
+ "pos": [
+ 13,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 9, 0 ],
+ "pos": [
+ 14,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 9, 0 ],
+ "pos": [
+ 15,
+ 9,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 10, 0 ],
+ "pos": [
+ 0,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 10, 0 ],
+ "pos": [
+ 1,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 10, 0 ],
+ "pos": [
+ 2,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 10, 0 ],
+ "pos": [
+ 3,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 10, 0 ],
+ "pos": [
+ 4,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 10, 0 ],
+ "pos": [
+ 5,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 10, 0 ],
+ "pos": [
+ 6,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 10, 0 ],
+ "pos": [
+ 7,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 10, 0 ],
+ "pos": [
+ 8,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 10, 0 ],
+ "pos": [
+ 9,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 10, 0 ],
+ "pos": [
+ 10,
+ 10,
+ 0
+ ],
"type": 6,
"tile": 0
},
{
- "pos": [ 11, 10, 0 ],
+ "pos": [
+ 11,
+ 10,
+ 0
+ ],
"type": 2,
"tile": 0
},
{
- "pos": [ 12, 10, 0 ],
+ "pos": [
+ 12,
+ 10,
+ 0
+ ],
"type": 7,
"tile": 0
},
{
- "pos": [ 13, 10, 0 ],
+ "pos": [
+ 13,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 10, 0 ],
+ "pos": [
+ 14,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 10, 0 ],
+ "pos": [
+ 15,
+ 10,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 11, 0 ],
+ "pos": [
+ 0,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 11, 0 ],
+ "pos": [
+ 1,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 11, 0 ],
+ "pos": [
+ 2,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 11, 0 ],
+ "pos": [
+ 3,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 11, 0 ],
+ "pos": [
+ 4,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 11, 0 ],
+ "pos": [
+ 5,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 11, 0 ],
+ "pos": [
+ 6,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 11, 0 ],
+ "pos": [
+ 7,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 11, 0 ],
+ "pos": [
+ 8,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 11, 0 ],
+ "pos": [
+ 9,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 11, 0 ],
+ "pos": [
+ 10,
+ 11,
+ 0
+ ],
"type": 3,
"tile": 0
},
{
- "pos": [ 11, 11, 1 ],
- "type": 1,
- "tile": 0
- },
- {
- "pos": [ 12, 11, 0 ],
+ "pos": [
+ 12,
+ 11,
+ 0
+ ],
"type": 5,
"tile": 0
},
{
- "pos": [ 13, 11, 0 ],
+ "pos": [
+ 13,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 11, 0 ],
+ "pos": [
+ 14,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 11, 0 ],
+ "pos": [
+ 15,
+ 11,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 12, 0 ],
+ "pos": [
+ 0,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 12, 0 ],
+ "pos": [
+ 1,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 12, 0 ],
+ "pos": [
+ 2,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 12, 0 ],
+ "pos": [
+ 3,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 12, 0 ],
+ "pos": [
+ 4,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 12, 0 ],
+ "pos": [
+ 5,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 12, 0 ],
+ "pos": [
+ 6,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 12, 0 ],
+ "pos": [
+ 7,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 12, 0 ],
+ "pos": [
+ 8,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 12, 0 ],
+ "pos": [
+ 9,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 12, 0 ],
+ "pos": [
+ 10,
+ 12,
+ 0
+ ],
"type": 8,
"tile": 0
},
{
- "pos": [ 11, 12, 0 ],
+ "pos": [
+ 11,
+ 12,
+ 0
+ ],
"type": 4,
"tile": 0
},
{
- "pos": [ 12, 12, 0 ],
+ "pos": [
+ 12,
+ 12,
+ 0
+ ],
"type": 9,
"tile": 0
},
{
- "pos": [ 13, 12, 0 ],
+ "pos": [
+ 13,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 12, 0 ],
+ "pos": [
+ 14,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 12, 0 ],
+ "pos": [
+ 15,
+ 12,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 13, 0 ],
+ "pos": [
+ 0,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 13, 0 ],
+ "pos": [
+ 1,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 13, 0 ],
+ "pos": [
+ 2,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 13, 0 ],
+ "pos": [
+ 3,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 13, 0 ],
+ "pos": [
+ 4,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 13, 0 ],
+ "pos": [
+ 5,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 13, 0 ],
+ "pos": [
+ 6,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 13, 0 ],
+ "pos": [
+ 7,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 13, 0 ],
+ "pos": [
+ 8,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 13, 0 ],
+ "pos": [
+ 9,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 13, 0 ],
+ "pos": [
+ 10,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 13, 0 ],
+ "pos": [
+ 11,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 13, 0 ],
+ "pos": [
+ 12,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 13, 0 ],
+ "pos": [
+ 13,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 13, 0 ],
+ "pos": [
+ 14,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 13, 0 ],
+ "pos": [
+ 15,
+ 13,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 14, 0 ],
+ "pos": [
+ 0,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 14, 0 ],
+ "pos": [
+ 1,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 14, 0 ],
+ "pos": [
+ 2,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 14, 0 ],
+ "pos": [
+ 3,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 14, 0 ],
+ "pos": [
+ 4,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 14, 0 ],
+ "pos": [
+ 5,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 14, 0 ],
+ "pos": [
+ 6,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 14, 0 ],
+ "pos": [
+ 7,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 14, 0 ],
+ "pos": [
+ 8,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 14, 0 ],
+ "pos": [
+ 9,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 14, 0 ],
+ "pos": [
+ 10,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 14, 0 ],
+ "pos": [
+ 11,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 14, 0 ],
+ "pos": [
+ 12,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 14, 0 ],
+ "pos": [
+ 13,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 14, 0 ],
+ "pos": [
+ 14,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 14, 0 ],
+ "pos": [
+ 15,
+ 14,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 0, 15, 0 ],
+ "pos": [
+ 0,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 1, 15, 0 ],
+ "pos": [
+ 1,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 2, 15, 0 ],
+ "pos": [
+ 2,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 3, 15, 0 ],
+ "pos": [
+ 3,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 4, 15, 0 ],
+ "pos": [
+ 4,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 5, 15, 0 ],
+ "pos": [
+ 5,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 6, 15, 0 ],
+ "pos": [
+ 6,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 7, 15, 0 ],
+ "pos": [
+ 7,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 8, 15, 0 ],
+ "pos": [
+ 8,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 9, 15, 0 ],
+ "pos": [
+ 9,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 10, 15, 0 ],
+ "pos": [
+ 10,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 11, 15, 0 ],
+ "pos": [
+ 11,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 12, 15, 0 ],
+ "pos": [
+ 12,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 13, 15, 0 ],
+ "pos": [
+ 13,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 14, 15, 0 ],
+ "pos": [
+ 14,
+ 15,
+ 0
+ ],
"type": 1,
"tile": 0
},
{
- "pos": [ 15, 15, 0 ],
+ "pos": [
+ 15,
+ 15,
+ 0
+ ],
+ "type": 1,
+ "tile": 0
+ },
+ {
+ "pos": [
+ 11,
+ 11,
+ 1
+ ],
"type": 1,
"tile": 0
}
+ ],
+ "meshes": [
+ {
+ "file": "house_5_3.dmf",
+ "pos": [
+ 1,
+ 1,
+ 0
+ ]
+ }
]
-}
+}
\ No newline at end of file
diff --git a/editor/client/public/common/chunkterrain.js b/editor/client/public/common/chunkterrain.js
new file mode 100644
index 00000000..663ab029
--- /dev/null
+++ b/editor/client/public/common/chunkterrain.js
@@ -0,0 +1,97 @@
+// Copyright (c) 2026 Dominic Masters
+//
+// This software is released under the MIT License.
+// https://opensource.org/licenses/MIT
+
+"use strict";
+
+// ChunkTerrain - JS port of the terrain-quad generation performed by
+// tools/asset/chunk/__main__.py (build_terrain_verts / _tile_quad /
+// _RAMP_CORNERS) so the editor's 3D preview can show the same geometry the
+// Python compiler will bake into the chunk's terrain mesh, without waiting
+// for a compile pass. Vertex layout matches DMF: interleaved [u, v, x, y, z].
+
+const ChunkTerrain = (() => {
+ const CHUNK_WIDTH = 16;
+ const CHUNK_HEIGHT = 16;
+ const CHUNK_DEPTH = 4;
+ const TILE_COUNT = CHUNK_WIDTH * CHUNK_HEIGHT * CHUNK_DEPTH;
+
+ const TILE_SHAPE_NULL = 0;
+ const TILE_SHAPE_GROUND = 1;
+ const TILE_SHAPE_RAMP_NORTH = 2;
+ const TILE_SHAPE_RAMP_EAST = 3;
+ const TILE_SHAPE_RAMP_SOUTH = 4;
+ const TILE_SHAPE_RAMP_WEST = 5;
+ const TILE_SHAPE_RAMP_NORTHEAST = 6;
+ const TILE_SHAPE_RAMP_NORTHWEST = 7;
+ const TILE_SHAPE_RAMP_SOUTHEAST = 8;
+ const TILE_SHAPE_RAMP_SOUTHWEST = 9;
+
+ // Per-shape corner heights as [sw, se, ne, nw] offsets from tile base Z.
+ // NORTH=+Y, EAST=+X. Cardinal ramps: low face at 0, high face at 1.
+ // Diagonal ramps: opposite corner at 0/1, adjacent corners at 0.5.
+ const RAMP_CORNERS = {
+ [TILE_SHAPE_GROUND]: [0.0, 0.0, 0.0, 0.0],
+ [TILE_SHAPE_RAMP_NORTH]: [0.0, 0.0, 1.0, 1.0],
+ [TILE_SHAPE_RAMP_SOUTH]: [1.0, 1.0, 0.0, 0.0],
+ [TILE_SHAPE_RAMP_EAST]: [0.0, 1.0, 1.0, 0.0],
+ [TILE_SHAPE_RAMP_WEST]: [1.0, 0.0, 0.0, 1.0],
+ [TILE_SHAPE_RAMP_NORTHEAST]: [0.0, 0.0, 1.0, 0.0],
+ [TILE_SHAPE_RAMP_NORTHWEST]: [0.0, 0.0, 0.0, 1.0],
+ [TILE_SHAPE_RAMP_SOUTHEAST]: [0.0, 1.0, 0.0, 0.0],
+ [TILE_SHAPE_RAMP_SOUTHWEST]: [1.0, 0.0, 0.0, 0.0],
+ };
+
+ function tileIndex(x, y, z) {
+ return x + y * CHUNK_WIDTH + z * CHUNK_WIDTH * CHUNK_HEIGHT;
+ }
+
+ // Six vertices (2 CCW triangles) for a quad with per-corner Z heights.
+ // Corner order: SW=(fx,fy), SE=(fx+1,fy), NE=(fx+1,fy+1), NW=(fx,fy+1).
+ function pushTileQuad(out, u0, u1, v0, v1, fx, fy, swZ, seZ, neZ, nwZ) {
+ out.push(
+ u0, v0, fx, fy, swZ,
+ u1, v0, fx + 1, fy, seZ,
+ u1, v1, fx + 1, fy + 1, neZ,
+ u0, v0, fx, fy, swZ,
+ u1, v1, fx + 1, fy + 1, neZ,
+ u0, v1, fx, fy + 1, nwZ,
+ );
+ }
+
+ // Generate terrain quads for every non-null tile in a flat tiles array
+ // (length TILE_COUNT, indexed via tileIndex). Returns a Float32Array of
+ // interleaved [u, v, x, y, z] vertices, matching DMF.VERTEX_FLOATS.
+ function buildTerrainVerts(tiles) {
+ const out = [];
+ for(let z = 0; z < CHUNK_DEPTH; z++) {
+ for(let y = 0; y < CHUNK_HEIGHT; y++) {
+ for(let x = 0; x < CHUNK_WIDTH; x++) {
+ const type = tiles[tileIndex(x, y, z)] || TILE_SHAPE_NULL;
+ const corners = RAMP_CORNERS[type];
+ if(!corners) continue;
+
+ const u0 = x / CHUNK_WIDTH;
+ const u1 = (x + 1) / CHUNK_WIDTH;
+ const v0 = y / CHUNK_HEIGHT;
+ const v1 = (y + 1) / CHUNK_HEIGHT;
+ const [sw, se, ne, nw] = corners;
+
+ pushTileQuad(out, u0, u1, v0, v1, x, y, z + sw, z + se, z + ne, z + nw);
+ }
+ }
+ }
+ return new Float32Array(out);
+ }
+
+ return Object.freeze({
+ CHUNK_WIDTH, CHUNK_HEIGHT, CHUNK_DEPTH, TILE_COUNT,
+ TILE_SHAPE_NULL, TILE_SHAPE_GROUND,
+ TILE_SHAPE_RAMP_NORTH, TILE_SHAPE_RAMP_EAST,
+ TILE_SHAPE_RAMP_SOUTH, TILE_SHAPE_RAMP_WEST,
+ TILE_SHAPE_RAMP_NORTHEAST, TILE_SHAPE_RAMP_NORTHWEST,
+ TILE_SHAPE_RAMP_SOUTHEAST, TILE_SHAPE_RAMP_SOUTHWEST,
+ RAMP_CORNERS, tileIndex, buildTerrainVerts,
+ });
+})();
diff --git a/editor/client/public/common/dmf.js b/editor/client/public/common/dmf.js
new file mode 100644
index 00000000..2a293cb5
--- /dev/null
+++ b/editor/client/public/common/dmf.js
@@ -0,0 +1,64 @@
+// Copyright (c) 2026 Dominic Masters
+//
+// This software is released under the MIT License.
+// https://opensource.org/licenses/MIT
+
+"use strict";
+
+// DMF - Dusk Mesh Format
+//
+// Header (12 bytes):
+// [0-3] "DMF\0" magic
+// [4-7] uint32 version (little-endian)
+// [8-11] uint32 vertCount (little-endian)
+//
+// Followed by vertCount vertices, each 20 bytes:
+// [0-7] float32[2] uv (little-endian)
+// [8-19] float32[3] pos (little-endian)
+
+const DMF = (() => {
+ const MAGIC = [0x44, 0x4d, 0x46, 0x00]; // "DMF\0"
+ const VERSION = 1;
+ const HEADER_SIZE = 12;
+ const VERTEX_FLOATS = 5; // uv[2] + pos[3]
+ const VERTEX_SIZE = VERTEX_FLOATS * 4;
+
+ // Decode a DMF ArrayBuffer into { version, vertexCount, floats }, where
+ // floats is a Float32Array of tightly interleaved [u, v, x, y, z] per
+ // vertex - ready to hand straight to gl.bufferData().
+ function decode(buffer) {
+ const bytes = new Uint8Array(buffer);
+ const view = new DataView(buffer);
+
+ if(bytes.length < HEADER_SIZE) throw new Error("File too small to be a valid DMF");
+ if(
+ bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1] ||
+ bytes[2] !== MAGIC[2] || bytes[3] !== MAGIC[3]
+ ) {
+ throw new Error("Invalid DMF magic bytes - not a DMF file");
+ }
+
+ const version = view.getUint32(4, true);
+ if(version !== VERSION) {
+ throw new Error(`Unsupported DMF version: ${version}`);
+ }
+
+ const vertexCount = view.getUint32(8, true);
+ const expected = HEADER_SIZE + vertexCount * VERTEX_SIZE;
+ if(bytes.length < expected) {
+ throw new Error(`DMF vertex data truncated (expected ${expected} bytes, got ${bytes.length})`);
+ }
+
+ const floats = new Float32Array(vertexCount * VERTEX_FLOATS);
+ for(let i = 0; i < floats.length; i++) {
+ floats[i] = view.getFloat32(HEADER_SIZE + i * 4, true);
+ }
+
+ return { version, vertexCount, floats };
+ }
+
+ return Object.freeze({
+ decode,
+ VERSION, HEADER_SIZE, VERTEX_FLOATS, VERTEX_SIZE,
+ });
+})();
diff --git a/editor/client/public/index.html b/editor/client/public/index.html
index 3507e45b..2eb5fe25 100644
--- a/editor/client/public/index.html
+++ b/editor/client/public/index.html
@@ -1 +1,6 @@
-
Hello Homepage
+
+
+
Dusk Map Editor
+
Browse and edit project assets.
+
+
diff --git a/editor/client/public/map/index.html b/editor/client/public/map/index.html
new file mode 100644
index 00000000..093d8e86
--- /dev/null
+++ b/editor/client/public/map/index.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
Meshes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Preview (scroll to zoom)
+
+
+
+
+
+
+
+
diff --git a/editor/client/public/map/map.css b/editor/client/public/map/map.css
new file mode 100644
index 00000000..e4b3b054
--- /dev/null
+++ b/editor/client/public/map/map.css
@@ -0,0 +1,65 @@
+/* Copyright (c) 2026 Dominic Masters
+ *
+ * This software is released under the MIT License.
+ * https://opensource.org/licenses/MIT
+ */
+
+.tile-palette {
+ display: flex;
+ flex-direction: column;
+ flex-wrap: wrap;
+ gap: 6px;
+ max-height: 512px;
+}
+
+.tile-swatch {
+ position: relative;
+ width: 32px;
+ height: 32px;
+ border: 2px solid transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ padding: 0;
+}
+
+.tile-swatch.active {
+ border-color: #fff;
+}
+
+.tile-swatch-icon {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+}
+
+.dpad {
+ display: grid;
+ grid-template-columns: repeat(3, 2rem);
+ grid-template-rows: repeat(3, 2rem);
+ grid-template-areas:
+ ". n ."
+ "w . e"
+ ". s .";
+ gap: 2px;
+}
+
+.dpad-n { grid-area: n; }
+.dpad-w { grid-area: w; }
+.dpad-e { grid-area: e; }
+.dpad-s { grid-area: s; }
+.dpad button { padding: 0; }
+
+.tile-grid {
+ image-rendering: pixelated;
+ cursor: crosshair;
+ touch-action: none;
+ max-width: 100%;
+ height: auto;
+}
+
+#previewCanvas {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ height: auto;
+}
diff --git a/editor/client/public/map/map.js b/editor/client/public/map/map.js
new file mode 100644
index 00000000..52adedb8
--- /dev/null
+++ b/editor/client/public/map/map.js
@@ -0,0 +1,701 @@
+// Copyright (c) 2026 Dominic Masters
+//
+// This software is released under the MIT License.
+// https://opensource.org/licenses/MIT
+
+"use strict";
+
+(() => {
+ const SHAPES = [
+ { type: ChunkTerrain.TILE_SHAPE_NULL, name: "Erase", color: "#20232a" },
+ { type: ChunkTerrain.TILE_SHAPE_GROUND, name: "Ground", color: "#4caf50" },
+ { type: ChunkTerrain.TILE_SHAPE_RAMP_NORTH, name: "Ramp North", color: "#2196f3" },
+ { type: ChunkTerrain.TILE_SHAPE_RAMP_EAST, name: "Ramp East", color: "#03a9f4" },
+ { type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTH, name: "Ramp South", color: "#00bcd4" },
+ { type: ChunkTerrain.TILE_SHAPE_RAMP_WEST, name: "Ramp West", color: "#009688" },
+ { type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST, name: "Ramp NE", color: "#ff9800" },
+ { type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST, name: "Ramp NW", color: "#ff5722" },
+ { type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST, name: "Ramp SE", color: "#9c27b0" },
+ { type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST, name: "Ramp SW", color: "#e91e63" },
+ ];
+ const SHAPE_COLORS = Object.fromEntries(SHAPES.map((s) => [s.type, s.color]));
+
+ // Arrow direction per ramp, in canvas/screen space: north is up (dy < 0),
+ // east is right (dx > 0) - matches renderGrid()'s north-is-up convention.
+ const SHAPE_DIRECTIONS = {
+ [ChunkTerrain.TILE_SHAPE_RAMP_NORTH]: { dx: 0, dy: -1 },
+ [ChunkTerrain.TILE_SHAPE_RAMP_SOUTH]: { dx: 0, dy: 1 },
+ [ChunkTerrain.TILE_SHAPE_RAMP_EAST]: { dx: 1, dy: 0 },
+ [ChunkTerrain.TILE_SHAPE_RAMP_WEST]: { dx: -1, dy: 0 },
+ [ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST]: { dx: 1, dy: -1 },
+ [ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST]: { dx: -1, dy: -1 },
+ [ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST]: { dx: 1, dy: 1 },
+ [ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST]: { dx: -1, dy: 1 },
+ };
+
+ // Draws an arrow centered at (cx, cy) pointing toward the ramp's high
+ // side, so ramp direction is visible at a glance on both the palette
+ // swatches and the tile grid.
+ function drawArrow(ctx, cx, cy, size, dx, dy) {
+ const mag = Math.hypot(dx, dy) || 1;
+ const ux = dx / mag, uy = dy / mag;
+ const len = size * 0.32;
+ const tipX = cx + ux * len, tipY = cy + uy * len;
+ const tailX = cx - ux * len, tailY = cy - uy * len;
+
+ ctx.strokeStyle = "#ffffff";
+ ctx.fillStyle = "#ffffff";
+ ctx.lineWidth = Math.max(1, size * 0.08);
+ ctx.lineCap = "round";
+
+ ctx.beginPath();
+ ctx.moveTo(tailX, tailY);
+ ctx.lineTo(tipX, tipY);
+ ctx.stroke();
+
+ const headLen = size * 0.18;
+ const angle = Math.atan2(uy, ux);
+ const leftAngle = angle + Math.PI * 0.8;
+ const rightAngle = angle - Math.PI * 0.8;
+ ctx.beginPath();
+ ctx.moveTo(tipX, tipY);
+ ctx.lineTo(tipX + Math.cos(leftAngle) * headLen, tipY + Math.sin(leftAngle) * headLen);
+ ctx.lineTo(tipX + Math.cos(rightAngle) * headLen, tipY + Math.sin(rightAngle) * headLen);
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ let coord = { x: 0, y: 0, z: 0 };
+ let tiles = new Int32Array(ChunkTerrain.TILE_COUNT);
+ let meshes = [];
+ let chunkExists = false;
+ let currentZLevel = 0;
+ let selectedShape = ChunkTerrain.TILE_SHAPE_GROUND;
+ let zoomWorldH = 20;
+ let dirty = false;
+ let hoverTile = null;
+
+ let mapRenderer = null;
+ let terrainMesh = null;
+ let terrainTexturePromise = null;
+ let modelIndex = null;
+ let neighborChunks = [];
+ const modelCache = new Map();
+
+ const UNDO_LIMIT = 100;
+ let undoStack = [];
+
+ function markDirty() {
+ dirty = true;
+ }
+
+ function snapshotState() {
+ return {
+ tiles: tiles.slice(),
+ meshes: meshes.map((m) => ({ file: m.file, pos: m.pos.slice() })),
+ };
+ }
+
+ function pushUndo() {
+ undoStack.push(snapshotState());
+ if(undoStack.length > UNDO_LIMIT) undoStack.shift();
+ }
+
+ function undo() {
+ if(!undoStack.length) return;
+ const snap = undoStack.pop();
+ tiles = snap.tiles;
+ meshes = snap.meshes;
+ dirty = true;
+ renderGrid();
+ renderMeshList();
+ renderPreview();
+ updateStatus("Undid last change.");
+ }
+
+ // Returns true if it's OK to proceed (no unsaved changes, or the user
+ // confirmed discarding them).
+ function confirmDiscardIfDirty() {
+ if(!dirty) return true;
+ return window.confirm("You have unsaved changes to this chunk. Discard them?");
+ }
+
+ function statusLine() {
+ return document.getElementById("statusLine");
+ }
+
+ function updateStatus(msg, isError) {
+ const el = statusLine();
+ el.textContent = msg;
+ el.classList.toggle("text-danger", !!isError);
+ el.classList.toggle("text-muted", !isError);
+ }
+
+ async function apiReadJson(path) {
+ const res = await fetch(`/api/read?path=${encodeURIComponent(path)}`);
+ const data = await res.json();
+ return { ok: res.ok, data };
+ }
+
+ async function apiWrite(path, content) {
+ return fetch(`/api/write?path=${encodeURIComponent(path)}`, {
+ method: "POST",
+ body: JSON.stringify({ content }),
+ });
+ }
+
+ async function apiDelete(path) {
+ return fetch(`/api/delete?path=${encodeURIComponent(path)}`, { method: "POST" });
+ }
+
+ async function apiCompileChunk(x, y, z) {
+ const res = await fetch(`/api/compile-chunk?x=${x}&y=${y}&z=${z}`, { method: "POST" });
+ return res.json();
+ }
+
+ async function apiLs(path) {
+ const res = await fetch(`/api/ls?path=${encodeURIComponent(path)}`);
+ return res.json();
+ }
+
+ async function apiFind(path, ext) {
+ const res = await fetch(`/api/find?path=${encodeURIComponent(path)}&ext=${encodeURIComponent(ext)}`);
+ return res.json();
+ }
+
+ function base64ToBuffer(base64) {
+ const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
+ return bytes.buffer;
+ }
+
+ async function loadImageTexture(assetPath) {
+ const { ok, data } = await apiReadJson(assetPath);
+ if(!ok || data.error) return null;
+ const blob = new Blob([base64ToBuffer(data.content)], { type: "image/png" });
+ const bitmap = await createImageBitmap(blob);
+ return mapRenderer.createTextureFromImage(bitmap);
+ }
+
+ async function readDmfVertices(assetPath) {
+ const { ok, data } = await apiReadJson(assetPath);
+ if(!ok || data.error) throw new Error(`Failed to read ${assetPath}`);
+ return DMF.decode(base64ToBuffer(data.content)).floats;
+ }
+
+ async function ensureModelIndex() {
+ if(modelIndex) return modelIndex;
+ const res = await apiFind("assets/models", ".json");
+ modelIndex = new Map();
+ for(const file of res.files || []) {
+ const base = file.split("/").pop().replace(/\.json$/, "");
+ modelIndex.set(base, file);
+ }
+ return modelIndex;
+ }
+
+ // Resolves a chunk mesh entry's bare filename (e.g. "house_5_3.dmf") to
+ // its model JSON by basename, mirroring find_model() in
+ // tools/asset/chunk/__main__.py - the chunk JSON only ever stores a bare
+ // filename, not a path.
+ async function resolveModelForMeshFile(file) {
+ await ensureModelIndex();
+ const base = file.replace(/\.[^.]+$/, "");
+ const rel = modelIndex.get(base);
+ if(!rel) return null;
+
+ const modelPath = `assets/models/${rel}`;
+ if(modelCache.has(modelPath)) return modelCache.get(modelPath);
+
+ const promise = (async () => {
+ const { ok, data } = await apiReadJson(modelPath);
+ if(!ok || data.error) throw new Error(`Failed to read model ${modelPath}`);
+ const modelDef = JSON.parse(data.content);
+ const meshFloats = await readDmfVertices(`assets/${modelDef.mesh}`);
+ const mesh = mapRenderer.createMesh(meshFloats);
+ const texture = modelDef.texture ? await loadImageTexture(`assets/${modelDef.texture}`) : null;
+ const colorBytes = modelDef.color || [255, 255, 255, 255];
+ const color = colorBytes.map((c) => c / 255);
+ return { mesh, texture, color };
+ })();
+ modelCache.set(modelPath, promise);
+ return promise;
+ }
+
+ function chunkPaths(x, y, z) {
+ return {
+ raw: `assetsraw/chunks/chunk_${x}_${y}_${z}.json`,
+ dcf: `assets/chunks/${x}_${y}_${z}.dcf`,
+ terrainDmf: `assets/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`,
+ terrainModel: `assets/models/chunks/chunk_${x}_${y}_${z}_0.json`,
+ };
+ }
+
+ // Loads the 8 same-Z neighbors around `coord` (read-only, for context in
+ // the 3D preview) and pre-builds their render data so renderPreview()
+ // doesn't have to rebuild GL buffers on every paint stroke.
+ async function loadNeighborChunks() {
+ const offsets = [];
+ for(let dy = -1; dy <= 1; dy++) {
+ for(let dx = -1; dx <= 1; dx++) {
+ if(dx || dy) offsets.push({ dx, dy });
+ }
+ }
+
+ const results = await Promise.all(offsets.map(async ({ dx, dy }) => {
+ const nx = coord.x + dx, ny = coord.y + dy, nz = coord.z;
+ const { ok, data } = await apiReadJson(chunkPaths(nx, ny, nz).raw);
+ if(!ok || data.error) return null;
+
+ const json = JSON.parse(data.content);
+ const neighborTiles = new Int32Array(ChunkTerrain.TILE_COUNT);
+ for(const t of json.tiles || []) {
+ const [tx, ty, tz] = t.pos;
+ neighborTiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type;
+ }
+ const neighborMeshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] }));
+
+ const floats = ChunkTerrain.buildTerrainVerts(neighborTiles);
+ const terrain = floats.length ? { mesh: mapRenderer.createMesh(floats) } : null;
+
+ const models = [];
+ for(const m of neighborMeshes) {
+ try {
+ const resolved = await resolveModelForMeshFile(m.file);
+ if(resolved) models.push({ ...resolved, offset: m.pos });
+ } catch(e) {
+ console.warn("Failed to load neighbor mesh preview", m.file, e);
+ }
+ }
+
+ return {
+ offset: [dx * ChunkTerrain.CHUNK_WIDTH, dy * ChunkTerrain.CHUNK_HEIGHT, 0],
+ terrain,
+ models,
+ };
+ }));
+
+ neighborChunks = results.filter(Boolean);
+ }
+
+ async function loadChunk(x, y, z) {
+ coord = { x, y, z };
+ tiles = new Int32Array(ChunkTerrain.TILE_COUNT);
+ meshes = [];
+ chunkExists = false;
+
+ const { ok, data } = await apiReadJson(chunkPaths(x, y, z).raw);
+ if(ok && !data.error) {
+ chunkExists = true;
+ const json = JSON.parse(data.content);
+ for(const t of json.tiles || []) {
+ const [tx, ty, tz] = t.pos;
+ tiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type;
+ }
+ meshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] }));
+ }
+
+ await loadNeighborChunks();
+ dirty = false;
+ undoStack = [];
+
+ renderGrid();
+ renderMeshList();
+ renderPreview();
+ updateStatus(chunkExists
+ ? `Loaded chunk_${x}_${y}_${z}.json`
+ : `New (empty) chunk at ${x}, ${y}, ${z} - paint a tile and save to create it.`);
+ }
+
+ async function saveChunk() {
+ const tilesArr = [];
+ for(let z = 0; z < ChunkTerrain.CHUNK_DEPTH; z++) {
+ for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) {
+ for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) {
+ const type = tiles[ChunkTerrain.tileIndex(x, y, z)];
+ if(type && type !== ChunkTerrain.TILE_SHAPE_NULL) {
+ tilesArr.push({ pos: [x, y, z], type, tile: 0 });
+ }
+ }
+ }
+ }
+
+ const paths = chunkPaths(coord.x, coord.y, coord.z);
+
+ if(tilesArr.length === 0 && meshes.length === 0) {
+ await Promise.all(Object.values(paths).map((p) => apiDelete(p)));
+ chunkExists = false;
+ dirty = false;
+ updateStatus(`Chunk ${coord.x}, ${coord.y}, ${coord.z} is empty - deleted.`);
+ return;
+ }
+
+ const json = { tiles: tilesArr, meshes: meshes.map((m) => ({ file: m.file, pos: m.pos })) };
+ await apiWrite(paths.raw, JSON.stringify(json, null, 2));
+ chunkExists = true;
+ dirty = false;
+ updateStatus("Saved. Compiling...");
+
+ const result = await apiCompileChunk(coord.x, coord.y, coord.z);
+ if(result.ok) {
+ updateStatus(`Compiled OK.\n${result.stdout}`);
+ } else {
+ updateStatus(`Compile FAILED (code ${result.returncode}):\n${result.stderr}`, true);
+ }
+ }
+
+ function buildPalette() {
+ const el = document.getElementById("tilePalette");
+ el.innerHTML = "";
+ for(const s of SHAPES) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "tile-swatch" + (s.type === selectedShape ? " active" : "");
+ btn.style.background = s.color;
+ btn.title = s.name;
+ btn.addEventListener("click", () => {
+ selectedShape = s.type;
+ buildPalette();
+ });
+
+ const dir = SHAPE_DIRECTIONS[s.type];
+ if(dir) {
+ const icon = document.createElement("canvas");
+ icon.width = 32;
+ icon.height = 32;
+ icon.className = "tile-swatch-icon";
+ drawArrow(icon.getContext("2d"), 16, 16, 32, dir.dx, dir.dy);
+ btn.appendChild(icon);
+ }
+
+ el.appendChild(btn);
+ }
+ }
+
+ function canvasToTile(e, canvas) {
+ const rect = canvas.getBoundingClientRect();
+ const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
+ const px = (e.clientX - rect.left) * (canvas.width / rect.width);
+ const py = (e.clientY - rect.top) * (canvas.height / rect.height);
+ const x = Math.floor(px / cell);
+ const yFromTop = Math.floor(py / cell);
+ const y = ChunkTerrain.CHUNK_HEIGHT - 1 - yFromTop;
+ if(x < 0 || x >= ChunkTerrain.CHUNK_WIDTH || y < 0 || y >= ChunkTerrain.CHUNK_HEIGHT) return null;
+ return { x, y };
+ }
+
+ function renderGrid() {
+ const canvas = document.getElementById("gridCanvas");
+ const ctx = canvas.getContext("2d");
+ const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) {
+ for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) {
+ const type = tiles[ChunkTerrain.tileIndex(x, y, currentZLevel)];
+ const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - y) * cell;
+ ctx.fillStyle = SHAPE_COLORS[type] || "#15171b";
+ ctx.fillRect(x * cell, sy, cell, cell);
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
+ ctx.strokeRect(x * cell, sy, cell, cell);
+
+ const dir = SHAPE_DIRECTIONS[type];
+ if(dir) drawArrow(ctx, x * cell + cell / 2, sy + cell / 2, cell, dir.dx, dir.dy);
+ }
+ }
+
+ ctx.fillStyle = "#ffffff";
+ for(const m of meshes) {
+ const [mx, my, mz] = m.pos;
+ if(Math.round(mz) !== currentZLevel) continue;
+ const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell;
+ ctx.beginPath();
+ ctx.arc(mx * cell + cell / 2, sy + cell / 2, cell * 0.3, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ if(hoverTile) {
+ const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - hoverTile.y) * cell;
+ ctx.strokeStyle = "#ffeb3b";
+ ctx.lineWidth = 2;
+ ctx.strokeRect(hoverTile.x * cell + 1, sy + 1, cell - 2, cell - 2);
+ }
+ }
+
+ function hitTestMesh(e, canvas) {
+ const rect = canvas.getBoundingClientRect();
+ const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
+ const px = (e.clientX - rect.left) * (canvas.width / rect.width);
+ const py = (e.clientY - rect.top) * (canvas.height / rect.height);
+
+ let closest = -1;
+ let closestDist = cell * 0.5;
+ meshes.forEach((m, i) => {
+ const [mx, my, mz] = m.pos;
+ if(Math.round(mz) !== currentZLevel) return;
+ const cx = mx * cell + cell / 2;
+ const cy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell + cell / 2;
+ const dist = Math.hypot(px - cx, py - cy);
+ if(dist < closestDist) { closestDist = dist; closest = i; }
+ });
+ return closest;
+ }
+
+ function moveMeshTo(e, index) {
+ const canvas = document.getElementById("gridCanvas");
+ const t = canvasToTile(e, canvas);
+ if(!t) return;
+ meshes[index].pos[0] = t.x;
+ meshes[index].pos[1] = t.y;
+ markDirty();
+ renderGrid();
+ renderMeshList();
+ renderPreview();
+ }
+
+ function paintAt(e) {
+ const canvas = document.getElementById("gridCanvas");
+ const t = canvasToTile(e, canvas);
+ if(!t) return;
+ tiles[ChunkTerrain.tileIndex(t.x, t.y, currentZLevel)] = selectedShape;
+ markDirty();
+ renderGrid();
+ renderPreview();
+ }
+
+ function rebuildTerrainMesh() {
+ const floats = ChunkTerrain.buildTerrainVerts(tiles);
+ terrainMesh = floats.length ? mapRenderer.createMesh(floats) : null;
+ }
+
+ async function renderPreview() {
+ if(!mapRenderer) return;
+ rebuildTerrainMesh();
+
+ const models = [];
+ for(const m of meshes) {
+ try {
+ const resolved = await resolveModelForMeshFile(m.file);
+ if(resolved) models.push({ ...resolved, offset: m.pos });
+ } catch(e) {
+ console.warn("Failed to load mesh preview", m.file, e);
+ }
+ }
+
+ let terrainTexture = null;
+ if(terrainMesh || neighborChunks.some((n) => n.terrain)) {
+ if(!terrainTexturePromise) terrainTexturePromise = loadImageTexture("assets/tiles.png");
+ terrainTexture = await terrainTexturePromise;
+ }
+
+ const neighbors = neighborChunks.map((n) => ({
+ offset: n.offset,
+ terrain: n.terrain ? { mesh: n.terrain.mesh, texture: terrainTexture } : null,
+ models: n.models,
+ }));
+
+ mapRenderer.render({
+ target: [ChunkTerrain.CHUNK_WIDTH / 2, ChunkTerrain.CHUNK_HEIGHT / 2, currentZLevel],
+ worldH: zoomWorldH,
+ terrain: terrainMesh ? { mesh: terrainMesh, texture: terrainTexture } : null,
+ models,
+ neighbors,
+ highlight: hoverTile ? { x: hoverTile.x, y: hoverTile.y, z: currentZLevel } : null,
+ });
+ }
+
+ function renderMeshList() {
+ const ul = document.getElementById("meshList");
+ ul.innerHTML = "";
+ meshes.forEach((m, i) => {
+ const li = document.createElement("li");
+ li.className = "list-group-item d-flex align-items-center gap-2 flex-wrap";
+
+ const label = document.createElement("span");
+ label.className = "me-auto";
+ label.textContent = m.file;
+ li.appendChild(label);
+
+ ["X", "Y", "Z"].forEach((axis, axisIndex) => {
+ const input = document.createElement("input");
+ input.type = "number";
+ input.className = "form-control form-control-sm";
+ input.style.width = "5rem";
+ input.title = axis;
+ input.value = m.pos[axisIndex];
+ input.addEventListener("change", () => {
+ pushUndo();
+ m.pos[axisIndex] = parseFloat(input.value) || 0;
+ markDirty();
+ renderGrid();
+ renderPreview();
+ });
+ li.appendChild(input);
+ });
+
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "btn btn-sm btn-outline-danger";
+ btn.textContent = "Remove";
+ btn.addEventListener("click", () => {
+ pushUndo();
+ meshes.splice(i, 1);
+ markDirty();
+ renderMeshList();
+ renderGrid();
+ renderPreview();
+ });
+
+ li.appendChild(btn);
+ ul.appendChild(li);
+ });
+ }
+
+ async function buildMeshPicker() {
+ await ensureModelIndex();
+ const select = document.getElementById("meshPicker");
+ select.innerHTML = "";
+ for(const [base, rel] of [...modelIndex.entries()].sort()) {
+ const opt = document.createElement("option");
+ opt.value = base;
+ opt.textContent = rel;
+ select.appendChild(opt);
+ }
+ }
+
+ async function buildChunkBrowseList() {
+ const list = document.getElementById("chunkBrowseList");
+ list.innerHTML = "";
+ const res = await apiLs("assetsraw/chunks");
+ for(const entry of res.entries || []) {
+ const m = entry.name.match(/^chunk_(-?\d+)_(-?\d+)_(-?\d+)\.json$/);
+ if(!m) continue;
+ const [, x, y, z] = m;
+
+ const li = document.createElement("li");
+ const a = document.createElement("a");
+ a.className = "dropdown-item";
+ a.href = "#";
+ a.textContent = `${x}, ${y}, ${z}`;
+ a.addEventListener("click", (e) => {
+ e.preventDefault();
+ goToChunk(parseInt(x, 10), parseInt(y, 10), parseInt(z, 10));
+ });
+
+ li.appendChild(a);
+ list.appendChild(li);
+ }
+ }
+
+ function setCoordInputs(x, y, z) {
+ document.getElementById("coordX").value = x;
+ document.getElementById("coordY").value = y;
+ document.getElementById("coordZ").value = z;
+ }
+
+ function goToChunk(x, y, z) {
+ if(!confirmDiscardIfDirty()) return;
+ setCoordInputs(x, y, z);
+ loadChunk(x, y, z);
+ }
+
+ function bindEvents() {
+ const gridCanvas = document.getElementById("gridCanvas");
+ let painting = false;
+ let draggingMeshIndex = -1;
+
+ gridCanvas.addEventListener("mousedown", (e) => {
+ const hit = hitTestMesh(e, gridCanvas);
+ pushUndo();
+ if(hit !== -1) {
+ draggingMeshIndex = hit;
+ return;
+ }
+ painting = true;
+ paintAt(e);
+ });
+ window.addEventListener("mouseup", () => { painting = false; draggingMeshIndex = -1; });
+ gridCanvas.addEventListener("mousemove", (e) => {
+ hoverTile = canvasToTile(e, gridCanvas);
+ if(draggingMeshIndex !== -1) { moveMeshTo(e, draggingMeshIndex); return; }
+ if(painting) { paintAt(e); return; }
+ renderGrid();
+ renderPreview();
+ });
+ gridCanvas.addEventListener("mouseleave", () => {
+ hoverTile = null;
+ renderGrid();
+ renderPreview();
+ });
+
+ function setZLevel(z) {
+ currentZLevel = Math.max(0, Math.min(ChunkTerrain.CHUNK_DEPTH - 1, z));
+ document.getElementById("zLevel").value = currentZLevel;
+ document.getElementById("zLevelLabel").textContent = currentZLevel;
+ renderGrid();
+ renderPreview();
+ }
+
+ document.getElementById("zLevel").addEventListener("input", (e) => {
+ setZLevel(parseInt(e.target.value, 10));
+ });
+ document.getElementById("zLevelDown").addEventListener("click", () => setZLevel(currentZLevel - 1));
+ document.getElementById("zLevelUp").addEventListener("click", () => setZLevel(currentZLevel + 1));
+
+ document.getElementById("btnLoad").addEventListener("click", () => {
+ const x = parseInt(document.getElementById("coordX").value, 10) || 0;
+ const y = parseInt(document.getElementById("coordY").value, 10) || 0;
+ const z = parseInt(document.getElementById("coordZ").value, 10) || 0;
+ goToChunk(x, y, z);
+ });
+
+ // NORTH=+Y, EAST=+X (matches tile-shape convention used by the tile
+ // grid and tools/asset/chunk/__main__.py).
+ document.getElementById("navN").addEventListener("click", () => goToChunk(coord.x, coord.y + 1, coord.z));
+ document.getElementById("navS").addEventListener("click", () => goToChunk(coord.x, coord.y - 1, coord.z));
+ document.getElementById("navE").addEventListener("click", () => goToChunk(coord.x + 1, coord.y, coord.z));
+ document.getElementById("navW").addEventListener("click", () => goToChunk(coord.x - 1, coord.y, coord.z));
+ document.getElementById("navUp").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z + 1));
+ document.getElementById("navDown").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z - 1));
+
+ document.getElementById("btnSave").addEventListener("click", () => {
+ saveChunk().catch((e) => updateStatus(`Save failed: ${e.message}`, true));
+ });
+
+ document.getElementById("btnAddMesh").addEventListener("click", () => {
+ const base = document.getElementById("meshPicker").value;
+ if(!base) return;
+ const pos = [
+ parseFloat(document.getElementById("meshX").value) || 0,
+ parseFloat(document.getElementById("meshY").value) || 0,
+ parseFloat(document.getElementById("meshZ").value) || 0,
+ ];
+ pushUndo();
+ meshes.push({ file: `${base}.dmf`, pos });
+ markDirty();
+ renderMeshList();
+ renderGrid();
+ renderPreview();
+ });
+
+ document.getElementById("previewCanvas").addEventListener("wheel", (e) => {
+ e.preventDefault();
+ zoomWorldH = Math.min(60, Math.max(4, zoomWorldH + Math.sign(e.deltaY)));
+ renderPreview();
+ }, { passive: false });
+
+ window.addEventListener("keydown", (e) => {
+ if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
+ e.preventDefault();
+ undo();
+ }
+ });
+ }
+
+ document.addEventListener("DOMContentLoaded", async () => {
+ buildPalette();
+ mapRenderer = MapRenderer.create(document.getElementById("previewCanvas"));
+ bindEvents();
+ await Promise.all([buildChunkBrowseList(), buildMeshPicker()]);
+ await loadChunk(coord.x, coord.y, coord.z);
+ });
+})();
diff --git a/editor/client/public/map/renderer.js b/editor/client/public/map/renderer.js
new file mode 100644
index 00000000..0c5976a7
--- /dev/null
+++ b/editor/client/public/map/renderer.js
@@ -0,0 +1,293 @@
+// Copyright (c) 2026 Dominic Masters
+//
+// This software is released under the MIT License.
+// https://opensource.org/licenses/MIT
+
+"use strict";
+
+// MapRenderer - a minimal, dependency-free WebGL renderer for the chunk
+// preview pane. The camera formula is ported 1:1 from
+// src/dusk/scene/overworld/sceneoverworld.c (lines 62-81) so the preview
+// approximates the game's own fixed-angle 3rd-person camera: the eye sits
+// worldH units "behind" the target along Y and a proportional distance
+// "above" along Z, always looking at the target, up = (0, 1, 0).
+//
+// The material model mirrors SHADER_UNLIT: a texture sampler multiplied by
+// a flat RGBA tint, with a 1x1 white fallback texture standing in for
+// untextured models.
+
+const MapRenderer = (() => {
+ const VERT_SRC = `
+ attribute vec2 aUV;
+ attribute vec3 aPos;
+ uniform mat4 uModel;
+ uniform mat4 uView;
+ uniform mat4 uProjection;
+ varying vec2 vUV;
+ void main() {
+ vUV = aUV;
+ gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
+ }
+ `;
+
+ const FRAG_SRC = `
+ precision mediump float;
+ varying vec2 vUV;
+ uniform sampler2D uTexture;
+ uniform vec4 uColor;
+ void main() {
+ gl_FragColor = texture2D(uTexture, vUV) * uColor;
+ }
+ `;
+
+ function mat4Identity(out) {
+ out.fill(0);
+ out[0] = out[5] = out[10] = out[15] = 1;
+ return out;
+ }
+
+ function mat4Translation(out, v) {
+ mat4Identity(out);
+ out[12] = v[0];
+ out[13] = v[1];
+ out[14] = v[2];
+ return out;
+ }
+
+ function mat4Perspective(out, fovy, aspect, near, far) {
+ const f = 1.0 / Math.tan(fovy / 2);
+ out.fill(0);
+ out[0] = f / aspect;
+ out[5] = f;
+ out[10] = (far + near) / (near - far);
+ out[11] = -1;
+ out[14] = (2 * far * near) / (near - far);
+ return out;
+ }
+
+ function vec3Sub(a, b) {
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
+ }
+
+ function vec3Cross(a, b) {
+ return [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ];
+ }
+
+ function vec3Normalize(a) {
+ const len = Math.hypot(a[0], a[1], a[2]) || 1;
+ return [a[0] / len, a[1] / len, a[2] / len];
+ }
+
+ function mat4LookAt(out, eye, center, up) {
+ const zAxis = vec3Normalize(vec3Sub(eye, center));
+ const xAxis = vec3Normalize(vec3Cross(up, zAxis));
+ const yAxis = vec3Cross(zAxis, xAxis);
+
+ out[0] = xAxis[0]; out[1] = yAxis[0]; out[2] = zAxis[0]; out[3] = 0;
+ out[4] = xAxis[1]; out[5] = yAxis[1]; out[6] = zAxis[1]; out[7] = 0;
+ out[8] = xAxis[2]; out[9] = yAxis[2]; out[10] = zAxis[2]; out[11] = 0;
+ out[12] = -(xAxis[0] * eye[0] + xAxis[1] * eye[1] + xAxis[2] * eye[2]);
+ out[13] = -(yAxis[0] * eye[0] + yAxis[1] * eye[1] + yAxis[2] * eye[2]);
+ out[14] = -(zAxis[0] * eye[0] + zAxis[1] * eye[1] + zAxis[2] * eye[2]);
+ out[15] = 1;
+ return out;
+ }
+
+ // Ported from sceneoverworld.c:62-81. Returns { eye, view }.
+ function computeCamera(target, worldH) {
+ const fov = Math.PI / 4; // glm_rad(45.0f)
+ const zDist = (worldH * 0.5) / Math.tan(fov * 0.5);
+ const offset = -worldH;
+
+ const eye = [target[0], target[1] + offset, target[2] + zDist];
+ const view = mat4LookAt(new Float32Array(16), eye, target, [0, 1, 0]);
+ return { eye, view, fov };
+ }
+
+ function compileShader(gl, type, src) {
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, src);
+ gl.compileShader(shader);
+ if(!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ const info = gl.getShaderInfoLog(shader);
+ gl.deleteShader(shader);
+ throw new Error(`Shader compile failed: ${info}`);
+ }
+ return shader;
+ }
+
+ function create(canvas) {
+ const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
+ if(!gl) throw new Error("WebGL is not available in this browser");
+
+ const program = gl.createProgram();
+ gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERT_SRC));
+ gl.attachShader(program, compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC));
+ gl.linkProgram(program);
+ if(!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`);
+ }
+
+ const attribs = {
+ aUV: gl.getAttribLocation(program, "aUV"),
+ aPos: gl.getAttribLocation(program, "aPos"),
+ };
+ const uniforms = {
+ uModel: gl.getUniformLocation(program, "uModel"),
+ uView: gl.getUniformLocation(program, "uView"),
+ uProjection: gl.getUniformLocation(program, "uProjection"),
+ uTexture: gl.getUniformLocation(program, "uTexture"),
+ uColor: gl.getUniformLocation(program, "uColor"),
+ };
+
+ const whiteTexture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, whiteTexture);
+ gl.texImage2D(
+ gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
+ new Uint8Array([255, 255, 255, 255]),
+ );
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+
+ gl.enable(gl.DEPTH_TEST);
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+ // Terrain/mesh winding isn't guaranteed consistent relative to this
+ // preview's fixed camera angle, so backface culling stays off - this
+ // is an authoring preview, not a performance-sensitive renderer, and
+ // an invisible mesh is a worse failure mode than a visible backface.
+
+ // Uploads interleaved [u, v, x, y, z] float data (DMF/terrain layout)
+ // into a GL buffer. Returns { buffer, vertexCount }.
+ function createMesh(floats) {
+ const buffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, floats, gl.STATIC_DRAW);
+ return { buffer, vertexCount: floats.length / 5 };
+ }
+
+ function createTextureFromImage(image) {
+ const texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ return texture;
+ }
+
+ function drawMesh(mesh, model, texture, color, depthWrite, mode) {
+ gl.depthMask(depthWrite !== false);
+ gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
+ gl.enableVertexAttribArray(attribs.aUV);
+ gl.vertexAttribPointer(attribs.aUV, 2, gl.FLOAT, false, 20, 0);
+ gl.enableVertexAttribArray(attribs.aPos);
+ gl.vertexAttribPointer(attribs.aPos, 3, gl.FLOAT, false, 20, 8);
+
+ gl.uniformMatrix4fv(uniforms.uModel, false, model);
+ gl.uniform4f(uniforms.uColor, color[0], color[1], color[2], color[3]);
+
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, texture || whiteTexture);
+ gl.uniform1i(uniforms.uTexture, 0);
+
+ gl.drawArrays(mode || gl.TRIANGLES, 0, mesh.vertexCount);
+ }
+
+ // Builds a 4-vertex outline of the tile at (x, y, z), lifted slightly
+ // to avoid z-fighting with the terrain surface beneath it.
+ function buildHighlightMesh(x, y, z) {
+ const eps = 0.02;
+ return createMesh(new Float32Array([
+ 0, 0, x, y, z + eps,
+ 0, 0, x + 1, y, z + eps,
+ 0, 0, x + 1, y + 1, z + eps,
+ 0, 0, x, y + 1, z + eps,
+ ]));
+ }
+
+ // Neighboring chunks are drawn dimmer and semi-transparent, so they
+ // read as context rather than part of the chunk being edited.
+ const NEIGHBOR_ALPHA = 0.35;
+ const NEIGHBOR_DARKEN = 0.5;
+
+ // scene = {
+ // target: [x, y, z], worldH: number,
+ // terrain: { mesh, texture } | null,
+ // models: [{ mesh, texture, color: [r,g,b,a in 0..1], offset: [x,y,z] }],
+ // neighbors: [{
+ // offset: [x,y,z],
+ // terrain: { mesh, texture } | null,
+ // models: [{ mesh, texture, color, offset }],
+ // }],
+ // highlight: { x, y, z } | null,
+ // }
+ function render(scene) {
+ const width = canvas.width, height = canvas.height;
+ gl.viewport(0, 0, width, height);
+ gl.clearColor(0.16, 0.18, 0.22, 1);
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+ gl.useProgram(program);
+ gl.depthMask(true);
+
+ const { view, fov } = computeCamera(scene.target, scene.worldH);
+ const projection = mat4Perspective(new Float32Array(16), fov, width / height, 0.1, 1000);
+ gl.uniformMatrix4fv(uniforms.uProjection, false, projection);
+ gl.uniformMatrix4fv(uniforms.uView, false, view);
+
+ const identity = mat4Identity(new Float32Array(16));
+
+ if(scene.terrain) {
+ drawMesh(scene.terrain.mesh, identity, scene.terrain.texture, [1, 1, 1, 1], true);
+ }
+
+ for(const model of scene.models) {
+ const modelMatrix = mat4Translation(new Float32Array(16), model.offset);
+ drawMesh(model.mesh, modelMatrix, model.texture, model.color, true);
+ }
+
+ for(const neighbor of scene.neighbors || []) {
+ if(neighbor.terrain) {
+ const modelMatrix = mat4Translation(new Float32Array(16), neighbor.offset);
+ drawMesh(
+ neighbor.terrain.mesh, modelMatrix, neighbor.terrain.texture,
+ [NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_ALPHA], false,
+ );
+ }
+
+ for(const model of neighbor.models) {
+ const worldOffset = [
+ neighbor.offset[0] + model.offset[0],
+ neighbor.offset[1] + model.offset[1],
+ neighbor.offset[2] + model.offset[2],
+ ];
+ const modelMatrix = mat4Translation(new Float32Array(16), worldOffset);
+ const color = [
+ model.color[0] * NEIGHBOR_DARKEN,
+ model.color[1] * NEIGHBOR_DARKEN,
+ model.color[2] * NEIGHBOR_DARKEN,
+ model.color[3] * NEIGHBOR_ALPHA,
+ ];
+ drawMesh(model.mesh, modelMatrix, model.texture, color, false);
+ }
+ }
+
+ if(scene.highlight) {
+ const { x, y, z } = scene.highlight;
+ const highlightMesh = buildHighlightMesh(x, y, z);
+ drawMesh(highlightMesh, identity, null, [1, 0.92, 0.23, 1], false, gl.LINE_LOOP);
+ }
+ }
+
+ return { gl, createMesh, createTextureFromImage, render };
+ }
+
+ return Object.freeze({ create, computeCamera });
+})();
diff --git a/editor/client/public/template.html b/editor/client/public/template.html
index 976e6fe4..5ea9aba2 100644
--- a/editor/client/public/template.html
+++ b/editor/client/public/template.html
@@ -4,8 +4,20 @@
Dusk Map Editor
+
-
+
+
+