213 lines
17 KiB
Markdown
213 lines
17 KiB
Markdown
# Level editor – geometry tools (`editor.tools.geometry`)
|
||
|
||
Plugin `GeometryEditorPlugin` (phase 3, discovered automatically). Everything the owner asked for
|
||
under "geometry": draw primitives, resize/turn/scale/mirror them, align and repeat, paint tiles,
|
||
paint collision materials, import meshes, reuse geometry across levels, export to Blender, and
|
||
geometry checks. Screenshots: `img/editor/geo_*.jpg`.
|
||
|
||

|
||
|
||
## User guide
|
||
|
||
### Brush tool (B) – draw primitives
|
||
Toolbar button / **Geometry → Brush tool**. The options bar has one button per primitive, the
|
||
Inspector shows the same palette with names plus the options of the chosen primitive (textures with
|
||
a swatch picker, thickness, steps, segments, collision, solid sides, material).
|
||
|
||
| Primitive | How to draw | Brush |
|
||
|---|---|---|
|
||
| Block, Platform, Ramp, Stairs, Round (cylinder), Floor, Water, Death plane, Invisible block | drag the footprint, then move the mouse up/down for the height and click (Enter = height field, Esc cancels) | BOX, PLATFORM, RAMP, STAIRS, CYLINDER (new), FLOOR, SEA, KILL_PLANE, BOX invisible |
|
||
| Wall, Invisible wall | drag along the wall – any direction, snapped to the angle step; then the height | BOX (turned by `rotation` if diagonal) |
|
||
| Pillar, Round | click = default size, drag = size | PILLAR, CYLINDER |
|
||
| Fence | click the corners, double-click / Enter finishes | FENCE |
|
||
| Tree | click | TREE |
|
||
|
||
* Drawing happens on the surface under the cursor (top of other geometry, water, the grid) or on a
|
||
**work plane** at a fixed height. Footprints snap to the move step (Ctrl inverts snapping).
|
||
* Ramps and stairs rise in the main drag direction. Stairs start at the floor in front (`y0`), the
|
||
walkable invisible slope makes them climbable (the engine has no step-up logic).
|
||
* **Handles**: a selected brush shows cubes on its faces (orange = sides, green = top/bottom). Drag
|
||
one to resize; the opposite face stays where it is – also for turned brushes.
|
||
* `[` / `]` turn the selection by 90°, Page Up / Page Down raise / lower it by the snap step.
|
||
* Status bar shows the size while drawing/resizing.
|
||
|
||
### Rotate / scale / arrange
|
||
* **Rotate (E)**: the green ring turns everything about the vertical axis (as before). The red and
|
||
blue rings now turn **brushes freely in 3D** (`rotation` x·y·z Euler angles about the brush's
|
||
centre; meshes about their origin). Tiles and objects only turn about the vertical axis in the
|
||
engine – the status bar says so and they are left alone.
|
||
* **Scale (R)** on brushes changes the primitive's own sizes in its frame (width/depth/height,
|
||
thickness, radius, crown …; meshes stretch per axis) – the bottom centre stays.
|
||
* Quarter turns and mirrors of unrotated brushes change their parameters (texture stays aligned to
|
||
the grid; ramp/stairs directions, sides, per-face settings follow).
|
||
* **Geometry menu**: Turn left/right 90° (Ctrl+[ / Ctrl+]), Mirror X / Z (about the selection
|
||
centre; tiles and objects are moved and turned – tile models themselves cannot be mirrored),
|
||
**Array duplicate…** (Ctrl+Shift+D: n copies with offset and turn per copy, optionally circular
|
||
about the selection centre), **Align ▸** west/centre/east, bottom/middle/top, north/centre/south to
|
||
the last selected item, distribute along X / Z, **Grid & snapping…** (any move/angle/scale step,
|
||
grid on/off), **Death plane height…** (moves death-plane brushes and the original level's
|
||
material-30 tiles, or adds a plane over the whole grid), **Re-import mesh…**, **Save selection to
|
||
library…**.
|
||
* Inspector section **Brush shape** (one brush): size, rotation X/Y/Z fields, turn/mirror buttons,
|
||
*Visible* (off = invisible collision volume) and a face table: collision on/off and material per
|
||
face (top, bottom, north, east, south, west). Section **Tile**: turn/raise buttons, "use palette
|
||
model", tile swap (see below) and the honest note that tiles only turn about y and cannot scale.
|
||
|
||
### Tiles tool (T) – original-style levels
|
||
Modes **Paint / Pick / Erase / Raise-lower / Swap model**, palette of the level's tile models with
|
||
thumbnails in the Inspector (filter, "only used models"). Paint puts the palette model into the cell
|
||
under the cursor (on the surface height, or at a fixed height), rotation 0/90/180/270 (`[` `]`),
|
||
height Page Up/Down. Painting onto a tile at the same height swaps its model instead of stacking
|
||
(keeps tile indices that strat scripts use). Shift+click = pick (model, height, rotation). New tiles
|
||
copy the vertex colours of a tile with the same model. **Tile swaps** (doors/bridges, WFPC 0x10000):
|
||
the Tile section shows the swap entry, sets the alternative model from the palette, removes or adds
|
||
a swap; layer "Tile swap alternatives" draws the alternative model in cyan.
|
||
One drag is **one undo step** (2026-11-07): paint, erase and swap issue `cmd.Stroke` commands with
|
||
the gesture's key; `Stroke.mergeWith` concatenates them (a `Batch` only merges commands of the same
|
||
shape, `DeferredRemove`/`AddNode` never merge). The collision tool's paint/toggle strokes use the
|
||
same command.
|
||
|
||
**No collision (visual only)** (2026-11-07): tile property `noCollision` (Inspector → Build, "No
|
||
collision"). `WadBuilder.visualOnly` replaces such tiles in the build by copies whose model (and
|
||
swap models) is a copy without collision polygons (`gen:visual:<model id>`, one per model, appended
|
||
to TRAK); other tiles of the model keep it. `Tile.worldCollisionPolygons()` is empty for them, so
|
||
`CollisionIndex`, checks, `FloorFinder`, `Ground` and the death-plane coverage ignore them; the
|
||
renderer draws them without the collision overlay. Default off = byte-identical builds.
|
||
In game (apptest :23, `NoCollisionDemo`, TestLevel 6): two walls baked from box brushes into scene
|
||
tiles, the north one visual only – the llama walks through it, the south one stops it
|
||
(`img/editor/geo_nocollision_game.jpg`, top row through, bottom row blocked).
|
||
|
||
### Collision tool (K)
|
||
Turns the collision layer on. Hovering highlights the polygon (or, on brushes, the whole face) under
|
||
the cursor and names its material; click/drag **paints** the material from the palette (Inspector
|
||
list with the known materials of `debugviz.md`: 0 normal, **16 exit floor**, **30 no floor /
|
||
death**, 17/18 selective, specials 1…255 "meaning unknown", "Other…" for any number). Shift+click
|
||
picks a material. **Toggle collision** mode switches a brush face's collision off/on. Tile models
|
||
are painted per polygon; shared models: change the model (all tiles) or give the tile its own copy.
|
||
Invisible collision volumes (Invisible wall/block, colliders) are drawn translucent red (layer
|
||
"Invisible collision volumes").
|
||
|
||
### Mesh import (File → Import mesh)
|
||
File chooser, then a dialog: rotatable preview with a 1-cell grid and a llama-sized marker, source
|
||
info, up axis, unit scale and "fit largest side to n cells", turn, mirror, stand on the ground,
|
||
texture size and UV precision, double-sided, lit, collision **from the mesh / simplified invisible
|
||
box / none** + material, warnings (densest cell vs. the 8000-vertex limit, larger than the grid).
|
||
Placement: click in the viewport (a box follows the cursor) or at the view centre.
|
||
**Geometry → Re-import mesh…** reloads the selected mesh brush's file (remembered) or another file
|
||
and keeps the placement.
|
||
|
||
### Library (cross-level reuse) and OBJ export
|
||
* **Save selection to library…** (Geometry menu or Assets → **Library** tab): selected tiles
|
||
(original TRAK models with collision and vertex colours), brushes and meshes (compiled to tiles)
|
||
→ `<data>/library/<name>.llamamesh`: a zip with `library.json` (models: vertices, faces, planes,
|
||
three collision groups; tiles: offsets from the anchor cell, rotation, vertex colours) and one PNG
|
||
per used texture (pixels embedded – owner decision: game data may be embedded). Death-plane tiles
|
||
are skipped.
|
||
* **Insert**: drag an entry from the Library tab into the viewport (anchor = cell under the drop
|
||
point) or double-click / Insert (view centre). Textures with identical pixels in the target level
|
||
are reused (same base: nothing new), the others are packed onto new texture pages (blend mode
|
||
kept); identical tile models are reused. Works on any base level.
|
||
* **File → Export selection as OBJ…**: OBJ (export space = the importer's convention) + MTL + one
|
||
PNG per texture, UVs per triangle (the engine's texture-rectangle halves), vertex colours as
|
||
`v x y z r g b` (0x80 = 1.0), optionally `…_collision.obj` (polygons grouped by material). The
|
||
dialog notes that the textures are game data (personal use). The importer reads it back
|
||
(round trip verified: same shape and textures, `img/editor/geo_library_insert.jpg` – right the
|
||
library tiles, left the re-imported OBJ as a mesh brush).
|
||
|
||
### glTF export (File → Export level as glTF…, `editor.tools.gltf`)
|
||
* `GltfExport.collect(scene, Options(objects, gameView), skip)` runs a private `SceneRenderer` on the
|
||
scene and takes exactly what the viewport draws: tile instances (scene tiles + compiled brushes and
|
||
meshes), optionally object models; `excluded` tiles and (option) hidden nodes are skipped.
|
||
Vertices are baked to world (export) space, deduplicated per primitive; one primitive per
|
||
page × blend class × double-sided, grouped into the meshes/nodes `level` and `objects`.
|
||
* Attributes: POSITION (exact min/max), NORMAL (unit; zero vertex normals → face normal; degenerate
|
||
triangles dropped), TEXCOORD_0 (page-normalised, v = 0 at the top as in glTF), COLOR_0 (u8
|
||
normalised RGBA = engine vertex colour ×2 clamped × flat/translucent face colour, as the viewport
|
||
shader). Materials: base colour texture = page PNG (embedded bufferView), `MASK` 0.5 for pages
|
||
with cut-out pixels, `BLEND` for alpha/additive classes (glTF has no additive), `doubleSided`,
|
||
`KHR_materials_unlit` (optional extension). u16 indices up to 65535 vertices, else u32.
|
||
* `.glb` (JSON + BIN chunk) or `.gltf` (one file, buffer as base64 data URI).
|
||
* Axes: export space (y up, z = −game z) like the OBJ export and the mesh importer, so a round trip
|
||
through **Import mesh** is exact. **Game orientation** mirrors z (and winding) for viewers – see
|
||
the open handedness question below.
|
||
* Verified: `GltfExportTest` (structural validation as the Khronos validator would: GLB header and
|
||
chunk padding, view/accessor bounds, equal attribute counts, exact POSITION min/max, unit normals,
|
||
UV range, index range, PNGs decode; .gltf data URI; round trip through `MeshImport` with the same
|
||
triangle count, bounds, textures and colours; mirrored bounds; objects; excluded tiles; brush box).
|
||
The official validator (npm) could not be run here (external code not allowed). **Blender 2.9
|
||
(local install)** imports the village .glb without complaint: meshes `level` 26 355 tris /
|
||
`objects` 9 684, 16 + 15 materials, 14 images, vertex colours and UVs
|
||
(`img/editor/geo_gltf_blender.jpg`, Workbench render); the editor's own Import mesh reads it back
|
||
(26 403 triangles, 16 materials, 15 textured, `img/editor/geo_gltf_export.jpg`).
|
||
Village: 36 087 triangles with objects, 2.9 MB .glb; without objects 2.1 MB.
|
||
|
||
### Checks (Level → Check, Build)
|
||
Tiles outside the grid; tile models (also compiled brush/mesh tiles) over **8000 vertices**;
|
||
**overlapping** identical tiles; **no floor collision under the player start** (cell-based like the
|
||
engine) or start far above the floor; **no exit floor (16)** in the LevelDoor's cell; **gaps** < 0.2
|
||
between neighbouring floors at the same height; brush compiler warnings.
|
||
|
||
## Design / implementation
|
||
|
||
* **Brush math** (`BrushMath`, pure, unit-tested): footprint → parameters (`Primitive.params`),
|
||
handles + `drag` (opposite face kept in world space via `keepWorld`), `rotate` (JOML matrices,
|
||
`getEulerAnglesXYZ`; quarter turns parametric), `scale`, `mirror` (local mirror + M·R·M), diff-based
|
||
commands (`Edits.set` with merge keys → one undo step per gesture). `BrushTransformer` replaces the
|
||
built-in brush transformer (position = bottom centre of the pivot column, yaw = rotation[1]).
|
||
`RotateTool3D` wraps the built-in Rotate tool (same id) and handles the non-vertical rings.
|
||
* **Generic brush parameters** (all kinds except SEA, KILL_PLANE, COLLIDER): `rotation` {x, y, z}°,
|
||
`visible`, `collFaces` ("tbnesw"), `faceMats` int[6] (−1 = brush material); MESH also `stretch`.
|
||
Applied by `BrushCompiler.postProcess` on the quads a brush added (`LevelBuilder.editQuads`/
|
||
`editSolids`): face class from the quad's normal in the brush frame, then rotation about
|
||
`Brush.pivot()`; default values leave the build byte-identical (golden/test-world tests pass).
|
||
* `CollisionIndex`: world polygons of scene tiles + compiled brush tiles (cached per scene,
|
||
invalidated by scene events) for picking, highlighting, checks.
|
||
* `MeshLibrary` (save/load/insert/OBJ), `LibraryTab` (asset tab + drag & drop kind `geolib`).
|
||
|
||
### Shared-file changes (backwards compatible)
|
||
| File | Change |
|
||
|---|---|
|
||
| `levelbuild/LevelBuilder` | `QuadEdit`, `quadCount/solidCount`, `editQuads/editSolids` (editor post-processing), `cylinder(...)` primitive |
|
||
| `editor/model/BrushKind` | kind `CYLINDER`; generic params (above); `FACES`, `transformable()`; INT_ARRAY params; STAIRS `y0` label "Start height (floor in front)" |
|
||
| `editor/model/Brush` | int[] defaults cloned; `bounds()` = rotated `localBounds()` (now includes platform/pillar/cylinder/tree/fence/mesh sizes), `pivot()`, `rotation()`, `hasRotation()`, `rotationMatrix()`, `rotate()` |
|
||
| `editor/io/BrushCompiler` | CYLINDER replay, `postProcess`, `faceClass/frontNormal`, mesh `stretch` (+ mirrored winding) |
|
||
| `editor/io/MeshImport` | bug fix: the source folder is normalised, otherwise a path with `..` silently dropped the MTL and textures (meshes rendered untextured after saving) |
|
||
|
||
## Verification
|
||
* Unit tests `BrushMathTest` (rotation matrix = JOML, Euler round trip, primitives from footprints,
|
||
diagonal wall end points, quarter turns = free 90° turn, mirror involution incl. rotated shapes,
|
||
handles keep the opposite face also when turned, scaling, transformer; compiled per-face material
|
||
16 / sides without collision / invisible = collision only / 45° walls / tilted top / cylinder) and
|
||
`GeometryChecksTest` (gap, player-start floor, exit floor; library save→load→insert into the same
|
||
level (textures reused) and into a world-4 base (textures imported, builds)).
|
||
* UI on Xvfb: `geo_draw` (ramp preview), `geo_handles`, `geo_rotate3d` (blue ring turned a ramp
|
||
60°, inspector updates live), `geo_collision` (hover highlight, palette), `geo_tiles` (palette +
|
||
cell preview, village), `geo_import` (dialog), `geo_placed`, `geo_menu` (Geometry ▸ Align),
|
||
`geo_library`, `geo_build_ok`.
|
||
* **In game** (apptest, Wine; display :21 because :5 was taken by another agent's session): test
|
||
level "Geometry Test" (TestLevel chapter 4, built with the same code paths, `GeoDemo`): start
|
||
plaza → ramp → platform → stairs → round platform → **imported OBJ bridge** → goal block whose top
|
||
face is painted **16** with a LevelDoor → walked through in one run, the door fired, level summary
|
||
shown (`geo_game_course.jpg`). **Death floor** (FLOOR material 30): stepping on it = death and
|
||
respawn (`geo_game_death.jpg`). **Invisible wall** at the plaza edge stops the llama
|
||
(`geo_game_invisible_wall.jpg`). **Library**: a village piece (9 tiles, 44 textures) saved and
|
||
inserted into new levels on the village base (textures reused) and on the world-4 base t1l4m001
|
||
(textures imported, 3 new pages): both build and load in game (`geo_game_library.jpg`); also built
|
||
on the river base t1l3m001 (no Llama placeable there, so not played).
|
||
|
||
## Known limits
|
||
* Collision materials of brushes are per face class, not per individual polygon; tile models per
|
||
polygon. Collision of a whole tile can be switched off ("No collision"), single tile-model polygons cannot.
|
||
* Water and death planes are cell based: resize/move/90° only. Gaps check only unrotated
|
||
box/platform/floor tops.
|
||
* Library insert keeps tiles cell-aligned (anchor = whole cells); objects are not part of the library
|
||
(see the objects agent's prefabs / object catalogue).
|
||
* glTF export: whole level only (no "selection" variant), no collision export (the OBJ export has it),
|
||
no texture animations, additive surfaces become plain alpha blending. **Handedness**: the editor
|
||
view is left-handed like the game (editor-render.md), but the mesh importer and the OBJ/glTF
|
||
exports treat file coordinates as export space – so a right-handed tool (Blender) shows exported
|
||
levels mirrored relative to the game, and an asymmetric model imported from Blender probably
|
||
appears mirrored in game. Not verified in game; "Game orientation" in the glTF export is the
|
||
workaround for viewing.
|
||
* `TextureImport` packs imported textures at one size per insert (largest texture, ≤ 256).
|