Files
llamamod/notes/editor-tools-geometry.md
T

17 KiB
Raw Blame History

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.

brush

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).