272 lines
22 KiB
Markdown
272 lines
22 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 on the readable model name or number with the outliner's
|
||
whole-number rule, `ReadableNames.matches`; "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 (mouse wheel zooms; framed on the main mass –
|
||
`Preview.focusBounds`: per axis the vertices within 2.5 IQR of the middle half (Tukey fences), the full
|
||
bounds unless they are over 1.5× larger; since 2026-11-07 also minus the smaller vertex group beyond a gap
|
||
wider than half the extent and twice the kept side, holding under half the other side's vertices – a stray triangle on a
|
||
low-poly box is 3 of 11 vertices and passes the fences; **Fit** uses the same bounds, and a hint under
|
||
"Largest side" names the main part's size when pieces are ignored, `geo_import_fit_stray.jpg`; `geo_import_focus.jpg`: house with a triangle 400 cells away, `geo_import_zoomed_out.jpg`) (shown as in game; flat shading with a small z-buffer at
|
||
twice the panel size, `PreviewRaster` – before 2026-11-07 the triangles were sorted by centroid and hidden
|
||
faces showed as slivers) 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. Tiles with **No collision** store `"noCollision": true` (the model keeps its collision, so
|
||
the inserted tile shares/dedups the model and the flag can be switched off again); insert sets the
|
||
flag (`GeometryChecksTest.libraryKeepsNoCollision`). Brush-compiled tiles have no such flag.
|
||
* **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 (game space = file 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, collision, only), 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.
|
||
* **Selection only** (2026-11-07): `only` = the selected nodes; an instance is exported when its owner
|
||
(tile, brush that the compiled tile belongs to, object) is in it. Nothing exportable → status message,
|
||
no file.
|
||
* **Collision as separate mesh** (2026-11-07): mesh/node `collision` with one primitive per collision
|
||
material (`collision_<mat>`, unlit base colour: 0 grey, 16 green, 30 red, others by hue, double-sided,
|
||
no UVs). Polygons of scene tiles (`Tile.worldCollisionPolygons`, so "No collision" tiles have none) and of
|
||
the compiled brush tiles (`SceneRenderer.brushOutput()`, owner = the brush instance in the same order),
|
||
fan-triangulated in game space and wound like the polygon's own plane normal (floors face +y).
|
||
`GltfExportTest.selectionOnlyAndCollisionMesh` / `collisionFloorsFaceUp`: validation, box bounds of both
|
||
nodes, a single tile's collision ≤ its polygons, round trip through Import mesh, outward box.
|
||
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: game space (right-handed, y up, z = −export z, winding reversed) like the OBJ export and
|
||
the mesh importer, so viewers show the level as the game does and a round trip through **Import
|
||
mesh** is exact (see "Handedness of files"; the former "Game orientation" option is gone).
|
||
* 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` (retaken 2026-11-07): export
|
||
dialog with the new options and the village exported with **Collision as separate mesh** read back
|
||
(45 558 triangles, 25 materials; red = material 30 under the islands), z-buffered preview.
|
||
Village: 36 087 triangles with objects, 2.9 MB .glb; without objects 2.1 MB.
|
||
|
||
### Handedness of files (fix 2026-11-07)
|
||
* **Problem (verified)**: the importer and both exports used export space as file space. The game
|
||
(and the editor view) shows export space left-handed, Blender and glTF are right-handed, so
|
||
everything crossing a file was mirrored: an "F" modelled in Blender (red front, blue +x end, as
|
||
OBJ with Blender's default *Forward −Z, Up Y* and as .glb) showed as "ꟻ" on its red side in game,
|
||
and the old glTF export of that level rendered in Blender (same camera) was the game picture
|
||
flipped left-right (`img/editor/handed_before.jpg`: Blender front view | game | Blender render
|
||
of the exported level). Editor round trips were exact (mirrored twice).
|
||
* **Convention now**: file space = **game space** (right-handed, y up; 1 unit = 1 cell): file
|
||
(x, y, z) = game (x, y, z) = export (x, y, −z); z-up files: game (x, z, −y). The conversion
|
||
mirrors, so triangle corners 1 and 2 swap (`MeshImport.Builder.tri`; exports write (0, 2, 1)).
|
||
Same in `MeshLibrary.exportObj` (also `_collision.obj`), `GltfExport` (positions, normals,
|
||
winding; option "Game orientation" removed – it is now always so) and the Python tools
|
||
(`geomlib.to_file/file_order`; `geom_export`/`geom_import --export-space` = old convention,
|
||
`geom_demo` writes file space, `vorschau/render_level.py` reads it).
|
||
* **Saved projects don't flip**: projects store the source file and re-parse it on load, so
|
||
`MeshAsset` got a property **`handedness`** ("right" | "left", Inspector "Handedness"). The field
|
||
default is "left" (old reading) – projects saved before have no such key and load as before;
|
||
`MeshImport.importFile` sets "right" on every new import; Re-import keeps the old asset's value.
|
||
Switching an old asset to "right" un-mirrors it. No format-version bump (older editors keep the
|
||
unknown key and show the mesh mirrored).
|
||
* Import dialog preview: screen x is now −x (game space is right-handed), so it shows the model
|
||
as the game does (before it showed the mirror image of the game); it starts at yaw 215°, pitch
|
||
−25°, i.e. from above at the model's front (before it looked from below – positive pitch tilted
|
||
the view up). Drag left/right is inverted to keep the orbit direction. `PreviewShot` (test
|
||
sources) paints it to a PNG. The manual's export screenshot had its "Game orientation" row
|
||
painted out.
|
||
* Python round trip unchanged: `geom_export` → `geom_import` of the village gives a WAD
|
||
byte-identical to the old tools (also `geom_demo` → `geom_import`); `--export-space` writes the
|
||
old files byte-identically.
|
||
* Tests: `MeshHandednessTest` (asymmetric triangle: y-up and z-up OBJ and glTF keep coordinates
|
||
and face +z; mirroring glTF node; "left" stays mirrored; new imports are "right"; a project whose
|
||
JSON lacks the key loads as "left"), `ObjExportRoundTripTest` (box brush → OBJ (+collision) →
|
||
Import mesh: same size, game z, every face outward), `GltfExportTest` (game-space bounds, box
|
||
round trip at game z 10..13 with outward faces), `MeshImportTest` updated.
|
||
* **Verified after the fix** (apptest :23, `HandednessDemo`: TestLevel 7, four F meshes at game
|
||
z 13 – OBJ and .glb as imported, then both turned 180° – llama at (16, 0, 4) facing +z): the
|
||
unturned Fs now show their grey back reading "ꟻ" (correct from behind), the turned ones their red
|
||
front reading "F"; the new glTF export of the level rendered in Blender from the game camera
|
||
matches the game picture (`img/editor/handed_after.jpg`).
|
||
|
||
### 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: no texture animations, additive surfaces become plain alpha blending; the collision node
|
||
has no tile-swap alternatives. (Handedness: fixed, see
|
||
"Handedness of files" above.)
|
||
* `TextureImport` packs imported textures at one size per insert (largest texture, ≤ 256).
|