diff --git a/docs/dev/TileWorldCreator4核心移植.md b/docs/dev/TileWorldCreator4核心移植.md new file mode 100644 index 00000000..b9bbeb03 --- /dev/null +++ b/docs/dev/TileWorldCreator4核心移植.md @@ -0,0 +1,77 @@ +# TileWorldCreator 4 核心功能移植到 EVEngine + +## 范围 + +本移植以 `TileWorldCreator 4 v4.3.5.unitypackage` 中的运行时算法、图组合、网格构建和配置语义为 +行为参考,采用 EVEngine 原生 `Grid2D`、`PointSet`、`PointGraph`、`MeshBuild`、Result 与模块句柄。 +“核心功能完成”不包括 Unity Inspector/EditorWindow 外观、Unity Prefab/ScriptableObject 二进制兼容、 +Unity Renderer/Collider 组件或示例美术资产;这些是宿主编辑器与资产格式,不是生成算法本身。 + +## 功能对应 + +- Dot Grid → `generate.dot_grid` +- Cellular Automata → `generate.cellular` +- Pathfinding → `grid.path` +- BSP Dungeon → `generate.registry` + `algorithm=dungeon.bsp`;完整 Roguelike 地牢使用 + `algorithm=level.roguelike`,不在 GridGraph 内维护第二套地牢算法 +- Random Walk Dungeon → `generate.random_walk` +- Maze → `generate.maze` +- Height Texture → `Grid2D.detail` 输入与 `select.detail_range` +- Random Noise → `generate.random_noise` +- Shapes → `generate.shape` +- Checkerboard → `generate.checkerboard` +- Poisson Disc Sampling → `generate.poisson` +- Add / Boolean / Subtract → `grid.union`、`grid.intersect`、`grid.subtract` +- Smooth / Expand / Shrink / Invert → 同名 `grid.*` 节点 +- Select / Select by neighbour / Select by rule → `select.random`、`select.neighbors`、`select.rule` +- Select islands / Find position on islands → `select.islands`、`select.island_centers` +- Push to paint positions → 图输出的拥有型 `Grid2D`;由调用方显式绑定为下游输入,不使用隐式全局画布 +- Tile preset / neighbour rules → `grid.autotile` 把八邻域 mask 写入 detail; + `resolveTilePreset` 按 4.3.5 原表精确解析 14 类标准网格瓦片、旋转、X 镜像和 9 位配置 +- Object build layer → `ObjectBuildLayer` 把 PointGraph 输出转换为带 `asset`、父子关系和完整变换的 + `PointSet`,支持确定性加权选择、随机变换、朝向层、place-on-top 高度属性和子对象规则 +- Persistent build configuration → `BuildLayerStack` 按插入顺序执行启用的 Tiles/Objects 层,使用 + `EVPCG_BUILD_LAYERS 1` 与嵌入式 `EVPCG_OBJECT_LAYER 1` 格式原子回读,失败不发布部分 artifacts +- Incremental build → `IncrementalBuildExecutor` 按固定格子簇缓存 BuildLayerStack artifacts;哈希包含层定义、 + 格子一圈 halo、完整点字段和强类型属性,输出有序 upsert/removal delta,任一簇失败则旧缓存原样保留 +- Tiles build layer / mesh combiner → `mesh.grid_tiles`、`mesh.merge`、`mesh.transform` +- Grid mesh collider generator → 增量示例从每簇 `MeshBuild` 创建 `World3D` 静态三角网格碰撞体; + 更新采用先建后换,removal 同步销毁,且 physics provider 缺席时可观察地退化为纯渲染 +- Runtime manager / configuration → Procgen 模块拥有的代际句柄、图 revision、版本 1 定义格式和统一 Result + +## 混合调用契约 + +标准路径为: + +`GridGraph -> convert.grid_to_points -> point.subgraph(PointGraph) -> MeshGraph.point.input -> mesh.instance_points` + +网格铺砖路径为: + +`GridGraph -> grid.autotile -> MeshGraph.grid.input -> mesh.grid_tiles -> MeshBuild/GeneratedArtifact` + +跨域只传递拥有型值或显式句柄。连接建立时检查端口类型;修改从节点向下游失效缓存;图实例仅允许 +所属线程访问且不可重入。Grid/Mesh 定义只持久化拓扑与标量参数,场景输入在恢复后重新绑定。 + +## 确定性与失败语义 + +随机生成器使用图参数中的命名 seed;相同版本、尺寸、参数和 seed 产生相同网格。图执行成本与访问 +格子数、输出顶点数或“源网格顶点数 × 点数量”成线性关系。无效操作、参数、类型、断开的必需输入、 +环、尺寸不匹配和损坏资产均返回结构化 Result,不使用 `bool + lastError`。 + +## 验证入口 + +- `examples/tileworld-graph-dungeon`:可直接运行的资产无关 3D 示例,覆盖注册表 Roguelike 地牢、 + `select.semantic` 分层、autotile、随机选择、 + Grid→PointGraph、版本化 BuildLayerStack、PointSet→MeshGraph 实例化,以及边界墙、导航和装饰层; + 地面与对象场景缓存直接消费 8×8 格子簇的 upsert/removal delta,不再额外执行完整 build stack。 + 同一 delta 还驱动每簇静态三角网格碰撞体的替换/回收。运行时会选取可行走端点、执行 + `grid.path`,并验证 MeshBuild 上传、空对象簇、热重载和换 seed 重建。 +- `test/procgen_grid_graph.cpp`:生成器、选择器、寻路、autotile、TilePreset 精确配置、Grid→PointGraph、Grid/Point→Mesh、 + 类型拒绝、环、缓存失效和两种定义格式回读。 +- `test/procgen_object_build_layer.cpp`:加权资产、确定性随机变换、朝向、place-on-top、子对象和失败路径。 +- `test/procgen_build_layer_stack.cpp`:层顺序/启用状态、混合 artifact、版本往返、未知字段和损坏配置原子拒绝。 +- `test/procgen_incremental_build.cpp`:稳定缓存、簇内与边界脏区传播、删除 delta、全局坐标网格分片、 + 参数拒绝以及失败时事务回滚。 +- `test/procgen_result_contract.cpp`:Squirrel 创建拥有型 Grid2D 并直接绑定 GridGraph,随后执行 + GridGraph/MeshGraph 并取得拥有型 MeshBuild,覆盖跨图代理的生命周期与类型契约。 +- `src/modules/procgen/GridGraphAlgorithms.cpp`:独立确定性算法实现。 diff --git a/docs/usr/modules/procgen.md b/docs/usr/modules/procgen.md index b48378ce..4a3ffcd6 100644 --- a/docs/usr/modules/procgen.md +++ b/docs/usr/modules/procgen.md @@ -2980,3 +2980,67 @@ Created、1 为 Removed。新增项按本次扫描顺序排列,随后删除项 spawner 计数,`updateRuleFraction` 更新当前规则的小数进度;`getProgress` 使用 `(totalCompleted + fraction) / totalCount`。`getTitle`、`getSubtitle`、`getStatus` 提供 UI 所需快照, `requestCancel` 发布取消请求,`clear` 隐藏并清空状态。所有更新先验证候选,非法计数或进度不修改旧值。 +## GridGraph、PointGraph 与 MeshGraph 混合编排 + +`GridGraph` 是面向语义格子的确定性数据流图。内建掩码生成节点覆盖填充、随机噪声、棋盘、点阵、 +形状、元胞自动机、随机游走、迷宫和 Poisson 撒点;`generate.registry` 则直接复用现有 +`GeneratorRegistry`,以 `algorithm` 字符串选择 `dungeon.bsp`、`level.roguelike`、WFC 等成熟 +生成器,并把节点上的整数、浮点和字符串参数投影为 `Params`。地牢算法只保留注册表中的一份实现。 +修改与选择节点覆盖布尔组合、反转、膨胀、收缩、平滑、随机/边缘/邻居/规则/岛屿/岛心/ +细节范围/语义选择、寻路与八邻域 autotile。固定 seed 的生成结果可复现。 + +`convert.grid_to_points` 是显式的类型边界:它将匹配语义的格子中心转换为 `PointSet`,并写入 +`cell_x`、`cell_y`、`semantic` 和 `detail` 属性。`point.subgraph` 随后可把这组点送入独立的 +`PointGraph`,用于筛选、变换、采样和组合。 + +`MeshGraph` 接受 `Grid2D`、`PointSet` 与 `MeshBuild` 三种强类型输入。`mesh.grid_tiles` 从格子 +构造顶面和暴露侧墙,并按 +`group/semantic/tile-kind/rN/(normal|mx)/reference-configuration` 建立命名三角形组。 +其中标准网格 mask 会映射到 TileWorldCreator 4.3.5 的 3×3 配置表,直接携带瓦片类别、 +0/90/180/270 度朝向和 X 镜像语义;原版未归类的配置 350 保持为 `none`。 + +`eve.ProcgenObjectBuildLayer()` 是 Objects Build Layer 的原生对应物。它接收 PointGraph 产生的 +`PointSet`,按稳定点身份和层 seed 确定性选择加权 `asset`,并应用层偏移、位置散布、Euler +旋转、统一或非统一缩放。`setOrientation` 可按北、东、南、西优先级朝向另一点层; +`setPlaceOnTop` 读取输入点的 `surface_highest_y` 或 `surface_lowest_y`。`addChild` 生成带 +`object_role=child` 与 `parent_index` 的子对象点。结果仍是普通 PointSet,可继续送入 PointGraph、 +`mesh.instance_points` 或实例发布接口。 + +对象层 API 速查:`clearAssets` 清空加权资产;`setLayerOffset`、`setLayerScale` 设置层级变换; +`setPositionRadius` 设置平面散布半径;`setRandomRotation`、`setRandomScale` 设置确定性随机范围; +`disableOrientation` 关闭朝向层;`clearChildRules` 清空子对象规则;`buildOriented` 使用显式朝向点集 +执行对象层。GridGraph 与 MeshGraph 的运行时输入分别通过 `setNodeGrid` 绑定;GridGraph 使用 +`setNodePointSubgraph` 绑定拥有明确输入/输出节点的 PointGraph 子图。 + +`eve.ProcgenBuildLayerStack()` 将 Tiles 与 Objects 定义放进同一个有序执行器。`addTileLayer` 和 +`addObjectLayer` 保存值副本,`setEnabled` 控制参与执行的层;`execute` 只在全部启用层成功后返回 +`ProcgenBuildLayerExecution`,再通过 `getType/getMesh/getPoints` 获取具名 artifact。配置使用 +`EVPCG_BUILD_LAYERS 1`,对象层使用嵌入式 `EVPCG_OBJECT_LAYER 1`;反序列化拒绝未知记录和尾随字段, +并在完整校验成功后原子替换旧配置。 + +层栈可用 `getLayerId`、`getLayerType` 和 `isLayerEnabled` 查询定义;`executeOriented` 为需要朝向 +点集的对象层执行完整栈。增量执行器的 `getCachedClusterCount` 返回当前成功提交的活动簇数量, +失败构建不会改变该计数。 + +`eve.ProcgenIncrementalBuildExecutor()` 为 BuildLayerStack 增加跨次构建缓存。`update` 接收簇边长(格子数) +和格子世界尺寸,返回 `ProcgenIncrementalBuildDelta`;每项通过 `getClusterX/getClusterZ/isRemoved` 标识 +场景侧应更新或删除的簇,非删除项用 `getArtifacts` 取得该簇的完整层结果。格子哈希带一格 halo, +因此边界 autotile 改动会同时使相邻簇失效;点按半开世界坐标范围归属一个簇。任一脏簇构建失败时, +内部旧缓存不变。带朝向层时使用 `updateOriented`;`getCachedArtifacts` 可按簇坐标读取当前快照。 +场景侧应以 `clusterX:clusterZ` 作为派生缓存键:普通 delta 替换该簇的渲染/碰撞 artifact,removed +delta 先从场景隐藏或移除旧对象再删除缓存项。空 PointSet 是合法簇结果,不应尝试上传空实例网格。 +示例中的物理消费者按同一键维护静态三角网格刚体:先成功创建替换体再销毁旧体,避免失败时留下 +无碰撞窗口;裁剪掉 `physics` 模块时明确退化为纯渲染路径,不影响生成结果与缓存权威状态。 +`mesh.instance_points` 把任意 `MeshBuild` 实例化到点集;`mesh.merge` 与 `mesh.transform` 完成 +组合和变换。输出仍是现有 `MeshBuild`,可继续交给统一发布、碰撞、材质和场景生命周期。 + +三个图各自拥有缓存,不共享跨帧可变裸指针。连接时检查端口类型,Grid 输出不能直接连到 +Point 或 Mesh 输入,必须经过显式转换或输入绑定。图拓扑、参数或输入变化会从修改点向下游 +失效缓存,相同输入重复执行会复用结果。 + +脚本通过 `procgen.newGridGraph()` 与 `procgen.newMeshGraph()` 获取模块拥有的句柄;所有失败均为 +统一 Result 投影。两种图定义都支持版本化 `serializeDefinition()` / `deserializeDefinition()`; +运行时输入值刻意不写入定义,加载后由调用方重新绑定,避免资产与场景实例形成第二份权威状态。 + +TileWorldCreator 4 运行时核心能力的逐项对应和边界见 +[`TileWorldCreator4核心移植.md`](../../dev/TileWorldCreator4核心移植.md)。 diff --git a/examples/README.md b/examples/README.md index 055a49e9..2fb39afc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -121,6 +121,7 @@ Windows 本地直接跑同样的命令即可:脚本自身按 UTF-8 写 stdout/ |---|---| | [procgen](procgen/README.md) | 六种地图算法(BSP / Cellular / Drunkard / Maze / 地形 / WFC)+ 纹理配方 | | [procgen-script-pipeline](procgen-script-pipeline/README.md) | 纯脚本 PointSet 组合、确定性 seed、事务式 hot reload | +| [tileworld-graph-dungeon](tileworld-graph-dungeon/README.md) | TileWorldCreator 风格 GridGraph + PointGraph + MeshGraph 混合地牢、分层构建、增量簇更新与可选物理碰撞 | | [pcg-biome](pcg-biome/README.md) | UE PCG 风格空间数据、多层运行时 Cell、时间预算与 Scene 实例批次 | | [pcg-runtime-orchestration](pcg-runtime-orchestration/README.md) | Pcg 运行时编排的数值契约:运行时盖章器 + 生成进度 + 任务队列(无 GPU 场景,等价脚本级单测) | | [pcg-location-system](pcg-location-system/README.md) | 位置书签:相机位姿 + 玩家位姿 + 控制器 + 场景名的命名书签存取与取景 | diff --git a/examples/tileworld-graph-dungeon/README.md b/examples/tileworld-graph-dungeon/README.md new file mode 100644 index 00000000..5a937d10 --- /dev/null +++ b/examples/tileworld-graph-dungeon/README.md @@ -0,0 +1,37 @@ +# TileWorld Graph Dungeon + +Asset-free 3D example for the TileWorldCreator-style graph port. + +The live path is: + +`GridGraph(GeneratorRegistry(level.roguelike) -> semantic layers -> autotile -> random selection -> grid-to-points)` +`-> PointGraph(transform)` +`-> BuildLayerStack + IncrementalBuildExecutor(cluster delta)` +`-> MeshGraph(grid tiles + point instancing)` +`-> MeshBuild -> Vulkan renderables`. + +The example uses four independent build layers: + +- autotile-mask floor groups with per-group material tints; +- a raised boundary-wall layer extracted from the roguelike generator's wall-autotile masks; +- a gold navigation layer produced by `grid.path` between deterministic endpoints; +- sparse procedural rock instances generated through PointGraph. + +The example executes the stack through an 8x8-cell incremental cluster cache. +Its scene cache consumes each upsert/removal delta to replace or hide only the +affected floor and prop renderables, then repeats the same update to prove the +stable delta is empty. Each live cluster also owns a static triangle-mesh +collider derived from the exact same floor artifact. Collider replacement is +construct-before-destroy, and removal destroys the matching body. When the +`physics` module is trimmed, this optional consumer is visibly reported as +`physics=disabled` and rendering continues. The example does not execute a +second full-stack preview build. + +- `R`: regenerate with the next deterministic seed. +- `Space`: pause/resume the camera orbit. + +Run from the repository root: + +```powershell +make run/win32-debug GAME=examples/tileworld-graph-dungeon +``` diff --git a/examples/tileworld-graph-dungeon/config.nut b/examples/tileworld-graph-dungeon/config.nut new file mode 100644 index 00000000..69b51bdd --- /dev/null +++ b/examples/tileworld-graph-dungeon/config.nut @@ -0,0 +1,7 @@ +config <- { + width = 1100, + height = 720, + title = "TileWorld Graph Dungeon", + debug = false, + hotReload = true +}; diff --git a/examples/tileworld-graph-dungeon/main.nut b/examples/tileworld-graph-dungeon/main.nut new file mode 100644 index 00000000..0c301770 --- /dev/null +++ b/examples/tileworld-graph-dungeon/main.nut @@ -0,0 +1,354 @@ +// TileWorldCreator-style runtime example implemented with EVEngine-native graphs. +// R regenerates with the next deterministic seed. Space toggles the camera orbit. + +persist twSeed = 435 +persist twCamera = null +persist twIncrementalBuild = null +persist twClusterParts = {} +persist twPhysicsWorld = null +persist twWalls = null +persist twPath = null +persist twYaw = -0.72 +persist twRotate = true +persist twPreviousKeys = {} + +const TW_WIDTH = 25; +const TW_HEIGHT = 19; +const TW_CELL = 2.0; + +function twRequire(result, context) { + if (!result.ok) throw context + ": " + result.status.summary; + return result.value; +} + +function twPressed(key) { + local down = keyboard.isDown(key); + local previous = key in twPreviousKeys ? twPreviousKeys[key] : false; + twPreviousKeys[key] <- down; + return down && !previous; +} + +function twBuildRock() { + local params = twRequire(procgen.newParams(), "rock parameters"); + params.setSeed(twSeed + 991); + params.setInt("subdivisions", 0); + params.setString("baseShape", "block"); + params.setFloat("radius", 0.32); + params.setFloat("variation", 0.18); + params.setFloat("flattening", 0.25); + params.setFloat("angularity", 0.65); + return twRequire(procgen.buildMesh("mesh.rock", params), "rock mesh"); +} + +function twBuildClusterCollider(meshBuild) { + if (twPhysicsWorld == null) return null; + local vertices = []; + local indices = []; + for (local i = 0; i < meshBuild.getVertexCount(); ++i) { + vertices.append(meshBuild.getPositionX(i)); + vertices.append(meshBuild.getPositionY(i)); + vertices.append(meshBuild.getPositionZ(i)); + } + for (local i = 0; i < meshBuild.getIndexCount(); ++i) + indices.append(meshBuild.getIndex(i)); + if (vertices.len() < 9 || indices.len() < 3) return null; + + // Construct-before-destroy keeps the previous collision authoritative if + // validation or Box3D mesh creation rejects the replacement. + local body = twPhysicsWorld.newBody("static", 0.0, 0.0, 0.0); + try { + body.newTriangleMeshShape(vertices, indices); + } catch (error) { + twPhysicsWorld.destroyBody(body); + throw error; + } + return body; +} + +function twFirstOccupied(grid) { + for (local y = 0; y < grid.getHeight(); ++y) + for (local x = 0; x < grid.getWidth(); ++x) + if (grid.getCell(x, y) != 0) return [x, y]; + return null; +} + +function twLastOccupied(grid) { + for (local y = grid.getHeight() - 1; y >= 0; --y) + for (local x = grid.getWidth() - 1; x >= 0; --x) + if (grid.getCell(x, y) != 0) return [x, y]; + return null; +} + +function twBuild() { + if (twPhysicsWorld == null && has_module("physics")) + twPhysicsWorld = physics.newWorld3D(0.0, 0.0, 0.0, false); + + // PointGraph is nested in GridGraph: selected grid cells become elevated points. + local pointGraph = twRequire(procgen.newPointGraph(), "point graph"); + if (!pointGraph.addNode("input", "input")) throw "PointGraph input node failed"; + if (!pointGraph.addNode("raise", "transform")) throw "PointGraph transform node failed"; + if (!pointGraph.connect("input", "raise", 0)) throw "PointGraph connection failed"; + if (!pointGraph.setNodeFloat("raise", "y", 0.62)) throw "PointGraph parameter failed"; + + local gridGraph = twRequire(procgen.newGridGraph(), "grid graph"); + // Reuse EVEngine's mature semantic dungeon generator as a graph source. + twRequire(gridGraph.addNode("rooms", "generate.registry"), "registry generator node"); + twRequire(gridGraph.setNodeString("rooms", "algorithm", "level.roguelike"), "generator algorithm"); + twRequire(gridGraph.setNodeInt("rooms", "width", TW_WIDTH), "dungeon width"); + twRequire(gridGraph.setNodeInt("rooms", "height", TW_HEIGHT), "dungeon height"); + twRequire(gridGraph.setNodeInt("rooms", "seed", twSeed), "dungeon seed"); + twRequire(gridGraph.setNodeInt("rooms", "roomCount", 8), "room budget"); + twRequire(gridGraph.setNodeInt("rooms", "roomMin", 4), "minimum room size"); + twRequire(gridGraph.setNodeInt("rooms", "roomMax", 7), "maximum room size"); + twRequire(gridGraph.setNodeString("rooms", "layoutStyle", "clustered"), "room layout"); + twRequire(gridGraph.setNodeString("rooms", "connectionStyle", "nearest"), "room connections"); + twRequire(gridGraph.setNodeString("rooms", "corridorStyle", "l"), "corridor style"); + twRequire(gridGraph.setNodeString("rooms", "floorPattern", "brick"), "floor pattern"); + twRequire(gridGraph.setNodeFloat("rooms", "propDensity", 0.14), "room prop density"); + twRequire(gridGraph.setNodeFloat("rooms", "corridorLightDensity", 0.04), "corridor lights"); + + twRequire(gridGraph.addNode("floor", "select.semantic"), "floor semantic selector"); + twRequire(gridGraph.addNode("corridors", "select.semantic"), "corridor semantic selector"); + twRequire(gridGraph.addNode("walls", "select.semantic"), "wall semantic selector"); + twRequire(gridGraph.setNodeInt("floor", "semantic", 2), "floor semantic"); + twRequire(gridGraph.setNodeInt("corridors", "semantic", 3), "corridor semantic"); + twRequire(gridGraph.setNodeInt("walls", "semantic", 1), "wall semantic"); + twRequire(gridGraph.connect("rooms", "floor", 0), "floor extraction"); + twRequire(gridGraph.connect("rooms", "corridors", 0), "corridor extraction"); + twRequire(gridGraph.connect("rooms", "walls", 0), "wall extraction"); + twRequire(gridGraph.addNode("wall_edges", "select.detail_range"), "wall edge selector"); + twRequire(gridGraph.setNodeInt("wall_edges", "min", 1), "wall edge minimum mask"); + twRequire(gridGraph.setNodeInt("wall_edges", "max", 255), "wall edge maximum mask"); + twRequire(gridGraph.connect("walls", "wall_edges", 0), "wall edge connection"); + twRequire(gridGraph.addNode("walkable", "grid.union"), "walkable union node"); + twRequire(gridGraph.connect("floor", "walkable", 0), "floor union connection"); + twRequire(gridGraph.connect("corridors", "walkable", 1), "corridor union connection"); + twRequire(gridGraph.addNode("autotile", "grid.autotile"), "autotile node"); + twRequire(gridGraph.connect("walkable", "autotile", 0), "autotile connection"); + + twRequire(gridGraph.addNode("scatter", "select.random"), "scatter node"); + twRequire(gridGraph.setNodeFloat("scatter", "weight", 0.055), "scatter weight"); + twRequire(gridGraph.setNodeInt("scatter", "seed", twSeed + 17), "scatter seed"); + twRequire(gridGraph.connect("autotile", "scatter", 0), "scatter connection"); + twRequire(gridGraph.addNode("points", "convert.grid_to_points"), "point conversion node"); + twRequire(gridGraph.setNodeFloat("points", "cellSize", TW_CELL), "point cell size"); + twRequire(gridGraph.connect("scatter", "points", 0), "point conversion connection"); + twRequire(gridGraph.addNode("point_pass", "point.subgraph"), "PointGraph bridge node"); + twRequire(gridGraph.connect("points", "point_pass", 0), "PointGraph bridge connection"); + twRequire(gridGraph.setNodePointSubgraph("point_pass", pointGraph, "input", "raise"), + "PointGraph bridge binding"); + + local level = twRequire(gridGraph.execute("rooms"), "semantic dungeon execution"); + local grid = twRequire(gridGraph.execute("autotile"), "grid execution"); + local border = twRequire(gridGraph.execute("wall_edges"), "wall execution"); + local points = twRequire(gridGraph.execute("point_pass"), "point execution"); + + // Deterministic Objects Build Layer: weighted assets, random transforms and + // child placements remain ordinary PointSet data for downstream graphs. + local objectLayer = eve.ProcgenObjectBuildLayer(); + twRequire(objectLayer.addAsset("dungeon/rock_a", 3.0), "rock A asset"); + twRequire(objectLayer.addAsset("dungeon/rock_b", 1.0), "rock B asset"); + objectLayer.setSeed(twSeed); + twRequire(objectLayer.setPositionRadius(0.22), "object scatter radius"); + twRequire(objectLayer.setRandomRotation(0.0, 0.0, -180.0, 180.0, 0.0, 0.0), + "object rotation range"); + twRequire(objectLayer.setRandomScale(0.72, 1.18, 0.72, 1.18, 0.72, 1.18, true), + "object scale range"); + twRequire(objectLayer.addChild("dungeon/rubble", 1, 0.48, 0.25, 0.5, -180.0, 180.0), + "rubble child rule"); + local buildStack = eve.ProcgenBuildLayerStack(); + twRequire(buildStack.addTileLayer("floor", true, TW_CELL, 0.34, "dungeon"), "floor build layer"); + twRequire(buildStack.addObjectLayer("props", true, objectLayer), "object build layer"); + local persistedStack = buildStack.serializeDefinition(); + local restoredStack = eve.ProcgenBuildLayerStack(); + twRequire(restoredStack.deserializeDefinition(persistedStack), "build layer restore"); + if (twIncrementalBuild == null) twIncrementalBuild = eve.ProcgenIncrementalBuildExecutor(); + local clusterDelta = twRequire(twIncrementalBuild.update(restoredStack, grid, points, 8, TW_CELL), + "incremental cluster build"); + local stableDelta = twRequire(twIncrementalBuild.update(restoredStack, grid, points, 8, TW_CELL), + "stable incremental cluster build"); + + // Scene-side cluster cache consumes only upserts/removals from the executor. + // No full BuildLayerStack execution is needed for the visible floor/props. + local rockBuild = twBuildRock(); + for (local i = 0; i < clusterDelta.getCount(); ++i) { + local clusterX = clusterDelta.getClusterX(i); + local clusterZ = clusterDelta.getClusterZ(i); + local key = clusterX + ":" + clusterZ; + if (clusterDelta.isRemoved(i)) { + if (key in twClusterParts) { + twClusterParts[key].floor.setVisible(false); + twClusterParts[key].props.setVisible(false); + if (twPhysicsWorld != null && "collider" in twClusterParts[key] && + twClusterParts[key].collider != null) + twPhysicsWorld.destroyBody(twClusterParts[key].collider); + delete twClusterParts[key]; + } + continue; + } + local artifacts = twRequire(clusterDelta.getArtifacts(i), "cluster artifacts"); + local floorBuild = twRequire(artifacts.getMesh(0), "cluster floor artifact"); + local objectPoints = twRequire(artifacts.getPoints(1), "cluster object artifact"); + local floorMesh = twRequire(procgen.uploadMesh(floorBuild, gfx), "cluster floor upload"); + if (!(key in twClusterParts)) + twClusterParts[key] <- { + floor = eve.Renderable3D(), props = eve.Renderable3D(), collider = null + }; + local part = twClusterParts[key]; + if (!("collider" in part)) part.collider <- null; + local replacementCollider = twBuildClusterCollider(floorBuild); + if (twPhysicsWorld != null && part.collider != null) + twPhysicsWorld.destroyBody(part.collider); + part.collider = replacementCollider; + part.floor.setMesh(floorMesh); + part.floor.setVisible(true); + part.floor.setTint(0.31 + 0.025 * ((clusterX + clusterZ) & 1), 0.43, 0.53, 1.0); + part.floor.setRoughness(0.94); + part.floor.setMetallic(0.02); + part.floor.setCastShadow(true); + part.floor.setReceiveShadow(true); + if (objectPoints.getCount() == 0) { + part.props.setVisible(false); + } else { + local propGraph = twRequire(procgen.newMeshGraph(), "cluster prop mesh graph"); + twRequire(propGraph.addNode("rock", "mesh.input"), "cluster rock input"); + twRequire(propGraph.addNode("points", "point.input"), "cluster point input"); + twRequire(propGraph.addNode("instances", "mesh.instance_points"), "cluster instance node"); + twRequire(propGraph.setNodeMesh("rock", rockBuild), "cluster rock binding"); + twRequire(propGraph.setNodePoints("points", objectPoints), "cluster point binding"); + twRequire(propGraph.setNodeFloat("instances", "scale", 1.0), "cluster instance scale"); + twRequire(propGraph.connect("rock", "instances", 0), "cluster rock connection"); + twRequire(propGraph.connect("points", "instances", 1), "cluster point connection"); + local propBuild = twRequire(propGraph.execute("instances"), "cluster instance execution"); + local propMesh = twRequire(procgen.uploadMesh(propBuild, gfx), "cluster prop upload"); + part.props.setMesh(propMesh); + part.props.setVisible(true); + part.props.setTint(0.80, 0.48, 0.19, 1.0); + part.props.setRoughness(0.78); + part.props.setMetallic(0.08); + part.props.setCastShadow(true); + part.props.setReceiveShadow(true); + } + } + + // Feed explicit owned Grid2D endpoint masks into GridGraph. This exercises the + // public newGrid -> GridGraph mixed-composition path, not an internal shortcut. + local startCell = null; + local targetCell = null; + for (local i = 0; i < level.getObjectCount(); ++i) { + local role = level.getObjectType(i); + if (role == "spawn") startCell = [level.getObjectX(i).tointeger(), level.getObjectY(i).tointeger()]; + if (role == "stairs") targetCell = [level.getObjectX(i).tointeger(), level.getObjectY(i).tointeger()]; + } + if (startCell == null) startCell = twFirstOccupied(grid); + if (targetCell == null) targetCell = twLastOccupied(grid); + if (startCell == null || targetCell == null || + (startCell[0] == targetCell[0] && startCell[1] == targetCell[1])) + throw "generated layout has no distinct navigable endpoint pair"; + local startSelection = twRequire(procgen.newGrid(TW_WIDTH, TW_HEIGHT), "route start grid"); + local targetSelection = twRequire(procgen.newGrid(TW_WIDTH, TW_HEIGHT), "route target grid"); + twRequire(startSelection.fill(0), "route start clear"); + twRequire(targetSelection.fill(0), "route target clear"); + twRequire(startSelection.setCell(startCell[0], startCell[1], 1), "route start cell"); + twRequire(targetSelection.setCell(targetCell[0], targetCell[1], 1), "route target cell"); + twRequire(gridGraph.addNode("route_start", "grid.input"), "route start input"); + twRequire(gridGraph.addNode("route_target", "grid.input"), "route target input"); + twRequire(gridGraph.setNodeGrid("route_start", startSelection), "route start binding"); + twRequire(gridGraph.setNodeGrid("route_target", targetSelection), "route target binding"); + twRequire(gridGraph.addNode("route", "grid.path"), "path node"); + twRequire(gridGraph.connect("autotile", "route", 0), "navigation connection"); + twRequire(gridGraph.connect("route_start", "route", 1), "start connection"); + twRequire(gridGraph.connect("route_target", "route", 2), "target connection"); + local route = twRequire(gridGraph.execute("route"), "path execution"); + local routeCells = 0; + for (local y = 0; y < TW_HEIGHT; ++y) + for (local x = 0; x < TW_WIDTH; ++x) + if (route.getCell(x, y) != 0) routeCells += 1; + if (routeCells < 2) throw "path output is unexpectedly short"; + + // A second build layer raises only the selected boundary cells into walls. + local wallGraph = twRequire(procgen.newMeshGraph(), "wall mesh graph"); + twRequire(wallGraph.addNode("grid", "grid.input"), "wall grid input"); + twRequire(wallGraph.addNode("walls", "mesh.grid_tiles"), "wall mesh node"); + twRequire(wallGraph.setNodeGrid("grid", border), "wall grid binding"); + twRequire(wallGraph.setNodeFloat("walls", "cellSize", TW_CELL), "wall cell size"); + twRequire(wallGraph.setNodeFloat("walls", "height", 1.65), "wall height"); + twRequire(wallGraph.setNodeString("walls", "group", "walls"), "wall group"); + twRequire(wallGraph.connect("grid", "walls", 0), "wall connection"); + local wallBuild = twRequire(wallGraph.execute("walls"), "wall mesh execution"); + local wallMesh = twRequire(procgen.uploadMesh(wallBuild, gfx), "wall mesh upload"); + + // Navigation is another independent build layer laid just above the floor. + local pathGraph = twRequire(procgen.newMeshGraph(), "path mesh graph"); + twRequire(pathGraph.addNode("grid", "grid.input"), "path grid input"); + twRequire(pathGraph.addNode("path", "mesh.grid_tiles"), "path mesh node"); + twRequire(pathGraph.setNodeGrid("grid", route), "path grid binding"); + twRequire(pathGraph.setNodeFloat("path", "cellSize", TW_CELL), "path cell size"); + twRequire(pathGraph.setNodeFloat("path", "height", 0.43), "path height"); + twRequire(pathGraph.setNodeString("path", "group", "navigation"), "path group"); + twRequire(pathGraph.connect("grid", "path", 0), "path mesh connection"); + local pathBuild = twRequire(pathGraph.execute("path"), "path mesh execution"); + local pathMesh = twRequire(procgen.uploadMesh(pathBuild, gfx), "path mesh upload"); + + if (twWalls == null) twWalls = eve.Renderable3D(); + twWalls.setMesh(wallMesh); + twWalls.setTint(0.18, 0.24, 0.31, 1.0); + twWalls.setRoughness(0.88); + twWalls.setMetallic(0.06); + twWalls.setCastShadow(true); + twWalls.setReceiveShadow(true); + + if (twPath == null) twPath = eve.Renderable3D(); + twPath.setMesh(pathMesh); + twPath.setTint(0.92, 0.56, 0.12, 1.0); + twPath.setRoughness(0.72); + twPath.setMetallic(0.04); + twPath.setCastShadow(false); + twPath.setReceiveShadow(true); + + print("tileworld-graph-dungeon: seed=" + twSeed + + " cells=" + grid.getWidth() + "x" + grid.getHeight() + + " dungeonObjects=" + level.getObjectCount() + + " liveClusters=" + twClusterParts.len() + + " wallTriangles=" + (wallBuild.getIndexCount() / 3) + + " routeCells=" + routeCells + + " props=" + points.getCount() + + " colliderBodies=" + (twPhysicsWorld == null ? 0 : twPhysicsWorld.getBodyCount()) + + " physics=" + (twPhysicsWorld == null ? "disabled" : "clustered") + + " dirtyClusters=" + clusterDelta.getCount() + + " stableClusters=" + stableDelta.getCount() + "\n"); +} + +if (twCamera == null) { + twCamera = eve.Camera3D(); + twCamera.setActive(true); +} +local twCenterX = TW_WIDTH * TW_CELL * 0.5; +local twCenterZ = TW_HEIGHT * TW_CELL * 0.5; +twCamera.setEye(twCenterX + cos(twYaw) * 76.0, 58.0, + twCenterZ + sin(twYaw) * 76.0); +twCamera.setTarget(twCenterX, 0.0, twCenterZ); +twCamera.setUp(0.0, 1.0, 0.0); +twCamera.setFov(42.0); +twCamera.setClipPlanes(0.1, 300.0); +twCamera.setAmbient(0.31, 0.34, 0.39); +gfx.setDirectionalLight(-0.55, -1.0, -0.35, 1.65, 1.48, 1.24); +gfx.setBackgroundColor(0.035, 0.055, 0.085, 1.0); + +if (twClusterParts.len() == 0 || twWalls == null || twPath == null) twBuild(); + +eve_update = function(dt) { + if (twPressed("r") || twPressed("R")) { + twSeed = (twSeed + 1).tointeger(); + twBuild(); + } + if (twPressed("space")) twRotate = !twRotate; + if (twRotate) twYaw = (twYaw + dt * 0.10).tofloat(); + twCamera.setEye(twCenterX + cos(twYaw) * 76.0, 58.0, + twCenterZ + sin(twYaw) * 76.0); +}; + +eve_render = function() { + gfx.clear(); + gfx.render3D(); +}; diff --git a/src/modules/procgen/BuildLayerStack.cpp b/src/modules/procgen/BuildLayerStack.cpp new file mode 100644 index 00000000..162182ec --- /dev/null +++ b/src/modules/procgen/BuildLayerStack.cpp @@ -0,0 +1,249 @@ +#include "procgen/BuildLayerStack.h" + +#include "common/Diagnostic.h" +#include "procgen/MeshGraph.h" + +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +template +Result fail(DiagnosticCode code, std::string message, std::string path = {}) { + return Result::failure(Diagnostic::error(code, std::move(message), std::move(path), {}, "procgen.buildLayers")); +} + +std::string hexEncode(std::string_view value) { + constexpr char digits[] = "0123456789abcdef"; + std::string output; + output.reserve(value.size() * 2); + for (const unsigned char byte : value) { + output.push_back(digits[byte >> 4U]); + output.push_back(digits[byte & 15U]); + } + return output; +} + +Result hexDecode(std::string_view value) { + if (value.size() % 2 != 0) return fail(DiagnosticCode::ParseError, "odd embedded definition size"); + const auto nibble = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + std::string output(value.size() / 2, '\0'); + for (std::size_t index = 0; index < value.size(); index += 2) { + const int high = nibble(value[index]); + const int low = nibble(value[index + 1]); + if (high < 0 || low < 0) return fail(DiagnosticCode::ParseError, "invalid embedded definition"); + output[index / 2] = char((high << 4) | low); + } + return Result::success(std::move(output)); +} + +} // namespace + +int BuildLayerExecution::getCount() const noexcept { return int(artifacts_.size()); } + +std::string BuildLayerExecution::getId(int index) const { + return index >= 0 && index < int(artifacts_.size()) ? artifacts_[std::size_t(index)].id : std::string(); +} + +std::string BuildLayerExecution::getType(int index) const { + if (index < 0 || index >= int(artifacts_.size())) return {}; + return std::holds_alternative(artifacts_[std::size_t(index)].value) ? "mesh" : "points"; +} + +Result BuildLayerExecution::getMesh(int index) const { + if (index < 0 || index >= int(artifacts_.size())) + return fail(DiagnosticCode::InvalidArgument, "artifact index is out of range"); + const auto* value = std::get_if(&artifacts_[std::size_t(index)].value); + if (!value) return fail(DiagnosticCode::TypeMismatch, "artifact is not a mesh"); + return Result::success(*value); +} + +Result BuildLayerExecution::getPoints(int index) const { + if (index < 0 || index >= int(artifacts_.size())) + return fail(DiagnosticCode::InvalidArgument, "artifact index is out of range"); + const auto* value = std::get_if(&artifacts_[std::size_t(index)].value); + if (!value) return fail(DiagnosticCode::TypeMismatch, "artifact is not a point set"); + return Result::success(*value); +} + +bool BuildLayerStack::contains(std::string_view id) const { + for (const auto& layer : layers_) + if (layer.id == id) return true; + return false; +} + +Result BuildLayerStack::addTileLayer(std::string id, bool enabled, float cellSize, float height, + std::string group) { + if (id.empty()) return fail(DiagnosticCode::InvalidArgument, "layer id is empty"); + if (contains(id)) return fail(DiagnosticCode::Conflict, "duplicate layer id", id); + if (!std::isfinite(cellSize) || cellSize <= 0.f || !std::isfinite(height)) + return fail(DiagnosticCode::InvalidArgument, "tile dimensions are invalid", id); + layers_.push_back({std::move(id), enabled, TileLayer{cellSize, height, std::move(group)}}); + return Result::success(); +} + +Result BuildLayerStack::addObjectLayer(std::string id, bool enabled, const ObjectBuildLayer& layer) { + if (id.empty()) return fail(DiagnosticCode::InvalidArgument, "layer id is empty"); + if (contains(id)) return fail(DiagnosticCode::Conflict, "duplicate layer id", id); + layers_.push_back({std::move(id), enabled, layer}); + return Result::success(); +} + +Result BuildLayerStack::setEnabled(std::string_view id, bool enabled) { + for (auto& layer : layers_) { + if (layer.id != id) continue; + layer.enabled = enabled; + return Result::success(); + } + return fail(DiagnosticCode::NotFound, "unknown layer", std::string(id)); +} + +void BuildLayerStack::clear() { layers_.clear(); } +int BuildLayerStack::getLayerCount() const noexcept { return int(layers_.size()); } + +std::string BuildLayerStack::getLayerId(int index) const { + return index >= 0 && index < int(layers_.size()) ? layers_[std::size_t(index)].id : std::string(); +} + +std::string BuildLayerStack::getLayerType(int index) const { + if (index < 0 || index >= int(layers_.size())) return {}; + return std::holds_alternative(layers_[std::size_t(index)].definition) ? "tiles" : "objects"; +} + +bool BuildLayerStack::isLayerEnabled(int index) const noexcept { + return index >= 0 && index < int(layers_.size()) && layers_[std::size_t(index)].enabled; +} + +Result BuildLayerStack::execute(const Grid2D& grid, const PointSet& points, + const PointSet* orientation) const { + return executeRegion(grid, points, + {0, 0, grid.getWidth(), grid.getHeight(), -std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), std::numeric_limits::infinity(), + std::numeric_limits::infinity()}, + orientation); +} + +Result BuildLayerStack::executeRegion(const Grid2D& grid, const PointSet& points, + const BuildLayerRegion& region, + const PointSet* orientation) const { + if (region.minCellX < 0 || region.minCellY < 0 || region.maxCellX < region.minCellX || + region.maxCellY < region.minCellY || region.maxCellX > grid.getWidth() || region.maxCellY > grid.getHeight() || + !(region.minWorldX < region.maxWorldX) || !(region.minWorldZ < region.maxWorldZ)) + return fail(DiagnosticCode::InvalidArgument, "invalid build-layer region"); + PointSet regionPoints; + regionPoints.reserve(points.points().size()); + for (std::size_t index = 0; index < points.points().size(); ++index) { + const auto& point = points.points()[index]; + if (point.x < region.minWorldX || point.x >= region.maxWorldX || point.z < region.minWorldZ || + point.z >= region.maxWorldZ) + continue; + auto appended = regionPoints.appendPointFrom(points, index); + if (!appended.ok()) return Result::failure(appended.status()); + } + BuildLayerExecution execution; + execution.artifacts_.reserve(layers_.size()); + for (const auto& layer : layers_) { + if (!layer.enabled) continue; + if (const auto* tile = std::get_if(&layer.definition)) { + MeshGraph graph; + auto result = graph.addNode("grid", "grid.input"); + if (result.ok()) result = graph.addNode("tiles", "mesh.grid_tiles"); + if (result.ok()) result = graph.setNodeGrid("grid", grid); + if (result.ok()) result = graph.setNodeFloat("tiles", "cellSize", tile->cellSize); + if (result.ok()) result = graph.setNodeFloat("tiles", "height", tile->height); + if (result.ok()) result = graph.setNodeFloat("tiles", "minX", float(region.minCellX)); + if (result.ok()) result = graph.setNodeFloat("tiles", "minY", float(region.minCellY)); + if (result.ok()) result = graph.setNodeFloat("tiles", "maxX", float(region.maxCellX)); + if (result.ok()) result = graph.setNodeFloat("tiles", "maxY", float(region.maxCellY)); + if (result.ok()) result = graph.setNodeString("tiles", "group", tile->group); + if (result.ok()) result = graph.connect("grid", "tiles"); + if (!result.ok()) return Result::failure(result.status()); + auto mesh = graph.execute("tiles"); + if (!mesh.ok()) return Result::failure(mesh.status()); + execution.artifacts_.push_back({layer.id, std::move(mesh).takeValue()}); + } else { + auto built = std::get(layer.definition).build(regionPoints, orientation); + if (!built.ok()) return Result::failure(built.status()); + execution.artifacts_.push_back({layer.id, std::move(built).takeValue()}); + } + } + return Result::success(std::move(execution)); +} + +std::string BuildLayerStack::serializeDefinition() const { + std::ostringstream out; + out << "EVPCG_BUILD_LAYERS 1\n" << std::setprecision(9); + for (const auto& layer : layers_) { + if (const auto* tile = std::get_if(&layer.definition)) { + out << "TILE " << std::quoted(layer.id) << ' ' << int(layer.enabled) << ' ' << tile->cellSize << ' ' + << tile->height << ' ' << std::quoted(tile->group) << '\n'; + } else { + const auto& object = std::get(layer.definition); + out << "OBJECT " << std::quoted(layer.id) << ' ' << int(layer.enabled) << ' ' + << hexEncode(object.serializeDefinition()) << '\n'; + } + } + out << "END\n"; + return out.str(); +} + +Result BuildLayerStack::deserializeDefinition(std::string_view definition) { + BuildLayerStack replacement; + std::istringstream input{std::string(definition)}; + std::string magic; + int version = 0; + if (!(input >> magic >> version) || magic != "EVPCG_BUILD_LAYERS" || version != 1) + return fail(DiagnosticCode::ParseError, "invalid build-layer header"); + std::string line; + std::getline(input, line); + bool ended = false; + while (std::getline(input, line)) { + if (line.empty()) continue; + std::istringstream record(line); + std::string kind; + record >> kind; + if (kind == "END") { + ended = true; + break; + } + std::string id; + int enabled = 0; + if (kind == "TILE") { + float cellSize = 0.f, height = 0.f; + std::string group; + if (!(record >> std::quoted(id) >> enabled >> cellSize >> height >> std::quoted(group)) || + (enabled != 0 && enabled != 1)) + return fail(DiagnosticCode::ParseError, "invalid TILE record"); + auto applied = replacement.addTileLayer(std::move(id), enabled != 0, cellSize, height, std::move(group)); + if (!applied.ok()) return applied; + } else if (kind == "OBJECT") { + std::string encoded; + if (!(record >> std::quoted(id) >> enabled >> encoded) || (enabled != 0 && enabled != 1)) + return fail(DiagnosticCode::ParseError, "invalid OBJECT record"); + auto decoded = hexDecode(encoded); + if (!decoded.ok()) return Result::failure(decoded.status()); + ObjectBuildLayer object; + auto applied = object.deserializeDefinition(decoded.value()); + if (!applied.ok()) return applied; + auto added = replacement.addObjectLayer(std::move(id), enabled != 0, object); + if (!added.ok()) return added; + } else { + return fail(DiagnosticCode::ParseError, "unknown build-layer record: " + kind); + } + record >> std::ws; + if (!record.eof()) return fail(DiagnosticCode::ParseError, "trailing build-layer record data"); + } + if (!ended) return fail(DiagnosticCode::ParseError, "build-layer END record is missing"); + *this = std::move(replacement); + return Result::success(); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/BuildLayerStack.h b/src/modules/procgen/BuildLayerStack.h new file mode 100644 index 00000000..4d78cc2d --- /dev/null +++ b/src/modules/procgen/BuildLayerStack.h @@ -0,0 +1,106 @@ +#pragma once + +#include "common/Result.h" +#include "procgen/Grid2D.h" +#include "procgen/MeshBuild.h" +#include "procgen/ObjectBuildLayer.h" +#include "procgen/PointSet.h" + +#include +#include +#include +#include + +namespace eve::procgen { + +/** @brief One named build-layer artifact in configured execution order. */ +struct BuildLayerArtifact { + std::string id; + std::variant value; +}; + +/** @brief Half-open grid and world-space bounds used for one incremental cluster build. */ +struct BuildLayerRegion { + int minCellX = 0; + int minCellY = 0; + int maxCellX = 0; + int maxCellY = 0; + float minWorldX = 0.f; + float minWorldZ = 0.f; + float maxWorldX = 0.f; + float maxWorldZ = 0.f; +}; + +/** @brief Immutable-by-convention result of one complete build-stack execution. */ +class BuildLayerExecution { +public: + [[nodiscard]] int getCount() const noexcept; + [[nodiscard]] std::string getId(int index) const; + [[nodiscard]] std::string getType(int index) const; + /** @brief Copy a mesh artifact, or return a typed failure for a bad index/type. */ + [[nodiscard]] Result getMesh(int index) const; + /** @brief Copy a point artifact, or return a typed failure for a bad index/type. */ + [[nodiscard]] Result getPoints(int index) const; + +private: + friend class BuildLayerStack; + std::vector artifacts_; +}; + +/** + * @brief Ordered, versioned executor for procedural tile and object build layers. + * + * The stack owns all definitions. Execution creates a new result and publishes no + * partial artifacts if a layer fails. No scene, renderer or physics state is owned. + * @thread Affine; mutate and execute on the owning thread. + */ +class BuildLayerStack { +public: + /** @brief Append a grid-to-mesh tile layer. Layer ids are unique. */ + [[nodiscard]] Result addTileLayer(std::string id, bool enabled, float cellSize, float height, + std::string group); + /** @brief Append an object layer by value. Layer ids are unique. */ + [[nodiscard]] Result addObjectLayer(std::string id, bool enabled, const ObjectBuildLayer& layer); + [[nodiscard]] Result setEnabled(std::string_view id, bool enabled); + void clear(); + [[nodiscard]] int getLayerCount() const noexcept; + [[nodiscard]] std::string getLayerId(int index) const; + [[nodiscard]] std::string getLayerType(int index) const; + [[nodiscard]] bool isLayerEnabled(int index) const noexcept; + + /** + * @brief Execute every enabled layer in stable insertion order. + * @param grid Source for tile layers. + * @param points Source for object layers. + * @param orientation Optional orientation layer shared by object definitions that enable orientation. + * @return Complete ordered artifacts, or the first structured failure with no visible partial result. + * @cost Sum of enabled tile mesh construction and object layer costs. + */ + [[nodiscard]] Result execute(const Grid2D& grid, const PointSet& points, + const PointSet* orientation = nullptr) const; + /** @brief Execute enabled layers for one half-open cluster region. */ + [[nodiscard]] Result executeRegion(const Grid2D& grid, const PointSet& points, + const BuildLayerRegion& region, + const PointSet* orientation = nullptr) const; + + /** @brief Serialize ordered definitions using EVPCG_BUILD_LAYERS schema version 1. */ + [[nodiscard]] std::string serializeDefinition() const; + /** @brief Atomically restore version 1; unknown fields and malformed embedded layers are rejected. */ + [[nodiscard]] Result deserializeDefinition(std::string_view definition); + +private: + struct TileLayer { + float cellSize = 1.f; + float height = 0.f; + std::string group; + }; + struct Layer { + std::string id; + bool enabled = true; + std::variant definition; + }; + [[nodiscard]] bool contains(std::string_view id) const; + std::vector layers_; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/GridGraph.cpp b/src/modules/procgen/GridGraph.cpp new file mode 100644 index 00000000..df0f0ac0 --- /dev/null +++ b/src/modules/procgen/GridGraph.cpp @@ -0,0 +1,667 @@ +#include "procgen/GridGraph.h" + +#include "common/Diagnostic.h" +#include "procgen/GridGraphAlgorithms.h" +#include "procgen/GeneratorRegistry.h" +#include "procgen/Params.h" +#include "procgen/PointGraph.h" +#include "procgen/Semantic.h" +#include "procgen/algorithms/RoguelikeGenerator.h" + +#include +#include +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +struct OperationSpec { + const char* id; + int inputs; + GridGraphValueType output; +}; + +const std::vector& specs() { + static const std::vector value = { + {"grid.input", 0, GridGraphValueType::Grid}, + {"grid.union", 2, GridGraphValueType::Grid}, + {"grid.intersect", 2, GridGraphValueType::Grid}, + {"grid.subtract", 2, GridGraphValueType::Grid}, + {"grid.invert", 1, GridGraphValueType::Grid}, + {"grid.expand", 1, GridGraphValueType::Grid}, + {"grid.shrink", 1, GridGraphValueType::Grid}, + {"grid.smooth", 1, GridGraphValueType::Grid}, + {"grid.autotile", 1, GridGraphValueType::Grid}, + {"generate.fill", 0, GridGraphValueType::Grid}, + {"generate.random_noise", 0, GridGraphValueType::Grid}, + {"generate.checkerboard", 0, GridGraphValueType::Grid}, + {"generate.dot_grid", 0, GridGraphValueType::Grid}, + {"generate.shape", 0, GridGraphValueType::Grid}, + {"generate.cellular", 0, GridGraphValueType::Grid}, + {"generate.random_walk", 0, GridGraphValueType::Grid}, + {"generate.maze", 0, GridGraphValueType::Grid}, + {"generate.poisson", 0, GridGraphValueType::Grid}, + {"generate.registry", 0, GridGraphValueType::Grid}, + {"select.random", 1, GridGraphValueType::Grid}, + {"select.border", 1, GridGraphValueType::Grid}, + {"select.fill", 1, GridGraphValueType::Grid}, + {"select.neighbors", 1, GridGraphValueType::Grid}, + {"select.rule", 1, GridGraphValueType::Grid}, + {"select.islands", 1, GridGraphValueType::Grid}, + {"select.island_centers", 1, GridGraphValueType::Grid}, + {"select.detail_range", 1, GridGraphValueType::Grid}, + {"select.semantic", 1, GridGraphValueType::Grid}, + {"grid.path", 3, GridGraphValueType::Grid}, + {"convert.grid_to_points", 1, GridGraphValueType::PointSet}, + {"point.subgraph", 1, GridGraphValueType::PointSet}, + }; + return value; +} + +const OperationSpec* specFor(std::string_view id) { + const auto found = + std::find_if(specs().begin(), specs().end(), [id](const OperationSpec& spec) { return id == spec.id; }); + return found == specs().end() ? nullptr : &*found; +} + +template +Result fail(DiagnosticCode code, std::string message, std::string path = {}) { + return Result::failure(Diagnostic::error(code, std::move(message), std::move(path), {}, "procgen.gridGraph")); +} + +Result failVoid(DiagnosticCode code, std::string message, std::string path = {}) { + return Result::failure(Diagnostic::error(code, std::move(message), std::move(path), {}, "procgen.gridGraph")); +} + +bool sameSize(const Grid2D& a, const Grid2D& b) { + return a.getWidth() == b.getWidth() && a.getHeight() == b.getHeight(); +} + +bool occupied(const Grid2D& grid, int x, int y) { return grid.getCell(x, y) != int(Semantic::Empty); } + +Grid2D combine(const Grid2D& a, const Grid2D& b, std::string_view operation) { + Grid2D out = a; + for (int y = 0; y < a.getHeight(); ++y) { + for (int x = 0; x < a.getWidth(); ++x) { + const bool left = occupied(a, x, y); + const bool right = occupied(b, x, y); + bool keep = false; + if (operation == "grid.union") keep = left || right; + if (operation == "grid.intersect") keep = left && right; + if (operation == "grid.subtract") keep = left && !right; + if (!keep) { + out.setCell(x, y, int(Semantic::Empty)); + out.setDetail(x, y, 0); + } else if (!left) { + out.setCell(x, y, b.getCell(x, y)); + out.setDetail(x, y, b.getDetail(x, y)); + } + } + } + return out; +} + +Grid2D morphology(const Grid2D& input, int radius, bool expand) { + Grid2D out = input; + radius = std::max(1, radius); + for (int y = 0; y < input.getHeight(); ++y) { + for (int x = 0; x < input.getWidth(); ++x) { + bool result = expand ? occupied(input, x, y) : true; + for (int oy = -radius; oy <= radius && (expand ? !result : result); ++oy) { + for (int ox = -radius; ox <= radius; ++ox) { + const bool sample = occupied(input, x + ox, y + oy); + result = expand ? (result || sample) : (result && sample); + if (expand ? result : !result) break; + } + } + if (result) { + if (!occupied(out, x, y)) out.setCell(x, y, int(Semantic::Floor)); + } else { + out.setCell(x, y, int(Semantic::Empty)); + out.setDetail(x, y, 0); + } + } + } + return out; +} + +std::uint64_t hashText(std::string_view text) { + std::uint64_t hash = 1469598103934665603ull; + for (const unsigned char value : text) { + hash ^= value; + hash *= 1099511628211ull; + } + return hash == 0 ? 1 : hash; +} + +Result gridToPoints(const Grid2D& grid, int semantic, float cellSize, float originX, float originZ, + std::string_view nodeId) { + if (!std::isfinite(cellSize) || cellSize <= 0.f) + return fail(DiagnosticCode::InvalidArgument, "cellSize must be finite and positive", + std::string(nodeId)); + PointSet points; + points.reserve(grid.cells().size()); + std::uint64_t ordinal = 0; + for (int y = 0; y < grid.getHeight(); ++y) { + for (int x = 0; x < grid.getWidth(); ++x) { + const int cell = grid.getCell(x, y); + if (cell == int(Semantic::Empty) || (semantic >= 0 && cell != semantic)) continue; + const int index = + points.add(originX + (float(x) + 0.5f) * cellSize, 0.f, originZ + (float(y) + 0.5f) * cellSize); + auto idResult = points.trySetPointId(index, derivePointId(hashText(nodeId), ++ordinal)); + if (!idResult.ok()) return Result::failure(idResult.status()); + auto xResult = points.trySetIntAttribute(index, "cell_x", x); + if (!xResult.ok()) return Result::failure(xResult.status()); + auto yResult = points.trySetIntAttribute(index, "cell_y", y); + if (!yResult.ok()) return Result::failure(yResult.status()); + auto semanticResult = points.trySetIntAttribute(index, "semantic", cell); + if (!semanticResult.ok()) return Result::failure(semanticResult.status()); + auto detailResult = points.trySetIntAttribute(index, "detail", grid.getDetail(x, y)); + if (!detailResult.ok()) return Result::failure(detailResult.status()); + } + } + return Result::success(std::move(points)); +} + +} // namespace + +Result GridGraph::addNode(std::string id, std::string operation) { + if (id.empty()) return failVoid(DiagnosticCode::InvalidArgument, "node id is empty"); + if (!specFor(operation)) return failVoid(DiagnosticCode::NotFound, "unknown operation: " + operation, id); + if (nodes_.contains(id)) return failVoid(DiagnosticCode::Conflict, "duplicate node id: " + id, id); + Node node; + node.id = id; + node.operation = std::move(operation); + nodes_.emplace(id, std::move(node)); + order_.push_back(std::move(id)); + ++revision_; + return Result::success(); +} + +Result GridGraph::connect(std::string_view fromId, std::string_view toId, int inputIndex) { + const auto from = nodes_.find(std::string(fromId)); + const auto to = nodes_.find(std::string(toId)); + if (from == nodes_.end() || to == nodes_.end()) + return failVoid(DiagnosticCode::NotFound, "connection references an unknown node", std::string(toId)); + const OperationSpec* target = specFor(to->second.operation); + if (!target || inputIndex < 0 || inputIndex >= target->inputs) + return failVoid(DiagnosticCode::InvalidArgument, "input slot is outside the operation contract", + std::string(toId)); + const auto sourceType = operationOutputType(from->second.operation); + if (!sourceType.ok()) return Result::failure(sourceType.status()); + const GridGraphValueType expected = + to->second.operation == "point.subgraph" ? GridGraphValueType::PointSet : GridGraphValueType::Grid; + if (sourceType.value() != expected) + return failVoid(DiagnosticCode::TypeMismatch, "typed graph ports are incompatible", std::string(toId)); + to->second.inputs[inputIndex] = std::string(fromId); + invalidateFrom(toId); + ++revision_; + return Result::success(); +} + +Result GridGraph::setNodeGrid(std::string_view id, const Grid2D& grid) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end()) return failVoid(DiagnosticCode::NotFound, "unknown node", std::string(id)); + if (found->second.operation != "grid.input") + return failVoid(DiagnosticCode::TypeMismatch, "node is not a grid.input", std::string(id)); + found->second.inputGrid = grid; + found->second.hasInputGrid = true; + invalidateFrom(id); + ++revision_; + return Result::success(); +} + +Result GridGraph::setNodeInt(std::string_view id, std::string key, int value) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end()) return failVoid(DiagnosticCode::NotFound, "unknown node", std::string(id)); + const bool declared = + found->second.operation.starts_with("generate.") || found->second.operation.starts_with("select.") || + found->second.operation == "grid.path" || + ((found->second.operation == "grid.expand" || found->second.operation == "grid.shrink") && key == "radius") || + (found->second.operation == "grid.smooth" && key == "threshold") || + ((found->second.operation == "grid.invert" || found->second.operation == "convert.grid_to_points") && + key == "semantic"); + if (!declared) + return failVoid(DiagnosticCode::InvalidArgument, "unknown integer parameter: " + key, std::string(id)); + found->second.ints[std::move(key)] = value; + invalidateFrom(id); + ++revision_; + return Result::success(); +} + +Result GridGraph::setNodeFloat(std::string_view id, std::string key, float value) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end()) return failVoid(DiagnosticCode::NotFound, "unknown node", std::string(id)); + if (!found->second.operation.starts_with("generate.") && !found->second.operation.starts_with("select.") && + (found->second.operation != "convert.grid_to_points" || + (key != "cellSize" && key != "originX" && key != "originZ"))) + return failVoid(DiagnosticCode::InvalidArgument, "unknown float parameter: " + key, std::string(id)); + found->second.floats[std::move(key)] = value; + invalidateFrom(id); + ++revision_; + return Result::success(); +} + +Result GridGraph::setNodeString(std::string_view id, std::string key, std::string value) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end()) return failVoid(DiagnosticCode::NotFound, "unknown node", std::string(id)); + const bool declared = (found->second.operation == "select.rule" && key == "rule") || + found->second.operation == "generate.registry"; + if (!declared) + return failVoid(DiagnosticCode::InvalidArgument, "unknown string parameter: " + key, std::string(id)); + found->second.strings[std::move(key)] = std::move(value); + invalidateFrom(id); + ++revision_; + return Result::success(); +} + +Result GridGraph::setNodePointSubgraph(std::string_view id, const PointGraph& graph, std::string inputNode, + std::string outputNode) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end()) return failVoid(DiagnosticCode::NotFound, "unknown node", std::string(id)); + if (found->second.operation != "point.subgraph") + return failVoid(DiagnosticCode::TypeMismatch, "node is not a point.subgraph", std::string(id)); + if (inputNode.empty() || outputNode.empty()) + return failVoid(DiagnosticCode::InvalidArgument, "subgraph ports must be non-empty", std::string(id)); + found->second.pointSubgraph = std::make_shared(graph); + found->second.pointInputNode = std::move(inputNode); + found->second.pointOutputNode = std::move(outputNode); + invalidateFrom(id); + ++revision_; + return Result::success(); +} + +ResultRef GridGraph::evaluate(std::string_view id, std::unordered_map& states) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end()) + return fail>(DiagnosticCode::NotFound, "unknown node", + std::string(id)); + Node& node = found->second; + if (node.cacheValid) return ResultRef::success(std::cref(node.cache)); + if (states[node.id] == 1) + return fail>(DiagnosticCode::Conflict, "graph contains a cycle", + node.id); + states[node.id] = 1; + const OperationSpec* spec = specFor(node.operation); + std::vector> inputs; + for (int i = 0; i < spec->inputs; ++i) { + if (node.inputs[i].empty()) + return fail>(DiagnosticCode::NotFound, + "required input is disconnected", node.id); + auto input = evaluate(node.inputs[i], states); + if (!input.ok()) return ResultRef::failure(input.status()); + inputs.push_back(input.value()); + } + + if (node.operation == "grid.input") { + if (!node.hasInputGrid) + return fail>(DiagnosticCode::NotFound, + "grid.input has no bound value", node.id); + node.cache = node.inputGrid; + } else if (node.operation == "generate.registry") { + const auto algorithm = node.strings.find("algorithm"); + if (algorithm == node.strings.end() || algorithm->second.empty()) + return fail>( + DiagnosticCode::InvalidArgument, "generate.registry requires a non-empty algorithm parameter", + node.id); + Params params; + params.setSize(node.ints.contains("width") ? node.ints["width"] : 32, + node.ints.contains("height") ? node.ints["height"] : 32); + params.setSeed(std::uint32_t(node.ints.contains("seed") ? node.ints["seed"] : 1)); + for (const auto& [key, value] : node.ints) + if (key != "width" && key != "height" && key != "seed") params.setInt(key, value); + for (const auto& [key, value] : node.floats) params.setFloat(key, value); + for (const auto& [key, value] : node.strings) + if (key != "algorithm") params.setString(key, value); + auto& registry = GeneratorRegistry::instance(); + registry.registerBuiltins(); + if (!registry.has(algorithm->second)) + return fail>( + DiagnosticCode::NotFound, "registered generator was not found: " + algorithm->second, node.id); + Grid2D generated; + std::string error; + if (!registry.generate(algorithm->second, params, generated, error)) + return fail>( + DiagnosticCode::Failed, error.empty() ? "registered generator failed" : std::move(error), node.id); + node.cache = std::move(generated); + } else if (node.operation.starts_with("generate.")) { + gridgraph::GenerateSettings settings; + settings.width = node.ints.contains("width") ? node.ints["width"] : 32; + settings.height = node.ints.contains("height") ? node.ints["height"] : 32; + settings.semantic = node.ints.contains("semantic") ? node.ints["semantic"] : int(Semantic::Floor); + settings.seed = std::uint64_t(node.ints.contains("seed") ? node.ints["seed"] : 1); + settings.a = node.ints.contains("a") ? node.ints["a"] : 1; + settings.b = node.ints.contains("b") ? node.ints["b"] : 0; + settings.c = node.ints.contains("c") ? node.ints["c"] : 1; + settings.x = node.floats.contains("x") ? node.floats["x"] : 0.5f; + settings.y = node.floats.contains("y") ? node.floats["y"] : 0.f; + auto generated = gridgraph::generate(node.operation, settings); + if (!generated.ok()) return ResultRef::failure(generated.status()); + node.cache = std::move(generated).takeValue(); + } else if (node.operation == "select.semantic") { + const auto* source = std::get_if(&inputs[0].get()); + if (!source) + return fail>(DiagnosticCode::TypeMismatch, + "grid input required", node.id); + Grid2D out; + out.resize(source->getWidth(), source->getHeight()); + const int semantic = node.ints.contains("semantic") ? node.ints["semantic"] : int(Semantic::Floor); + for (int y = 0; y < source->getHeight(); ++y) + for (int x = 0; x < source->getWidth(); ++x) + if (source->getCell(x, y) == semantic) { + out.setCell(x, y, semantic); + out.setDetail(x, y, source->getDetail(x, y)); + } + for (const auto& [key, value] : source->metadata()) out.setMeta(key, value); + node.cache = std::move(out); + } else if (node.operation == "select.detail_range") { + const auto* source = std::get_if(&inputs[0].get()); + if (!source) + return fail>(DiagnosticCode::TypeMismatch, + "grid input required", node.id); + Grid2D out; + out.resize(source->getWidth(), source->getHeight()); + const int minimum = node.ints.contains("min") ? node.ints["min"] : 0; + const int maximum = node.ints.contains("max") ? node.ints["max"] : 255; + for (int y = 0; y < source->getHeight(); ++y) + for (int x = 0; x < source->getWidth(); ++x) + if (occupied(*source, x, y) && source->getDetail(x, y) >= minimum && + source->getDetail(x, y) <= maximum) { + out.setCell(x, y, source->getCell(x, y)); + out.setDetail(x, y, source->getDetail(x, y)); + } + node.cache = std::move(out); + } else if (node.operation.starts_with("select.")) { + const auto* source = std::get_if(&inputs[0].get()); + if (!source) + return fail>(DiagnosticCode::TypeMismatch, + "grid input required", node.id); + auto selected = gridgraph::select(*source, node.operation, node.ints.contains("mode") ? node.ints["mode"] : 0, + node.ints.contains("count") ? node.ints["count"] : 0, + node.floats.contains("weight") ? node.floats["weight"] : 0.5f, + std::uint64_t(node.ints.contains("seed") ? node.ints["seed"] : 1), + node.strings.contains("rule") ? node.strings["rule"] : "*********"); + if (!selected.ok()) return ResultRef::failure(selected.status()); + node.cache = std::move(selected).takeValue(); + } else if (node.operation == "grid.path") { + const auto* navigation = std::get_if(&inputs[0].get()); + const auto* starts = std::get_if(&inputs[1].get()); + const auto* targets = std::get_if(&inputs[2].get()); + if (!navigation || !starts || !targets) + return fail>(DiagnosticCode::TypeMismatch, + "three grid inputs required", node.id); + auto path = gridgraph::findPath(*navigation, *starts, *targets, + node.ints.contains("semantic") ? node.ints["semantic"] : int(Semantic::Floor)); + if (!path.ok()) return ResultRef::failure(path.status()); + node.cache = std::move(path).takeValue(); + } else if (node.operation == "grid.union" || node.operation == "grid.intersect" || + node.operation == "grid.subtract") { + const auto* first = std::get_if(&inputs[0].get()); + const auto* second = std::get_if(&inputs[1].get()); + if (!first || !second || !sameSize(*first, *second)) + return fail>( + DiagnosticCode::TypeMismatch, "grid inputs must have equal dimensions", node.id); + node.cache = combine(*first, *second, node.operation); + } else if (node.operation == "grid.invert") { + const auto* source = std::get_if(&inputs[0].get()); + if (!source) + return fail>(DiagnosticCode::TypeMismatch, + "grid input required", node.id); + Grid2D out = *source; + const int semantic = node.ints.contains("semantic") ? node.ints["semantic"] : int(Semantic::Floor); + for (int y = 0; y < out.getHeight(); ++y) + for (int x = 0; x < out.getWidth(); ++x) + out.setCell(x, y, occupied(*source, x, y) ? int(Semantic::Empty) : semantic); + node.cache = std::move(out); + } else if (node.operation == "grid.expand" || node.operation == "grid.shrink") { + const auto* source = std::get_if(&inputs[0].get()); + if (!source) + return fail>(DiagnosticCode::TypeMismatch, + "grid input required", node.id); + node.cache = morphology(*source, node.ints.contains("radius") ? node.ints["radius"] : 1, + node.operation == "grid.expand"); + } else if (node.operation == "grid.smooth") { + const auto* source = std::get_if(&inputs[0].get()); + if (!source) + return fail>(DiagnosticCode::TypeMismatch, + "grid input required", node.id); + Grid2D out = *source; + const int threshold = node.ints.contains("threshold") ? node.ints["threshold"] : 4; + for (int y = 0; y < source->getHeight(); ++y) { + for (int x = 0; x < source->getWidth(); ++x) { + int neighbors = 0; + for (int oy = -1; oy <= 1; ++oy) + for (int ox = -1; ox <= 1; ++ox) + if ((ox != 0 || oy != 0) && occupied(*source, x + ox, y + oy)) ++neighbors; + out.setCell(x, y, neighbors >= threshold ? int(Semantic::Floor) : int(Semantic::Empty)); + } + } + node.cache = std::move(out); + } else if (node.operation == "grid.autotile") { + const auto* source = std::get_if(&inputs[0].get()); + if (!source) + return fail>(DiagnosticCode::TypeMismatch, + "grid input required", node.id); + Grid2D out = *source; + auto autotiled = autotileOccupiedGridInPlace(out); + if (!autotiled.ok()) + return ResultRef::failure(autotiled.status()); + node.cache = std::move(out); + } else if (node.operation == "convert.grid_to_points") { + const auto* source = std::get_if(&inputs[0].get()); + if (!source) + return fail>(DiagnosticCode::TypeMismatch, + "grid input required", node.id); + auto points = gridToPoints(*source, node.ints.contains("semantic") ? node.ints["semantic"] : -1, + node.floats.contains("cellSize") ? node.floats["cellSize"] : 1.f, + node.floats.contains("originX") ? node.floats["originX"] : 0.f, + node.floats.contains("originZ") ? node.floats["originZ"] : 0.f, node.id); + if (!points.ok()) return ResultRef::failure(points.status()); + node.cache = std::move(points).takeValue(); + } else if (node.operation == "point.subgraph") { + const auto* source = std::get_if(&inputs[0].get()); + if (!source || !node.pointSubgraph) + return fail>( + DiagnosticCode::TypeMismatch, "bound PointGraph and PointSet input required", node.id); + PointSet copy = *source; + if (!node.pointSubgraph->setNodePoints(node.pointInputNode, ©)) + return fail>( + DiagnosticCode::InvalidArgument, "nested PointGraph input binding failed", node.id); + auto output = node.pointSubgraph->executeResult(node.pointOutputNode); + if (!output.ok()) return ResultRef::failure(output.status()); + node.cache = std::move(output).takeValue(); + } + states[node.id] = 2; + node.cacheValid = true; + return ResultRef::success(std::cref(node.cache)); +} + +Result GridGraph::execute(std::string_view outputId) { + std::unordered_map states; + auto result = evaluate(outputId, states); + if (!result.ok()) return Result::failure(result.status()); + return Result::success(result.value().get()); +} + +Result GridGraph::validate(std::string_view outputId) const { + std::unordered_map states; + const auto visit = [&](const auto& self, std::string_view id) -> Result { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end()) return failVoid(DiagnosticCode::NotFound, "unknown node", std::string(id)); + if (states[found->first] == 1) + return failVoid(DiagnosticCode::Conflict, "graph contains a cycle", found->first); + if (states[found->first] == 2) return Result::success(); + states[found->first] = 1; + const OperationSpec* spec = specFor(found->second.operation); + for (int index = 0; index < spec->inputs; ++index) { + if (found->second.inputs[index].empty()) + return failVoid(DiagnosticCode::NotFound, "required input is disconnected", found->first); + auto input = self(self, found->second.inputs[index]); + if (!input.ok()) return input; + } + if (found->second.operation == "grid.input" && !found->second.hasInputGrid) + return failVoid(DiagnosticCode::NotFound, "grid.input has no bound value", found->first); + if (found->second.operation == "point.subgraph" && + (!found->second.pointSubgraph || found->second.pointInputNode.empty() || + found->second.pointOutputNode.empty())) + return failVoid(DiagnosticCode::NotFound, "point.subgraph has no complete binding", found->first); + states[found->first] = 2; + return Result::success(); + }; + return visit(visit, outputId); +} + +void GridGraph::clearCache() { + for (auto& [id, node] : nodes_) node.cacheValid = false; +} + +void GridGraph::invalidateFrom(std::string_view id) { + std::queue pending; + std::unordered_set visited; + pending.push(std::string(id)); + while (!pending.empty()) { + const std::string current = pending.front(); + pending.pop(); + if (!visited.insert(current).second) continue; + const auto found = nodes_.find(current); + if (found != nodes_.end()) found->second.cacheValid = false; + for (const auto& [otherId, node] : nodes_) + if (node.inputs[0] == current || node.inputs[1] == current || node.inputs[2] == current) + pending.push(otherId); + } +} + +int GridGraph::operationCount() { return int(specs().size()); } + +std::string GridGraph::operationId(int index) { + return index >= 0 && index < int(specs().size()) ? specs()[std::size_t(index)].id : std::string(); +} + +Result GridGraph::operationOutputType(std::string_view operation) { + const OperationSpec* spec = specFor(operation); + if (!spec) + return fail(DiagnosticCode::NotFound, "unknown operation: " + std::string(operation)); + return Result::success(spec->output); +} + +std::string GridGraph::serializeDefinition() const { + std::ostringstream out; + out << "EVPCG_GRID_GRAPH 1\n" << std::setprecision(9); + for (const auto& id : order_) { + const auto& node = nodes_.at(id); + out << "NODE " << std::quoted(id) << ' ' << std::quoted(node.operation) << '\n'; + std::vector keys; + for (const auto& [key, value] : node.ints) keys.push_back(key); + std::sort(keys.begin(), keys.end()); + for (const auto& key : keys) + out << "INT " << std::quoted(id) << ' ' << std::quoted(key) << ' ' << node.ints.at(key) << '\n'; + keys.clear(); + for (const auto& [key, value] : node.floats) keys.push_back(key); + std::sort(keys.begin(), keys.end()); + for (const auto& key : keys) + out << "FLOAT " << std::quoted(id) << ' ' << std::quoted(key) << ' ' << node.floats.at(key) << '\n'; + keys.clear(); + for (const auto& [key, value] : node.strings) keys.push_back(key); + std::sort(keys.begin(), keys.end()); + for (const auto& key : keys) + out << "STRING " << std::quoted(id) << ' ' << std::quoted(key) << ' ' << std::quoted(node.strings.at(key)) + << '\n'; + } + for (const auto& id : order_) { + const auto& node = nodes_.at(id); + for (int input = 0; input < 3; ++input) + if (!node.inputs[input].empty()) + out << "EDGE " << std::quoted(node.inputs[input]) << ' ' << std::quoted(id) << ' ' << input << '\n'; + } + out << "END\n"; + return out.str(); +} + +Result GridGraph::deserializeDefinition(std::string_view definition) { + GridGraph replacement; + std::istringstream input{std::string(definition)}; + std::string magic; + int version = 0; + if (!(input >> magic >> version) || magic != "EVPCG_GRID_GRAPH" || version != 1) + return failVoid(DiagnosticCode::ParseError, "invalid grid graph header"); + std::string line; + std::getline(input, line); + bool ended = false; + while (std::getline(input, line)) { + if (line.empty()) continue; + std::istringstream record(line); + std::string kind; + record >> kind; + if (kind == "END") { + ended = true; + break; + } + std::string id; + if (kind == "NODE") { + std::string operation; + if (!(record >> std::quoted(id) >> std::quoted(operation))) + return failVoid(DiagnosticCode::ParseError, "invalid NODE record"); + auto result = replacement.addNode(std::move(id), std::move(operation)); + if (!result.ok()) return result; + } else if (kind == "EDGE") { + std::string target; + int slot = -1; + if (!(record >> std::quoted(id) >> std::quoted(target) >> slot)) + return failVoid(DiagnosticCode::ParseError, "invalid EDGE record"); + auto result = replacement.connect(id, target, slot); + if (!result.ok()) return result; + } else if (kind == "INT") { + std::string key; + int value = 0; + if (!(record >> std::quoted(id) >> std::quoted(key) >> value)) + return failVoid(DiagnosticCode::ParseError, "invalid INT record"); + auto result = replacement.setNodeInt(id, std::move(key), value); + if (!result.ok()) return result; + } else if (kind == "FLOAT") { + std::string key; + float value = 0.f; + if (!(record >> std::quoted(id) >> std::quoted(key) >> value)) + return failVoid(DiagnosticCode::ParseError, "invalid FLOAT record"); + auto result = replacement.setNodeFloat(id, std::move(key), value); + if (!result.ok()) return result; + } else if (kind == "STRING") { + std::string key; + std::string value; + if (!(record >> std::quoted(id) >> std::quoted(key) >> std::quoted(value))) + return failVoid(DiagnosticCode::ParseError, "invalid STRING record"); + auto result = replacement.setNodeString(id, std::move(key), std::move(value)); + if (!result.ok()) return result; + } else { + return failVoid(DiagnosticCode::ParseError, "unknown grid graph record: " + kind); + } + record >> std::ws; + if (!record.eof()) return failVoid(DiagnosticCode::ParseError, "trailing grid graph record data"); + } + if (!ended) return failVoid(DiagnosticCode::ParseError, "grid graph END record is missing"); + std::unordered_map states; + const auto visit = [&](const auto& self, const std::string& id) -> Result { + if (states[id] == 2) return Result::success(); + if (states[id] == 1) return failVoid(DiagnosticCode::Conflict, "cycle in serialized graph", id); + states[id] = 1; + const auto found = replacement.nodes_.find(id); + if (found == replacement.nodes_.end()) return failVoid(DiagnosticCode::NotFound, "unknown node", id); + for (const auto& dependency : found->second.inputs) { + if (dependency.empty()) continue; + auto result = self(self, dependency); + if (!result.ok()) return result; + } + states[id] = 2; + return Result::success(); + }; + for (const auto& id : replacement.order_) { + auto result = visit(visit, id); + if (!result.ok()) return result; + } + *this = std::move(replacement); + return Result::success(); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/GridGraph.h b/src/modules/procgen/GridGraph.h new file mode 100644 index 00000000..96059c23 --- /dev/null +++ b/src/modules/procgen/GridGraph.h @@ -0,0 +1,107 @@ +#pragma once + +#include "common/BorrowedRef.h" +#include "procgen/Grid2D.h" +#include "procgen/PointSet.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace eve::procgen { + +class PointGraph; + +/** @brief Strong runtime value passed between typed GridGraph ports. */ +using GridGraphValue = std::variant; + +/** @brief Declared output kind of a GridGraph operation. */ +enum class GridGraphValueType : std::uint8_t { Grid, PointSet }; + +/** + * @brief Deterministic data-flow graph for grid generation and explicit grid-to-point composition. + * + * Grid nodes own dense intermediate values rather than ECS entities. Mutations invalidate the + * affected node and its downstream caches. `point.subgraph` is the first typed cross-graph bridge: + * it consumes a PointSet and executes an independently owned PointGraph instance. + * + * @thread Graph-owning thread only. + * @reentrant Not reentrant for one graph instance. + */ +class GridGraph { +public: + /** @brief Add a reflected operation under a stable, non-empty node id. */ + [[nodiscard]] Result addNode(std::string id, std::string operation); + /** @brief Connect one node output to a typed input slot. */ + [[nodiscard]] Result connect(std::string_view fromId, std::string_view toId, int inputIndex = 0); + /** @brief Assign an owning copy to a `grid.input` node. */ + [[nodiscard]] Result setNodeGrid(std::string_view id, const Grid2D& grid); + /** @brief Set one integer parameter declared by the node operation. */ + [[nodiscard]] Result setNodeInt(std::string_view id, std::string key, int value); + /** @brief Set one floating-point parameter declared by the node operation. */ + [[nodiscard]] Result setNodeFloat(std::string_view id, std::string key, float value); + /** @brief Set one string parameter declared by the node operation. */ + [[nodiscard]] Result setNodeString(std::string_view id, std::string key, std::string value); + /** + * @brief Bind an owning PointGraph copy to a `point.subgraph` bridge. + * @param inputNode Nested `input` node receiving the upstream PointSet. + * @param outputNode Nested output node to evaluate. + */ + [[nodiscard]] Result setNodePointSubgraph(std::string_view id, const PointGraph& graph, std::string inputNode, + std::string outputNode); + + /** + * @brief Validate and evaluate one output into an independent owning value. + * @return Grid2D or PointSet according to the selected node's reflected output type. + * @cost Linear in visited cells for local grid operations; nested PointGraph cost is additive. + */ + [[nodiscard]] Result execute(std::string_view outputId); + /** @brief Validate topology, required inputs and cross-graph bindings without evaluating values. */ + [[nodiscard]] Result validate(std::string_view outputId) const; + /** @brief Remove all cached node values while retaining topology and parameters. */ + void clearCache(); + /** @brief Monotonic authoring revision; successful mutations increment it. */ + [[nodiscard]] std::uint64_t revision() const noexcept { return revision_; } + /** @brief Serialize topology and scalar parameters using the versioned EVPCG grid schema. */ + [[nodiscard]] std::string serializeDefinition() const; + /** @brief Atomically replace this graph from a versioned definition. */ + [[nodiscard]] Result deserializeDefinition(std::string_view definition); + + /** @brief Return the number of reflected operations. */ + [[nodiscard]] static int operationCount(); + /** @brief Return a reflected operation id, or empty for an invalid index. */ + [[nodiscard]] static std::string operationId(int index); + /** @brief Return a reflected operation's output kind. */ + [[nodiscard]] static Result operationOutputType(std::string_view operation); + +private: + struct Node { + std::string id; + std::string operation; + std::string inputs[3]; + std::unordered_map ints; + std::unordered_map floats; + std::unordered_map strings; + Grid2D inputGrid; + bool hasInputGrid = false; + std::shared_ptr pointSubgraph; + std::string pointInputNode; + std::string pointOutputNode; + GridGraphValue cache; + bool cacheValid = false; + }; + + [[nodiscard]] ResultRef evaluate(std::string_view id, + std::unordered_map& states); + void invalidateFrom(std::string_view id); + + std::unordered_map nodes_; + std::vector order_; + std::uint64_t revision_ = 0; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/GridGraphAlgorithms.cpp b/src/modules/procgen/GridGraphAlgorithms.cpp new file mode 100644 index 00000000..e07ef648 --- /dev/null +++ b/src/modules/procgen/GridGraphAlgorithms.cpp @@ -0,0 +1,339 @@ +#include "procgen/GridGraphAlgorithms.h" + +#include "common/Diagnostic.h" +#include "procgen/Semantic.h" + +#include +#include +#include +#include +#include + +namespace eve::procgen::gridgraph { +namespace { + +template +Result failure(std::string message) { + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), {}, {}, "procgen.gridGraph")); +} + +bool occupied(const Grid2D& grid, int x, int y) { return grid.getCell(x, y) != int(Semantic::Empty); } + +Grid2D makeGrid(const GenerateSettings& settings) { + Grid2D grid; + grid.resize(settings.width, settings.height); + return grid; +} + +void setOccupied(Grid2D& grid, int x, int y, int semantic) { + if (x >= 0 && y >= 0 && x < grid.getWidth() && y < grid.getHeight()) grid.setCell(x, y, semantic); +} + +std::vector> cells(const Grid2D& grid) { + std::vector> result; + for (int y = 0; y < grid.getHeight(); ++y) + for (int x = 0; x < grid.getWidth(); ++x) + if (occupied(grid, x, y)) result.emplace_back(x, y); + return result; +} + +std::vector>> islands(const Grid2D& grid) { + std::vector>> result; + std::vector visited(std::size_t(grid.getWidth() * grid.getHeight())); + constexpr int dx[] = {-1, 1, 0, 0}; + constexpr int dy[] = {0, 0, -1, 1}; + for (int y = 0; y < grid.getHeight(); ++y) { + for (int x = 0; x < grid.getWidth(); ++x) { + const int start = y * grid.getWidth() + x; + if (!occupied(grid, x, y) || visited[std::size_t(start)]) continue; + result.emplace_back(); + std::queue> pending; + pending.emplace(x, y); + visited[std::size_t(start)] = 1; + while (!pending.empty()) { + const auto current = pending.front(); + pending.pop(); + result.back().push_back(current); + for (int direction = 0; direction < 4; ++direction) { + const int nx = current.first + dx[direction]; + const int ny = current.second + dy[direction]; + if (nx < 0 || ny < 0 || nx >= grid.getWidth() || ny >= grid.getHeight()) continue; + const int index = ny * grid.getWidth() + nx; + if (occupied(grid, nx, ny) && !visited[std::size_t(index)]) { + visited[std::size_t(index)] = 1; + pending.emplace(nx, ny); + } + } + } + } + } + return result; +} + +Grid2D cellular(const GenerateSettings& settings) { + Grid2D grid = makeGrid(settings); + std::mt19937_64 rng(settings.seed); + std::uniform_real_distribution probability(0.f, 1.f); + for (int y = 0; y < settings.height; ++y) + for (int x = 0; x < settings.width; ++x) + if (probability(rng) < settings.x) grid.setCell(x, y, settings.semantic); + for (int step = 0; step < settings.a; ++step) { + Grid2D next = grid; + for (int y = 0; y < settings.height; ++y) { + for (int x = 0; x < settings.width; ++x) { + int neighbors = 0; + for (int oy = -1; oy <= 1; ++oy) + for (int ox = -1; ox <= 1; ++ox) + if ((ox != 0 || oy != 0) && occupied(grid, x + ox, y + oy)) ++neighbors; + next.setCell( + x, y, + neighbors > 4 ? settings.semantic : (neighbors < 4 ? int(Semantic::Empty) : grid.getCell(x, y))); + } + } + grid = std::move(next); + } + return grid; +} + +Grid2D randomWalk(const GenerateSettings& settings) { + Grid2D grid = makeGrid(settings); + std::mt19937_64 rng(settings.seed); + std::uniform_int_distribution startX(0, settings.width - 1); + std::uniform_int_distribution startY(0, settings.height - 1); + std::uniform_int_distribution direction(0, 3); + int x = settings.b != 0 ? std::clamp(settings.b, 0, settings.width - 1) : startX(rng); + int y = settings.c != 0 ? std::clamp(settings.c, 0, settings.height - 1) : startY(rng); + const int iterations = std::max(1, settings.a); + const int length = std::max(1, int(settings.x)); + constexpr int dx[] = {-1, 1, 0, 0}; + constexpr int dy[] = {0, 0, -1, 1}; + for (int iteration = 0; iteration < iterations; ++iteration) { + for (int step = 0; step < length; ++step) { + setOccupied(grid, x, y, settings.semantic); + const int d = direction(rng); + x = std::clamp(x + dx[d], 0, settings.width - 1); + y = std::clamp(y + dy[d], 0, settings.height - 1); + } + if (settings.y > 0.5f) { + x = startX(rng); + y = startY(rng); + } + } + return grid; +} + +Grid2D maze(const GenerateSettings& settings) { + Grid2D grid = makeGrid(settings); + if (settings.width < 3 || settings.height < 3) return grid; + std::mt19937_64 rng(settings.seed); + std::vector> stack{{1, 1}}; + setOccupied(grid, 1, 1, settings.semantic); + constexpr int dx[] = {-2, 2, 0, 0}; + constexpr int dy[] = {0, 0, -2, 2}; + while (!stack.empty()) { + const auto current = stack.back(); + std::vector candidates; + for (int d = 0; d < 4; ++d) { + const int nx = current.first + dx[d]; + const int ny = current.second + dy[d]; + if (nx > 0 && ny > 0 && nx < settings.width - 1 && ny < settings.height - 1 && !occupied(grid, nx, ny)) + candidates.push_back(d); + } + if (candidates.empty()) { + stack.pop_back(); + continue; + } + std::shuffle(candidates.begin(), candidates.end(), rng); + const int d = candidates.front(); + const int nx = current.first + dx[d]; + const int ny = current.second + dy[d]; + setOccupied(grid, current.first + dx[d] / 2, current.second + dy[d] / 2, settings.semantic); + setOccupied(grid, nx, ny, settings.semantic); + stack.emplace_back(nx, ny); + } + return grid; +} + +} // namespace + +Result generate(std::string_view operation, const GenerateSettings& settings) { + if (settings.width <= 0 || settings.height <= 0) return failure("generator dimensions must be positive"); + Grid2D grid = makeGrid(settings); + std::mt19937_64 rng(settings.seed); + if (operation == "generate.fill") { + grid.fill(settings.semantic); + } else if (operation == "generate.random_noise") { + std::uniform_real_distribution probability(0.f, 1.f); + for (int y = 0; y < settings.height; ++y) + for (int x = 0; x < settings.width; ++x) + if (probability(rng) < settings.x) grid.setCell(x, y, settings.semantic); + } else if (operation == "generate.checkerboard" || operation == "generate.dot_grid") { + const int spacing = std::max(1, settings.a); + for (int y = 0; y < settings.height; ++y) + for (int x = 0; x < settings.width; ++x) + if (operation == "generate.checkerboard" ? ((x / spacing + y / spacing) % 2 == 0) + : (x % spacing == 0 && y % spacing == 0)) + grid.setCell(x, y, settings.semantic); + } else if (operation == "generate.shape") { + const int cx = settings.a; + const int cy = settings.b; + const int radius = std::max(1, settings.c); + for (int y = 0; y < settings.height; ++y) + for (int x = 0; x < settings.width; ++x) { + bool inside = settings.x < 0.5f ? ((x - cx) * (x - cx) + (y - cy) * (y - cy) <= radius * radius) + : (std::abs(x - cx) <= radius && std::abs(y - cy) <= radius); + if (inside) grid.setCell(x, y, settings.semantic); + } + } else if (operation == "generate.cellular") { + grid = cellular(settings); + } else if (operation == "generate.random_walk") { + grid = randomWalk(settings); + } else if (operation == "generate.maze") { + grid = maze(settings); + } else if (operation == "generate.poisson") { + const float radius = std::max(1.f, settings.x); + std::vector> accepted; + std::vector> candidates; + for (int y = 0; y < settings.height; ++y) + for (int x = 0; x < settings.width; ++x) candidates.emplace_back(x, y); + std::shuffle(candidates.begin(), candidates.end(), rng); + for (const auto& candidate : candidates) { + const bool clear = std::none_of(accepted.begin(), accepted.end(), [&](const auto& point) { + const float dx = float(point.first - candidate.first); + const float dy = float(point.second - candidate.second); + return dx * dx + dy * dy < radius * radius; + }); + if (clear) { + accepted.push_back(candidate); + grid.setCell(candidate.first, candidate.second, settings.semantic); + } + } + } else { + return failure("unsupported generator: " + std::string(operation)); + } + return Result::success(std::move(grid)); +} + +Result select(const Grid2D& input, std::string_view operation, int mode, int count, float weight, + std::uint64_t seed, std::string_view rule) { + Grid2D out; + out.resize(input.getWidth(), input.getHeight()); + auto active = cells(input); + if (operation == "select.random") { + std::mt19937_64 rng(seed); + std::shuffle(active.begin(), active.end(), rng); + const int amount = count > 0 ? std::min(count, int(active.size())) + : int(std::round(std::clamp(weight, 0.f, 1.f) * float(active.size()))); + active.resize(std::size_t(amount)); + for (const auto& point : active) + out.setCell(point.first, point.second, input.getCell(point.first, point.second)); + } else if (operation == "select.border" || operation == "select.fill") { + for (const auto& point : active) { + bool border = false; + constexpr int dx[] = {-1, 1, 0, 0}; + constexpr int dy[] = {0, 0, -1, 1}; + for (int d = 0; d < 4; ++d) border = border || !occupied(input, point.first + dx[d], point.second + dy[d]); + if ((operation == "select.border" && border) || (operation == "select.fill" && !border)) + out.setCell(point.first, point.second, input.getCell(point.first, point.second)); + } + } else if (operation == "select.neighbors") { + for (const auto& point : active) { + int neighbors = 0; + for (int oy = -1; oy <= 1; ++oy) + for (int ox = -1; ox <= 1; ++ox) + if ((ox != 0 || oy != 0) && occupied(input, point.first + ox, point.second + oy)) ++neighbors; + if ((mode == 0 && neighbors == count) || (mode == 1 && neighbors >= count) || + (mode == 2 && neighbors <= count)) + out.setCell(point.first, point.second, input.getCell(point.first, point.second)); + } + } else if (operation == "select.rule") { + if (rule.size() != 9) return failure("rule must contain exactly nine characters (0/1/*)"); + for (const auto& point : active) { + bool match = true; + for (int oy = -1; oy <= 1 && match; ++oy) + for (int ox = -1; ox <= 1; ++ox) { + const char expected = rule[std::size_t((oy + 1) * 3 + ox + 1)]; + if (expected != '*' && + (occupied(input, point.first + ox, point.second + oy) != (expected == '1'))) { + match = false; + break; + } + } + if (match) out.setCell(point.first, point.second, input.getCell(point.first, point.second)); + } + } else if (operation == "select.islands" || operation == "select.island_centers") { + for (const auto& island : islands(input)) { + const bool keep = mode == 0 ? int(island.size()) < count + : (mode == 1 ? int(island.size()) > count : int(island.size()) == count); + if (!keep) continue; + if (operation == "select.island_centers") { + long long sx = 0; + long long sy = 0; + for (const auto& point : island) { + sx += point.first; + sy += point.second; + } + const int x = int(std::llround(double(sx) / double(island.size()))); + const int y = int(std::llround(double(sy) / double(island.size()))); + out.setCell(x, y, input.getCell(island.front().first, island.front().second)); + } else { + for (const auto& point : island) + out.setCell(point.first, point.second, input.getCell(point.first, point.second)); + } + } + } else { + return failure("unsupported selector: " + std::string(operation)); + } + return Result::success(std::move(out)); +} + +Result findPath(const Grid2D& navigation, const Grid2D& starts, const Grid2D& targets, int semantic) { + if (navigation.getWidth() != starts.getWidth() || navigation.getHeight() != starts.getHeight() || + navigation.getWidth() != targets.getWidth() || navigation.getHeight() != targets.getHeight()) + return failure("pathfinding grids must have equal dimensions"); + const auto startCells = cells(starts); + const auto targetCells = cells(targets); + if (startCells.empty() || targetCells.empty()) + return failure("pathfinding requires start and target cells"); + const int width = navigation.getWidth(); + const int height = navigation.getHeight(); + std::vector previous(std::size_t(width * height), -1); + std::queue pending; + const int start = startCells.front().second * width + startCells.front().first; + pending.push(start); + previous[std::size_t(start)] = start; + int destination = -1; + while (!pending.empty() && destination < 0) { + const int current = pending.front(); + pending.pop(); + const int x = current % width; + const int y = current / width; + if (occupied(targets, x, y)) { + destination = current; + break; + } + constexpr int dx[] = {-1, 1, 0, 0}; + constexpr int dy[] = {0, 0, -1, 1}; + for (int d = 0; d < 4; ++d) { + const int nx = x + dx[d]; + const int ny = y + dy[d]; + if (nx < 0 || ny < 0 || nx >= width || ny >= height || !occupied(navigation, nx, ny)) continue; + const int next = ny * width + nx; + if (previous[std::size_t(next)] < 0) { + previous[std::size_t(next)] = current; + pending.push(next); + } + } + } + if (destination < 0) return failure("no path found"); + Grid2D out; + out.resize(width, height); + for (int current = destination;; current = previous[std::size_t(current)]) { + out.setCell(current % width, current / width, semantic); + if (current == start) break; + } + return Result::success(std::move(out)); +} + +} // namespace eve::procgen::gridgraph diff --git a/src/modules/procgen/GridGraphAlgorithms.h b/src/modules/procgen/GridGraphAlgorithms.h new file mode 100644 index 00000000..71cd8865 --- /dev/null +++ b/src/modules/procgen/GridGraphAlgorithms.h @@ -0,0 +1,30 @@ +#pragma once + +#include "common/Result.h" +#include "procgen/Grid2D.h" + +#include +#include +#include + +namespace eve::procgen::gridgraph { + +struct GenerateSettings { + int width = 32; + int height = 32; + int semantic = 1; + std::uint64_t seed = 1; + int a = 0; + int b = 0; + int c = 0; + float x = 0.f; + float y = 0.f; +}; + +[[nodiscard]] Result generate(std::string_view operation, const GenerateSettings& settings); +[[nodiscard]] Result select(const Grid2D& input, std::string_view operation, int mode, int count, float weight, + std::uint64_t seed, std::string_view rule); +[[nodiscard]] Result findPath(const Grid2D& navigation, const Grid2D& starts, const Grid2D& targets, + int semantic); + +} // namespace eve::procgen::gridgraph diff --git a/src/modules/procgen/GridMeshGraphScript.cpp b/src/modules/procgen/GridMeshGraphScript.cpp new file mode 100644 index 00000000..433232d6 --- /dev/null +++ b/src/modules/procgen/GridMeshGraphScript.cpp @@ -0,0 +1,391 @@ +#include "procgen/GridMeshGraphScript.h" + +#include "common/SquirrelBinding.h" +#include "common/SquirrelOwnership.h" +#include "procgen/BuildLayerStack.h" +#include "procgen/GridGraph.h" +#include "procgen/IncrementalBuild.h" +#include "procgen/MeshGraph.h" +#include "procgen/ObjectBuildLayer.h" +#include "procgen/PointGraph.h" + +#include + +#include + +namespace eve::procgen { + +void exposeGridMeshGraphs(ssq::Table& table) { + auto grid = table.addClass("ProcgenGridGraph", + std::function([]() -> GridGraph* { return nullptr; }), true); + const HSQUIRRELVM gridVm = grid.getHandle(); + grid.addFunc("addNode", [gridVm](GridGraph* self, const std::string& id, const std::string& operation) { + return eve::script::projectResult(gridVm, self->addNode(id, operation)); + }); + grid.addFunc("connect", [gridVm](GridGraph* self, const std::string& from, const std::string& to, int slot) { + return eve::script::projectResult(gridVm, self->connect(from, to, slot)); + }); + grid.addFunc("setNodeGrid", [gridVm](GridGraph* self, const std::string& id, Grid2D* value) { + if (!value) + return eve::script::projectResult( + gridVm, Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, "grid is null"))); + return eve::script::projectResult(gridVm, self->setNodeGrid(id, *value)); + }); + grid.addFunc("setNodeInt", [gridVm](GridGraph* self, const std::string& id, const std::string& key, int value) { + return eve::script::projectResult(gridVm, self->setNodeInt(id, key, value)); + }); + grid.addFunc("setNodeFloat", [gridVm](GridGraph* self, const std::string& id, const std::string& key, float value) { + return eve::script::projectResult(gridVm, self->setNodeFloat(id, key, value)); + }); + grid.addFunc("setNodeString", + [gridVm](GridGraph* self, const std::string& id, const std::string& key, const std::string& value) { + return eve::script::projectResult(gridVm, self->setNodeString(id, key, value)); + }); + grid.addFunc("setNodePointSubgraph", [gridVm](GridGraph* self, const std::string& id, PointGraph* nested, + const std::string& input, const std::string& output) { + if (!nested) + return eve::script::projectResult(gridVm, Result::failure(Diagnostic::error( + DiagnosticCode::InvalidArgument, "point graph is null"))); + return eve::script::projectResult(gridVm, self->setNodePointSubgraph(id, *nested, input, output)); + }); + grid.addFunc("execute", [gridVm](GridGraph* self, const std::string& output) -> ssq::Table { + auto result = self->execute(output); + if (!result.ok()) return eve::script::projectStatusResult(gridVm, result.status(), false, false); + auto value = std::move(result).takeValue(); + ssq::Object object; + if (auto* outputGrid = std::get_if(&value)) { + auto instance = + eve::script::makeOwnedSquirrelInstance(gridVm, std::make_unique(*outputGrid)); + if (!instance.ok()) return eve::script::projectStatusResult(gridVm, instance.status(), false, false); + object = std::move(instance).takeValue(); + } else { + auto instance = eve::script::makeOwnedSquirrelInstance( + gridVm, std::make_unique(std::get(std::move(value)))); + if (!instance.ok()) return eve::script::projectStatusResult(gridVm, instance.status(), false, false); + object = std::move(instance).takeValue(); + } + auto projected = eve::script::projectStatusResult(gridVm, Status::success(), true, true); + projected.set("value", std::move(object)); + return projected; + }); + grid.addFunc("validate", [gridVm](GridGraph* self, const std::string& output) { + return eve::script::projectResult(gridVm, self->validate(output)); + }); + grid.addFunc("clearCache", &GridGraph::clearCache); + grid.addFunc("getRevision", [](GridGraph* self) { return self->revision(); }); + grid.addFunc("serializeDefinition", &GridGraph::serializeDefinition); + grid.addFunc("deserializeDefinition", [gridVm](GridGraph* self, const std::string& definition) { + return eve::script::projectResult(gridVm, self->deserializeDefinition(definition)); + }); + + auto mesh = table.addClass("ProcgenMeshGraph", + std::function([]() -> MeshGraph* { return nullptr; }), true); + const HSQUIRRELVM meshVm = mesh.getHandle(); + mesh.addFunc("addNode", [meshVm](MeshGraph* self, const std::string& id, const std::string& operation) { + return eve::script::projectResult(meshVm, self->addNode(id, operation)); + }); + mesh.addFunc("connect", [meshVm](MeshGraph* self, const std::string& from, const std::string& to, int slot) { + return eve::script::projectResult(meshVm, self->connect(from, to, slot)); + }); + mesh.addFunc("setNodeGrid", [meshVm](MeshGraph* self, const std::string& id, Grid2D* value) { + if (!value) + return eve::script::projectResult( + meshVm, Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, "grid is null"))); + return eve::script::projectResult(meshVm, self->setNodeGrid(id, *value)); + }); + mesh.addFunc("setNodePoints", [meshVm](MeshGraph* self, const std::string& id, PointSet* value) { + if (!value) + return eve::script::projectResult( + meshVm, Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, "points are null"))); + return eve::script::projectResult(meshVm, self->setNodePoints(id, *value)); + }); + mesh.addFunc("setNodeMesh", [meshVm](MeshGraph* self, const std::string& id, MeshBuild* value) { + if (!value) + return eve::script::projectResult( + meshVm, Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, "mesh is null"))); + return eve::script::projectResult(meshVm, self->setNodeMesh(id, *value)); + }); + mesh.addFunc("setNodeFloat", [meshVm](MeshGraph* self, const std::string& id, const std::string& key, float value) { + return eve::script::projectResult(meshVm, self->setNodeFloat(id, key, value)); + }); + mesh.addFunc("setNodeString", + [meshVm](MeshGraph* self, const std::string& id, const std::string& key, const std::string& value) { + return eve::script::projectResult(meshVm, self->setNodeString(id, key, value)); + }); + mesh.addFunc("execute", [meshVm](MeshGraph* self, const std::string& output) -> ssq::Table { + auto result = self->execute(output); + if (!result.ok()) return eve::script::projectStatusResult(meshVm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + meshVm, std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) return eve::script::projectStatusResult(meshVm, instance.status(), false, false); + auto projected = eve::script::projectStatusResult(meshVm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); + mesh.addFunc("validate", [meshVm](MeshGraph* self, const std::string& output) { + return eve::script::projectResult(meshVm, self->validate(output)); + }); + mesh.addFunc("clearCache", &MeshGraph::clearCache); + mesh.addFunc("getRevision", [](MeshGraph* self) { return self->revision(); }); + mesh.addFunc("serializeDefinition", &MeshGraph::serializeDefinition); + mesh.addFunc("deserializeDefinition", [meshVm](MeshGraph* self, const std::string& definition) { + return eve::script::projectResult(meshVm, self->deserializeDefinition(definition)); + }); + + auto objects = table.addClass("ProcgenObjectBuildLayer", ssq::Class::Ctor()); + const HSQUIRRELVM objectsVm = objects.getHandle(); + objects.addFunc("addAsset", [objectsVm](ObjectBuildLayer* self, const std::string& asset, float weight) { + return eve::script::projectResult(objectsVm, self->addAsset(asset, weight)); + }); + objects.addFunc("clearAssets", &ObjectBuildLayer::clearAssets); + objects.addFunc("setSeed", [](ObjectBuildLayer* self, std::uint32_t seed) { self->setSeed(seed); }); + objects.addFunc("setLayerOffset", + [](ObjectBuildLayer* self, float x, float y, float z) { self->setLayerOffset(x, y, z); }); + objects.addFunc("setLayerScale", [objectsVm](ObjectBuildLayer* self, float x, float y, float z) { + return eve::script::projectResult(objectsVm, self->setLayerScale(x, y, z)); + }); + objects.addFunc("setPositionRadius", [objectsVm](ObjectBuildLayer* self, float radius) { + return eve::script::projectResult(objectsVm, self->setPositionRadius(radius)); + }); + objects.addFunc("setRandomRotation", [objectsVm](ObjectBuildLayer* self, float minPitch, float maxPitch, + float minYaw, float maxYaw, float minRoll, float maxRoll) { + return eve::script::projectResult( + objectsVm, self->setRandomRotation(minPitch, maxPitch, minYaw, maxYaw, minRoll, maxRoll)); + }); + objects.addFunc("setRandomScale", [objectsVm](ObjectBuildLayer* self, float minX, float maxX, float minY, + float maxY, float minZ, float maxZ, bool uniform) { + return eve::script::projectResult(objectsVm, self->setRandomScale(minX, maxX, minY, maxY, minZ, maxZ, uniform)); + }); + objects.addFunc("setOrientation", + [objectsVm](ObjectBuildLayer* self, float cellSize, float yawOffset, bool invert) { + return eve::script::projectResult(objectsVm, self->setOrientation(cellSize, yawOffset, invert)); + }); + objects.addFunc("disableOrientation", [](ObjectBuildLayer* self) { self->disableOrientation(); }); + objects.addFunc("setPlaceOnTop", [](ObjectBuildLayer* self, bool enabled, bool useLowest, float topOffset) { + self->setPlaceOnTop(enabled, useLowest, topOffset); + }); + objects.addFunc("addChild", [objectsVm](ObjectBuildLayer* self, const std::string& asset, int count, float radius, + float minScale, float maxScale, float minYaw, float maxYaw) { + return eve::script::projectResult(objectsVm, + self->addChild(asset, count, radius, minScale, maxScale, minYaw, maxYaw)); + }); + objects.addFunc("clearChildRules", &ObjectBuildLayer::clearChildRules); + objects.addFunc("serializeDefinition", &ObjectBuildLayer::serializeDefinition); + objects.addFunc("deserializeDefinition", [objectsVm](ObjectBuildLayer* self, const std::string& definition) { + return eve::script::projectResult(objectsVm, self->deserializeDefinition(definition)); + }); + objects.addFunc("build", [objectsVm](ObjectBuildLayer* self, PointSet* source) -> ssq::Table { + if (!source) + return eve::script::projectStatusResult( + objectsVm, + Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, "source points are null")) + .status(), + false, false); + auto result = self->build(*source); + if (!result.ok()) return eve::script::projectStatusResult(objectsVm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + objectsVm, std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) return eve::script::projectStatusResult(objectsVm, instance.status(), false, false); + auto projected = eve::script::projectStatusResult(objectsVm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); + objects.addFunc("buildOriented", + [objectsVm](ObjectBuildLayer* self, PointSet* source, PointSet* orientation) -> ssq::Table { + if (!source || !orientation) + return eve::script::projectStatusResult( + objectsVm, + Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "source or orientation is null")) + .status(), + false, false); + auto result = self->build(*source, orientation); + if (!result.ok()) + return eve::script::projectStatusResult(objectsVm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + objectsVm, std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) + return eve::script::projectStatusResult(objectsVm, instance.status(), false, false); + auto projected = eve::script::projectStatusResult(objectsVm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); + + auto execution = table.addClass( + "ProcgenBuildLayerExecution", + std::function([]() -> BuildLayerExecution* { return nullptr; }), true); + const HSQUIRRELVM executionVm = execution.getHandle(); + execution.addFunc("getCount", [](BuildLayerExecution* self) { return self->getCount(); }); + execution.addFunc("getId", &BuildLayerExecution::getId); + execution.addFunc("getType", &BuildLayerExecution::getType); + execution.addFunc("getMesh", [executionVm](BuildLayerExecution* self, int index) -> ssq::Table { + auto result = self->getMesh(index); + if (!result.ok()) return eve::script::projectStatusResult(executionVm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + executionVm, std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) return eve::script::projectStatusResult(executionVm, instance.status(), false, false); + auto projected = eve::script::projectStatusResult(executionVm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); + execution.addFunc("getPoints", [executionVm](BuildLayerExecution* self, int index) -> ssq::Table { + auto result = self->getPoints(index); + if (!result.ok()) return eve::script::projectStatusResult(executionVm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + executionVm, std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) return eve::script::projectStatusResult(executionVm, instance.status(), false, false); + auto projected = eve::script::projectStatusResult(executionVm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); + + auto stack = table.addClass("ProcgenBuildLayerStack", ssq::Class::Ctor()); + const HSQUIRRELVM stackVm = stack.getHandle(); + stack.addFunc("addTileLayer", [stackVm](BuildLayerStack* self, const std::string& id, bool enabled, float cellSize, + float height, const std::string& group) { + return eve::script::projectResult(stackVm, self->addTileLayer(id, enabled, cellSize, height, group)); + }); + stack.addFunc("addObjectLayer", [stackVm](BuildLayerStack* self, const std::string& id, bool enabled, + ObjectBuildLayer* layer) { + if (!layer) + return eve::script::projectResult(stackVm, Result::failure(Diagnostic::error( + DiagnosticCode::InvalidArgument, "object layer is null"))); + return eve::script::projectResult(stackVm, self->addObjectLayer(id, enabled, *layer)); + }); + stack.addFunc("setEnabled", [stackVm](BuildLayerStack* self, const std::string& id, bool enabled) { + return eve::script::projectResult(stackVm, self->setEnabled(id, enabled)); + }); + stack.addFunc("clear", &BuildLayerStack::clear); + stack.addFunc("getLayerCount", [](BuildLayerStack* self) { return self->getLayerCount(); }); + stack.addFunc("getLayerId", &BuildLayerStack::getLayerId); + stack.addFunc("getLayerType", &BuildLayerStack::getLayerType); + stack.addFunc("isLayerEnabled", [](BuildLayerStack* self, int index) { return self->isLayerEnabled(index); }); + stack.addFunc("serializeDefinition", &BuildLayerStack::serializeDefinition); + stack.addFunc("deserializeDefinition", [stackVm](BuildLayerStack* self, const std::string& definition) { + return eve::script::projectResult(stackVm, self->deserializeDefinition(definition)); + }); + stack.addFunc("execute", [stackVm](BuildLayerStack* self, Grid2D* grid, PointSet* points) -> ssq::Table { + if (!grid || !points) + return eve::script::projectStatusResult( + stackVm, + Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "grid or points input is null")) + .status(), + false, false); + auto result = self->execute(*grid, *points); + if (!result.ok()) return eve::script::projectStatusResult(stackVm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + stackVm, std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) return eve::script::projectStatusResult(stackVm, instance.status(), false, false); + auto projected = eve::script::projectStatusResult(stackVm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); + stack.addFunc( + "executeOriented", + [stackVm](BuildLayerStack* self, Grid2D* grid, PointSet* points, PointSet* orientation) -> ssq::Table { + if (!grid || !points || !orientation) + return eve::script::projectStatusResult( + stackVm, + Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "build-layer input is null")) + .status(), + false, false); + auto result = self->execute(*grid, *points, orientation); + if (!result.ok()) return eve::script::projectStatusResult(stackVm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + stackVm, std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) return eve::script::projectStatusResult(stackVm, instance.status(), false, false); + auto projected = eve::script::projectStatusResult(stackVm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); + + auto delta = table.addClass( + "ProcgenIncrementalBuildDelta", + std::function([]() -> IncrementalBuildDelta* { return nullptr; }), true); + const HSQUIRRELVM deltaVm = delta.getHandle(); + delta.addFunc("getCount", [](IncrementalBuildDelta* self) { return self->getCount(); }); + delta.addFunc("getClusterX", [](IncrementalBuildDelta* self, int index) { return self->getClusterX(index); }); + delta.addFunc("getClusterZ", [](IncrementalBuildDelta* self, int index) { return self->getClusterZ(index); }); + delta.addFunc("isRemoved", [](IncrementalBuildDelta* self, int index) { return self->isRemoved(index); }); + delta.addFunc("getArtifacts", [deltaVm](IncrementalBuildDelta* self, int index) -> ssq::Table { + auto result = self->getArtifacts(index); + if (!result.ok()) return eve::script::projectStatusResult(deltaVm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + deltaVm, std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) return eve::script::projectStatusResult(deltaVm, instance.status(), false, false); + auto projected = eve::script::projectStatusResult(deltaVm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); + + auto incremental = table.addClass("ProcgenIncrementalBuildExecutor", + ssq::Class::Ctor()); + const HSQUIRRELVM incrementalVm = incremental.getHandle(); + incremental.addFunc("clear", &IncrementalBuildExecutor::clear); + incremental.addFunc("getCachedClusterCount", + [](IncrementalBuildExecutor* self) { return self->getCachedClusterCount(); }); + incremental.addFunc("getCachedArtifacts", + [incrementalVm](IncrementalBuildExecutor* self, int x, int z) -> ssq::Table { + auto result = self->getCachedArtifacts(x, z); + if (!result.ok()) + return eve::script::projectStatusResult(incrementalVm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + incrementalVm, + std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) + return eve::script::projectStatusResult(incrementalVm, instance.status(), false, false); + auto projected = + eve::script::projectStatusResult(incrementalVm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); + incremental.addFunc( + "update", + [incrementalVm](IncrementalBuildExecutor* self, BuildLayerStack* buildStack, Grid2D* grid, PointSet* points, + int clusterSizeCells, float cellSizeWorld) -> ssq::Table { + if (!buildStack || !grid || !points) + return eve::script::projectStatusResult( + incrementalVm, + Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, + "incremental build input is null")) + .status(), + false, false); + auto result = self->update(*buildStack, *grid, *points, clusterSizeCells, cellSizeWorld); + if (!result.ok()) + return eve::script::projectStatusResult(incrementalVm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + incrementalVm, std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) + return eve::script::projectStatusResult(incrementalVm, instance.status(), false, false); + auto projected = eve::script::projectStatusResult(incrementalVm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); + incremental.addFunc( + "updateOriented", + [incrementalVm](IncrementalBuildExecutor* self, BuildLayerStack* buildStack, Grid2D* grid, PointSet* points, + int clusterSizeCells, float cellSizeWorld, PointSet* orientation) -> ssq::Table { + if (!buildStack || !grid || !points || !orientation) + return eve::script::projectStatusResult( + incrementalVm, + Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, + "incremental build input is null")) + .status(), + false, false); + auto result = + self->update(*buildStack, *grid, *points, clusterSizeCells, cellSizeWorld, orientation); + if (!result.ok()) + return eve::script::projectStatusResult(incrementalVm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + incrementalVm, std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) + return eve::script::projectStatusResult(incrementalVm, instance.status(), false, false); + auto projected = eve::script::projectStatusResult(incrementalVm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/GridMeshGraphScript.h b/src/modules/procgen/GridMeshGraphScript.h new file mode 100644 index 00000000..bf88df3d --- /dev/null +++ b/src/modules/procgen/GridMeshGraphScript.h @@ -0,0 +1,11 @@ +#pragma once + +namespace ssq { +class Table; +} + +namespace eve::procgen { + +void exposeGridMeshGraphs(ssq::Table& table); + +} // namespace eve::procgen diff --git a/src/modules/procgen/IncrementalBuild.cpp b/src/modules/procgen/IncrementalBuild.cpp new file mode 100644 index 00000000..7b5daeee --- /dev/null +++ b/src/modules/procgen/IncrementalBuild.cpp @@ -0,0 +1,228 @@ +#include "procgen/IncrementalBuild.h" + +#include "common/Diagnostic.h" +#include "procgen/Semantic.h" + +#include +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +template +Result fail(DiagnosticCode code, std::string message) { + return Result::failure(Diagnostic::error(code, std::move(message), {}, {}, "procgen.incrementalBuild")); +} + +std::uint64_t keyFor(int x, int z) { + return (std::uint64_t(std::uint32_t(x)) << 32U) | std::uint32_t(z); +} + +int keyX(std::uint64_t key) { return std::int32_t(key >> 32U); } +int keyZ(std::uint64_t key) { return std::int32_t(key & 0xffffffffU); } + +void hashBytes(std::uint64_t& hash, const void* bytes, std::size_t count) { + const auto* data = static_cast(bytes); + for (std::size_t index = 0; index < count; ++index) { + hash ^= data[index]; + hash *= 1099511628211ULL; + } +} + +template +void hashValue(std::uint64_t& hash, const T& value) { + hashBytes(hash, &value, sizeof(value)); +} + +void hashString(std::uint64_t& hash, std::string_view value) { + hashBytes(hash, value.data(), value.size()); + const unsigned char separator = 0xff; + hashBytes(hash, &separator, 1); +} + +void hashPoint(std::uint64_t& hash, const PointSet& points, std::size_t row) { + const auto& point = points.points()[row]; + hashValue(hash, point.id); + hashValue(hash, point.x); + hashValue(hash, point.y); + hashValue(hash, point.z); + hashValue(hash, point.normalX); + hashValue(hash, point.normalY); + hashValue(hash, point.normalZ); + hashValue(hash, point.pitch); + hashValue(hash, point.yaw); + hashValue(hash, point.roll); + hashValue(hash, point.scaleX); + hashValue(hash, point.scaleY); + hashValue(hash, point.scaleZ); + hashValue(hash, point.density); + hashValue(hash, point.seed); + hashValue(hash, point.boundsMinX); + hashValue(hash, point.boundsMinY); + hashValue(hash, point.boundsMinZ); + hashValue(hash, point.boundsMaxX); + hashValue(hash, point.boundsMaxY); + hashValue(hash, point.boundsMaxZ); + hashValue(hash, point.colorR); + hashValue(hash, point.colorG); + hashValue(hash, point.colorB); + hashValue(hash, point.colorA); + hashValue(hash, point.steepness); + const auto& attributes = points.attributes(); + for (std::size_t column = 0; column < attributes.columnCount(); ++column) { + const auto name = attributes.columnName(column); + hashString(hash, name); + const auto type = attributes.typeOf(name); + if (!type || !attributes.has(row, name)) { + const std::uint8_t absent = 0; + hashValue(hash, absent); + continue; + } + const auto typeValue = std::uint8_t(*type) + 1; + hashValue(hash, typeValue); + switch (*type) { + case ProcgenAttributeType::Float: hashValue(hash, *attributes.getFloat(row, name)); break; + case ProcgenAttributeType::Int: hashValue(hash, *attributes.getInt(row, name)); break; + case ProcgenAttributeType::Bool: hashValue(hash, *attributes.getBool(row, name)); break; + case ProcgenAttributeType::Vector: { + const auto value = *attributes.getVector(row, name); + hashValue(hash, value.x); + hashValue(hash, value.y); + hashValue(hash, value.z); + break; + } + case ProcgenAttributeType::String: hashString(hash, *attributes.getString(row, name)); break; + } + } +} + +int floorDivision(float value, float divisor) { return int(std::floor(value / divisor)); } + +bool gridContent(const Grid2D& grid, int clusterX, int clusterZ, int size) { + const int minX = std::max(0, clusterX * size); + const int minY = std::max(0, clusterZ * size); + const int maxX = std::min(grid.getWidth(), (clusterX + 1) * size); + const int maxY = std::min(grid.getHeight(), (clusterZ + 1) * size); + for (int y = minY; y < maxY; ++y) + for (int x = minX; x < maxX; ++x) + if (grid.getCell(x, y) != int(Semantic::Empty)) return true; + return false; +} + +std::uint64_t clusterHash(const BuildLayerStack& stack, const Grid2D& grid, const PointSet& points, + const PointSet* orientation, int clusterX, int clusterZ, int size, float worldSize) { + std::uint64_t hash = 1469598103934665603ULL; + const auto definition = stack.serializeDefinition(); + hashString(hash, definition); + const int minX = std::max(0, clusterX * size - 1); + const int minY = std::max(0, clusterZ * size - 1); + const int maxX = std::min(grid.getWidth(), (clusterX + 1) * size + 1); + const int maxY = std::min(grid.getHeight(), (clusterZ + 1) * size + 1); + for (int y = minY; y < maxY; ++y) { + for (int x = minX; x < maxX; ++x) { + const int cell = grid.getCell(x, y); + const int detail = grid.getDetail(x, y); + hashValue(hash, cell); + hashValue(hash, detail); + } + } + for (std::size_t index = 0; index < points.points().size(); ++index) { + const auto& point = points.points()[index]; + if (floorDivision(point.x, worldSize) == clusterX && floorDivision(point.z, worldSize) == clusterZ) + hashPoint(hash, points, index); + } + if (orientation) { + for (std::size_t index = 0; index < orientation->points().size(); ++index) hashPoint(hash, *orientation, index); + } + return hash; +} + +} // namespace + +int IncrementalBuildDelta::getCount() const noexcept { return int(changes_.size()); } +int IncrementalBuildDelta::getClusterX(int index) const noexcept { + return index >= 0 && index < int(changes_.size()) ? changes_[std::size_t(index)].x : 0; +} +int IncrementalBuildDelta::getClusterZ(int index) const noexcept { + return index >= 0 && index < int(changes_.size()) ? changes_[std::size_t(index)].z : 0; +} +bool IncrementalBuildDelta::isRemoved(int index) const noexcept { + return index >= 0 && index < int(changes_.size()) && changes_[std::size_t(index)].removed; +} +Result IncrementalBuildDelta::getArtifacts(int index) const { + if (index < 0 || index >= int(changes_.size())) + return fail(DiagnosticCode::InvalidArgument, "delta index is out of range"); + if (changes_[std::size_t(index)].removed) + return fail(DiagnosticCode::NotFound, "removed cluster has no artifacts"); + return Result::success(changes_[std::size_t(index)].artifacts); +} + +Result IncrementalBuildExecutor::update(const BuildLayerStack& stack, const Grid2D& grid, + const PointSet& points, int clusterSizeCells, + float cellSizeWorld, const PointSet* orientation) { + if (clusterSizeCells <= 0 || !std::isfinite(cellSizeWorld) || cellSizeWorld <= 0.f) + return fail(DiagnosticCode::InvalidArgument, "cluster and cell sizes must be positive"); + const float worldSize = float(clusterSizeCells) * cellSizeWorld; + std::set> candidates; + for (const auto& [key, record] : cache_) candidates.emplace(keyX(key), keyZ(key)); + for (int y = 0; y < grid.getHeight(); ++y) + for (int x = 0; x < grid.getWidth(); ++x) + if (grid.getCell(x, y) != int(Semantic::Empty)) candidates.emplace(x / clusterSizeCells, y / clusterSizeCells); + for (const auto& point : points.points()) + candidates.emplace(floorDivision(point.x, worldSize), floorDivision(point.z, worldSize)); + + IncrementalBuildDelta delta; + std::vector removals; + std::vector> upserts; + for (const auto& [clusterX, clusterZ] : candidates) { + const auto key = keyFor(clusterX, clusterZ); + bool active = gridContent(grid, clusterX, clusterZ, clusterSizeCells); + if (!active) { + for (const auto& point : points.points()) { + if (floorDivision(point.x, worldSize) == clusterX && floorDivision(point.z, worldSize) == clusterZ) { + active = true; + break; + } + } + } + const auto previous = cache_.find(key); + if (!active) { + if (previous != cache_.end()) { + removals.push_back(key); + delta.changes_.push_back({clusterX, clusterZ, true, {}}); + } + continue; + } + const auto hash = clusterHash(stack, grid, points, orientation, clusterX, clusterZ, clusterSizeCells, worldSize); + if (previous != cache_.end() && previous->second.hash == hash) continue; + const int minCellX = std::clamp(clusterX * clusterSizeCells, 0, grid.getWidth()); + const int minCellY = std::clamp(clusterZ * clusterSizeCells, 0, grid.getHeight()); + const int maxCellX = std::clamp((clusterX + 1) * clusterSizeCells, minCellX, grid.getWidth()); + const int maxCellY = std::clamp((clusterZ + 1) * clusterSizeCells, minCellY, grid.getHeight()); + BuildLayerRegion region{minCellX, minCellY, maxCellX, maxCellY, float(clusterX) * worldSize, + float(clusterZ) * worldSize, float(clusterX + 1) * worldSize, + float(clusterZ + 1) * worldSize}; + auto built = stack.executeRegion(grid, points, region, orientation); + if (!built.ok()) return Result::failure(built.status()); + Record record{hash, std::move(built).takeValue()}; + delta.changes_.push_back({clusterX, clusterZ, false, record.artifacts}); + upserts.emplace_back(key, std::move(record)); + } + for (const auto key : removals) cache_.erase(key); + for (auto& [key, record] : upserts) cache_.insert_or_assign(key, std::move(record)); + return Result::success(std::move(delta)); +} + +void IncrementalBuildExecutor::clear() { cache_.clear(); } +int IncrementalBuildExecutor::getCachedClusterCount() const noexcept { return int(cache_.size()); } + +Result IncrementalBuildExecutor::getCachedArtifacts(int clusterX, int clusterZ) const { + const auto found = cache_.find(keyFor(clusterX, clusterZ)); + if (found == cache_.end()) return fail(DiagnosticCode::NotFound, "cluster is not cached"); + return Result::success(found->second.artifacts); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/IncrementalBuild.h b/src/modules/procgen/IncrementalBuild.h new file mode 100644 index 00000000..b27a32e1 --- /dev/null +++ b/src/modules/procgen/IncrementalBuild.h @@ -0,0 +1,70 @@ +#pragma once + +#include "common/Result.h" +#include "procgen/BuildLayerStack.h" + +#include +#include +#include + +namespace eve::procgen { + +/** @brief One cache mutation emitted by an incremental cluster rebuild. */ +struct BuildClusterChange { + int x = 0; + int z = 0; + bool removed = false; + BuildLayerExecution artifacts; +}; + +/** @brief Ordered changes required to synchronize a scene-side cluster cache. */ +class IncrementalBuildDelta { +public: + [[nodiscard]] int getCount() const noexcept; + [[nodiscard]] int getClusterX(int index) const noexcept; + [[nodiscard]] int getClusterZ(int index) const noexcept; + [[nodiscard]] bool isRemoved(int index) const noexcept; + [[nodiscard]] Result getArtifacts(int index) const; + +private: + friend class IncrementalBuildExecutor; + std::vector changes_; +}; + +/** + * @brief Derived cluster cache that incrementally executes a BuildLayerStack. + * + * Grid hashes include a one-cell halo so tile-side geometry is invalidated when a + * neighboring cluster changes. Point ownership follows the source point's half-open + * world-space cluster. Updates are transactional and preserve the old cache on failure. + * @thread Affine; update and query on the owning thread. + */ +class IncrementalBuildExecutor { +public: + /** + * @brief Rebuild only clusters whose layer definition, grid halo, point rows or orientation changed. + * @param stack Authoritative ordered layer definitions. + * @param grid Authoritative tile source. + * @param points Authoritative object placement source. + * @param clusterSizeCells Positive square cluster extent in grid cells. + * @param cellSizeWorld Positive world-space size of one grid cell. + * @param orientation Optional object orientation layer; changes invalidate all active clusters. + * @return Deterministically ordered upsert/removal delta. Empty means the cache was already current. + * @cost O(grid cells + points + dirty cluster build cost); unchanged artifacts are copied only by cache ownership. + */ + [[nodiscard]] Result update(const BuildLayerStack& stack, const Grid2D& grid, + const PointSet& points, int clusterSizeCells, + float cellSizeWorld, const PointSet* orientation = nullptr); + void clear(); + [[nodiscard]] int getCachedClusterCount() const noexcept; + [[nodiscard]] Result getCachedArtifacts(int clusterX, int clusterZ) const; + +private: + struct Record { + std::uint64_t hash = 0; + BuildLayerExecution artifacts; + }; + std::unordered_map cache_; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/MeshGraph.cpp b/src/modules/procgen/MeshGraph.cpp new file mode 100644 index 00000000..413a6484 --- /dev/null +++ b/src/modules/procgen/MeshGraph.cpp @@ -0,0 +1,417 @@ +#include "procgen/MeshGraph.h" + +#include "common/Diagnostic.h" +#include "procgen/Semantic.h" +#include "procgen/TilePreset.h" + +#include +#include +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +enum class ValueType { Grid, Points, Mesh }; + +struct Spec { + const char* id; + int inputs; + ValueType input[2]; + ValueType output; +}; + +constexpr Spec specs[] = { + {"grid.input", 0, {}, ValueType::Grid}, + {"point.input", 0, {}, ValueType::Points}, + {"mesh.input", 0, {}, ValueType::Mesh}, + {"mesh.grid_tiles", 1, {ValueType::Grid}, ValueType::Mesh}, + {"mesh.instance_points", 2, {ValueType::Mesh, ValueType::Points}, ValueType::Mesh}, + {"mesh.merge", 2, {ValueType::Mesh, ValueType::Mesh}, ValueType::Mesh}, + {"mesh.transform", 1, {ValueType::Mesh}, ValueType::Mesh}, +}; + +const Spec* specFor(std::string_view id) { + const auto found = + std::find_if(std::begin(specs), std::end(specs), [id](const Spec& spec) { return id == spec.id; }); + return found == std::end(specs) ? nullptr : found; +} + +template +Result failure(DiagnosticCode code, std::string message, std::string path = {}) { + return Result::failure(Diagnostic::error(code, std::move(message), std::move(path), {}, "procgen.meshGraph")); +} + +Result failureVoid(DiagnosticCode code, std::string message, std::string path = {}) { + return Result::failure(Diagnostic::error(code, std::move(message), std::move(path), {}, "procgen.meshGraph")); +} + +ValueType typeOf(const MeshGraphValue& value) { + if (std::holds_alternative(value)) return ValueType::Grid; + if (std::holds_alternative(value)) return ValueType::Points; + return ValueType::Mesh; +} + +bool acceptsFloat(std::string_view operation, std::string_view key) { + if (operation == "mesh.grid_tiles") + return key == "cellSize" || key == "height" || key == "minX" || key == "minY" || key == "maxX" || + key == "maxY"; + if (operation == "mesh.instance_points") return key == "scale"; + if (operation == "mesh.transform") + return key == "x" || key == "y" || key == "z" || key == "yaw" || key == "sx" || key == "sy" || key == "sz"; + return false; +} + +bool acceptsString(std::string_view operation, std::string_view key) { + return operation == "mesh.grid_tiles" && key == "group"; +} + +void addQuad(MeshBuild& mesh, float ax, float ay, float az, float bx, float by, float bz, float cx, float cy, float cz, + float dx, float dy, float dz, float nx, float ny, float nz) { + const auto base = std::uint32_t(mesh.getVertexCount()); + mesh.addVertex(ax, ay, az, nx, ny, nz, 0.f, 0.f); + mesh.addVertex(bx, by, bz, nx, ny, nz, 1.f, 0.f); + mesh.addVertex(cx, cy, cz, nx, ny, nz, 1.f, 1.f); + mesh.addVertex(dx, dy, dz, nx, ny, nz, 0.f, 1.f); + mesh.addTriangle(base, base + 2, base + 1); + mesh.addTriangle(base, base + 3, base + 2); +} + +MeshBuild buildTiles(const Grid2D& grid, float cellSize, float height, std::string_view group, int minX, int minY, + int maxX, int maxY) { + MeshBuild mesh; + minX = std::clamp(minX, 0, grid.getWidth()); + minY = std::clamp(minY, 0, grid.getHeight()); + maxX = std::clamp(maxX, minX, grid.getWidth()); + maxY = std::clamp(maxY, minY, grid.getHeight()); + for (int y = minY; y < maxY; ++y) { + for (int x = minX; x < maxX; ++x) { + if (grid.getCell(x, y) == int(Semantic::Empty)) continue; + const auto preset = resolveTilePreset(std::uint8_t(grid.getDetail(x, y))); + const std::string cellGroup = + (group.empty() ? std::string("tiles") : std::string(group)) + "/" + std::to_string(grid.getCell(x, y)) + + "/" + std::string(tilePresetKindName(preset.kind)) + "/r" + std::to_string(preset.rotationDegrees) + + (preset.mirrorX ? "/mx/" : "/normal/") + std::to_string(preset.configuration); + mesh.setActiveGroup(cellGroup); + const float x0 = float(x) * cellSize; + const float x1 = x0 + cellSize; + const float z0 = float(y) * cellSize; + const float z1 = z0 + cellSize; + addQuad(mesh, x0, height, z0, x1, height, z0, x1, height, z1, x0, height, z1, 0.f, 1.f, 0.f); + if (grid.getCell(x - 1, y) == int(Semantic::Empty)) + addQuad(mesh, x0, 0.f, z1, x0, 0.f, z0, x0, height, z0, x0, height, z1, -1.f, 0.f, 0.f); + if (grid.getCell(x + 1, y) == int(Semantic::Empty)) + addQuad(mesh, x1, 0.f, z0, x1, 0.f, z1, x1, height, z1, x1, height, z0, 1.f, 0.f, 0.f); + if (grid.getCell(x, y - 1) == int(Semantic::Empty)) + addQuad(mesh, x0, 0.f, z0, x1, 0.f, z0, x1, height, z0, x0, height, z0, 0.f, 0.f, -1.f); + if (grid.getCell(x, y + 1) == int(Semantic::Empty)) + addQuad(mesh, x1, 0.f, z1, x0, 0.f, z1, x0, height, z1, x1, height, z1, 0.f, 0.f, 1.f); + } + } + mesh.setMeta("source", "mesh.grid_tiles"); + return mesh; +} + +} // namespace + +Result MeshGraph::addNode(std::string id, std::string operation) { + if (id.empty()) return failureVoid(DiagnosticCode::InvalidArgument, "node id is empty"); + if (!specFor(operation)) return failureVoid(DiagnosticCode::NotFound, "unknown operation: " + operation, id); + if (nodes_.contains(id)) return failureVoid(DiagnosticCode::Conflict, "duplicate node id", id); + Node node; + node.id = id; + node.operation = std::move(operation); + nodes_.emplace(id, std::move(node)); + order_.push_back(std::move(id)); + ++revision_; + return Result::success(); +} + +Result MeshGraph::connect(std::string_view fromId, std::string_view toId, int inputIndex) { + const auto from = nodes_.find(std::string(fromId)); + const auto to = nodes_.find(std::string(toId)); + if (from == nodes_.end() || to == nodes_.end()) + return failureVoid(DiagnosticCode::NotFound, "connection references an unknown node", std::string(toId)); + const Spec* source = specFor(from->second.operation); + const Spec* target = specFor(to->second.operation); + if (inputIndex < 0 || inputIndex >= target->inputs) + return failureVoid(DiagnosticCode::InvalidArgument, "input slot is outside the operation contract", + std::string(toId)); + if (source->output != target->input[inputIndex]) + return failureVoid(DiagnosticCode::TypeMismatch, "typed graph ports are incompatible", std::string(toId)); + to->second.inputs[inputIndex] = std::string(fromId); + invalidateFrom(toId); + ++revision_; + return Result::success(); +} + +Result MeshGraph::setNodeGrid(std::string_view id, const Grid2D& value) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end() || found->second.operation != "grid.input") + return failureVoid(DiagnosticCode::TypeMismatch, "node is not grid.input", std::string(id)); + found->second.value = value; + found->second.hasValue = true; + invalidateFrom(id); + ++revision_; + return Result::success(); +} + +Result MeshGraph::setNodePoints(std::string_view id, const PointSet& value) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end() || found->second.operation != "point.input") + return failureVoid(DiagnosticCode::TypeMismatch, "node is not point.input", std::string(id)); + found->second.value = value; + found->second.hasValue = true; + invalidateFrom(id); + ++revision_; + return Result::success(); +} + +Result MeshGraph::setNodeMesh(std::string_view id, const MeshBuild& value) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end() || found->second.operation != "mesh.input") + return failureVoid(DiagnosticCode::TypeMismatch, "node is not mesh.input", std::string(id)); + found->second.value = value; + found->second.hasValue = true; + invalidateFrom(id); + ++revision_; + return Result::success(); +} + +Result MeshGraph::setNodeFloat(std::string_view id, std::string key, float value) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end()) return failureVoid(DiagnosticCode::NotFound, "unknown node", std::string(id)); + if (!acceptsFloat(found->second.operation, key)) + return failureVoid(DiagnosticCode::InvalidArgument, "parameter is not declared by this operation: " + key, + std::string(id)); + if (!std::isfinite(value)) + return failureVoid(DiagnosticCode::InvalidArgument, "mesh parameter must be finite", std::string(id)); + found->second.floats[std::move(key)] = value; + invalidateFrom(id); + ++revision_; + return Result::success(); +} + +Result MeshGraph::setNodeString(std::string_view id, std::string key, std::string value) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end()) return failureVoid(DiagnosticCode::NotFound, "unknown node", std::string(id)); + if (!acceptsString(found->second.operation, key)) + return failureVoid(DiagnosticCode::InvalidArgument, "parameter is not declared by this operation: " + key, + std::string(id)); + found->second.strings[std::move(key)] = std::move(value); + invalidateFrom(id); + ++revision_; + return Result::success(); +} + +Result MeshGraph::evaluate(std::string_view id, std::unordered_map& states) { + const auto found = nodes_.find(std::string(id)); + if (found == nodes_.end()) + return failure(DiagnosticCode::NotFound, "unknown node", std::string(id)); + Node& node = found->second; + if (node.cacheValid) return Result::success(node.cache); + if (states[node.id] == 1) + return failure(DiagnosticCode::Conflict, "graph contains a cycle", node.id); + states[node.id] = 1; + const Spec* spec = specFor(node.operation); + std::vector inputs; + for (int input = 0; input < spec->inputs; ++input) { + if (node.inputs[input].empty()) + return failure(DiagnosticCode::NotFound, "required input is disconnected", node.id); + auto value = evaluate(node.inputs[input], states); + if (!value.ok()) return value; + if (typeOf(value.value()) != spec->input[input]) + return failure(DiagnosticCode::TypeMismatch, "runtime port type mismatch", node.id); + inputs.push_back(std::move(value).takeValue()); + } + if (spec->inputs == 0) { + if (!node.hasValue) + return failure(DiagnosticCode::NotFound, "input node has no value", node.id); + node.cache = node.value; + } else if (node.operation == "mesh.grid_tiles") { + const auto& grid = std::get(inputs[0]); + node.cache = buildTiles( + grid, node.floats.contains("cellSize") ? node.floats["cellSize"] : 1.f, + node.floats.contains("height") ? node.floats["height"] : 1.f, + node.strings.contains("group") ? node.strings["group"] : "tiles", + node.floats.contains("minX") ? int(node.floats["minX"]) : 0, + node.floats.contains("minY") ? int(node.floats["minY"]) : 0, + node.floats.contains("maxX") ? int(node.floats["maxX"]) : grid.getWidth(), + node.floats.contains("maxY") ? int(node.floats["maxY"]) : grid.getHeight()); + } else if (node.operation == "mesh.merge") { + MeshBuild result = std::get(inputs[0]); + const MeshBuild& other = std::get(inputs[1]); + if (!result.appendTransformed(&other, 0.f, 0.f, 0.f, 0.f, 1.f, 1.f, 1.f)) + return failure(DiagnosticCode::Failed, "mesh merge failed", node.id); + node.cache = std::move(result); + } else if (node.operation == "mesh.transform") { + MeshBuild result; + const MeshBuild& source = std::get(inputs[0]); + if (!result.appendTransformed(&source, node.floats["x"], node.floats["y"], node.floats["z"], node.floats["yaw"], + node.floats.contains("sx") ? node.floats["sx"] : 1.f, + node.floats.contains("sy") ? node.floats["sy"] : 1.f, + node.floats.contains("sz") ? node.floats["sz"] : 1.f)) + return failure(DiagnosticCode::InvalidArgument, "mesh transform failed", node.id); + node.cache = std::move(result); + } else if (node.operation == "mesh.instance_points") { + MeshBuild result; + const MeshBuild& source = std::get(inputs[0]); + const PointSet& points = std::get(inputs[1]); + const float scale = node.floats.contains("scale") ? node.floats["scale"] : 1.f; + for (int index = 0; index < points.getCount(); ++index) + if (!result.appendTransformed(&source, points.getX(index), points.getY(index), points.getZ(index), 0.f, + scale, scale, scale)) + return failure(DiagnosticCode::InvalidArgument, "point instancing failed", node.id); + node.cache = std::move(result); + } + states[node.id] = 2; + node.cacheValid = true; + return Result::success(node.cache); +} + +Result MeshGraph::execute(std::string_view outputId) { + std::unordered_map states; + auto value = evaluate(outputId, states); + if (!value.ok()) return Result::failure(value.status()); + const auto* mesh = std::get_if(&value.value()); + if (!mesh) + return failure(DiagnosticCode::TypeMismatch, "output node is not a mesh", std::string(outputId)); + return Result::success(*mesh); +} + +Result MeshGraph::validate(std::string_view outputId) const { + MeshGraph copy = *this; + auto result = copy.execute(outputId); + if (!result.ok()) return Result::failure(result.status()); + return Result::success(); +} + +void MeshGraph::clearCache() { + for (auto& [id, node] : nodes_) node.cacheValid = false; +} + +void MeshGraph::invalidateFrom(std::string_view id) { + std::queue pending; + std::unordered_set visited; + pending.push(std::string(id)); + while (!pending.empty()) { + const std::string current = pending.front(); + pending.pop(); + if (!visited.insert(current).second) continue; + const auto found = nodes_.find(current); + if (found != nodes_.end()) found->second.cacheValid = false; + for (const auto& [otherId, node] : nodes_) + if (node.inputs[0] == current || node.inputs[1] == current) pending.push(otherId); + } +} + +std::string MeshGraph::serializeDefinition() const { + std::ostringstream out; + out << "EVPCG_MESH_GRAPH 1\n" << std::setprecision(9); + for (const auto& id : order_) { + const auto& node = nodes_.at(id); + out << "NODE " << std::quoted(id) << ' ' << std::quoted(node.operation) << '\n'; + std::vector keys; + for (const auto& [key, value] : node.floats) keys.push_back(key); + std::sort(keys.begin(), keys.end()); + for (const auto& key : keys) + out << "FLOAT " << std::quoted(id) << ' ' << std::quoted(key) << ' ' << node.floats.at(key) << '\n'; + keys.clear(); + for (const auto& [key, value] : node.strings) keys.push_back(key); + std::sort(keys.begin(), keys.end()); + for (const auto& key : keys) + out << "STRING " << std::quoted(id) << ' ' << std::quoted(key) << ' ' << std::quoted(node.strings.at(key)) + << '\n'; + } + for (const auto& id : order_) { + const auto& node = nodes_.at(id); + for (int input = 0; input < 2; ++input) + if (!node.inputs[input].empty()) + out << "EDGE " << std::quoted(node.inputs[input]) << ' ' << std::quoted(id) << ' ' << input << '\n'; + } + out << "END\n"; + return out.str(); +} + +Result MeshGraph::deserializeDefinition(std::string_view definition) { + MeshGraph replacement; + std::istringstream input{std::string(definition)}; + std::string magic; + int version = 0; + if (!(input >> magic >> version) || magic != "EVPCG_MESH_GRAPH" || version != 1) + return failureVoid(DiagnosticCode::ParseError, "invalid mesh graph header"); + std::string line; + std::getline(input, line); + bool ended = false; + while (std::getline(input, line)) { + if (line.empty()) continue; + std::istringstream record(line); + std::string kind; + record >> kind; + if (kind == "END") { + ended = true; + break; + } + std::string id; + auto result = [&]() -> Result { + if (kind == "NODE") { + std::string operation; + if (!(record >> std::quoted(id) >> std::quoted(operation))) + return failureVoid(DiagnosticCode::ParseError, "invalid NODE record"); + return replacement.addNode(std::move(id), std::move(operation)); + } + if (kind == "EDGE") { + std::string target; + int slot = -1; + if (!(record >> std::quoted(id) >> std::quoted(target) >> slot)) + return failureVoid(DiagnosticCode::ParseError, "invalid EDGE record"); + return replacement.connect(id, target, slot); + } + if (kind == "FLOAT") { + std::string key; + float value = 0.f; + if (!(record >> std::quoted(id) >> std::quoted(key) >> value)) + return failureVoid(DiagnosticCode::ParseError, "invalid FLOAT record"); + return replacement.setNodeFloat(id, std::move(key), value); + } + if (kind == "STRING") { + std::string key; + std::string value; + if (!(record >> std::quoted(id) >> std::quoted(key) >> std::quoted(value))) + return failureVoid(DiagnosticCode::ParseError, "invalid STRING record"); + return replacement.setNodeString(id, std::move(key), std::move(value)); + } + return failureVoid(DiagnosticCode::ParseError, "unknown mesh graph record: " + kind); + }(); + if (!result.ok()) return result; + record >> std::ws; + if (!record.eof()) return failureVoid(DiagnosticCode::ParseError, "trailing mesh graph record data"); + } + if (!ended) return failureVoid(DiagnosticCode::ParseError, "mesh graph END record is missing"); + + std::unordered_map states; + const auto visit = [&](const auto& self, const std::string& id) -> Result { + if (states[id] == 2) return Result::success(); + if (states[id] == 1) return failureVoid(DiagnosticCode::Conflict, "cycle in serialized graph", id); + states[id] = 1; + const auto found = replacement.nodes_.find(id); + if (found == replacement.nodes_.end()) return failureVoid(DiagnosticCode::NotFound, "unknown node", id); + for (const auto& dependency : found->second.inputs) { + if (dependency.empty()) continue; + auto result = self(self, dependency); + if (!result.ok()) return result; + } + states[id] = 2; + return Result::success(); + }; + for (const auto& id : replacement.order_) { + auto result = visit(visit, id); + if (!result.ok()) return result; + } + *this = std::move(replacement); + return Result::success(); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/MeshGraph.h b/src/modules/procgen/MeshGraph.h new file mode 100644 index 00000000..44a01097 --- /dev/null +++ b/src/modules/procgen/MeshGraph.h @@ -0,0 +1,61 @@ +#pragma once + +#include "common/Result.h" +#include "procgen/Grid2D.h" +#include "procgen/MeshBuild.h" +#include "procgen/PointSet.h" + +#include +#include +#include +#include +#include +#include + +namespace eve::procgen { + +/** @brief Strong values accepted by MeshGraph ports. */ +using MeshGraphValue = std::variant; + +/** @brief Typed procedural mesh graph composing grid, point and topology domains. */ +class MeshGraph { +public: + [[nodiscard]] Result addNode(std::string id, std::string operation); + [[nodiscard]] Result connect(std::string_view fromId, std::string_view toId, int inputIndex = 0); + [[nodiscard]] Result setNodeGrid(std::string_view id, const Grid2D& value); + [[nodiscard]] Result setNodePoints(std::string_view id, const PointSet& value); + [[nodiscard]] Result setNodeMesh(std::string_view id, const MeshBuild& value); + [[nodiscard]] Result setNodeFloat(std::string_view id, std::string key, float value); + [[nodiscard]] Result setNodeString(std::string_view id, std::string key, std::string value); + /** @cost Linear in emitted vertices and indices; point instancing multiplies source mesh cost by point count. */ + [[nodiscard]] Result execute(std::string_view outputId); + [[nodiscard]] Result validate(std::string_view outputId) const; + void clearCache(); + [[nodiscard]] std::uint64_t revision() const noexcept { return revision_; } + /** @brief Serialize topology and scalar parameters using the versioned EVPCG mesh schema. */ + [[nodiscard]] std::string serializeDefinition() const; + /** @brief Atomically replace this graph from a versioned definition. Runtime input values are not persisted. */ + [[nodiscard]] Result deserializeDefinition(std::string_view definition); + +private: + struct Node { + std::string id; + std::string operation; + std::string inputs[2]; + std::unordered_map floats; + std::unordered_map strings; + MeshGraphValue value; + bool hasValue = false; + MeshGraphValue cache; + bool cacheValid = false; + }; + + [[nodiscard]] Result evaluate(std::string_view id, std::unordered_map& states); + void invalidateFrom(std::string_view id); + + std::unordered_map nodes_; + std::vector order_; + std::uint64_t revision_ = 0; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/ObjectBuildLayer.cpp b/src/modules/procgen/ObjectBuildLayer.cpp new file mode 100644 index 00000000..fccb05e7 --- /dev/null +++ b/src/modules/procgen/ObjectBuildLayer.cpp @@ -0,0 +1,419 @@ +#include "procgen/ObjectBuildLayer.h" + +#include "common/Diagnostic.h" + +#include +#include +#include +#include +#include + +namespace eve::procgen { +namespace { + +template +Result fail(std::string message, std::string path = {}) { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path), + {}, "procgen.objectBuild")); +} + +bool finiteRange(float minimum, float maximum) { + return std::isfinite(minimum) && std::isfinite(maximum) && minimum <= maximum; +} + +Result validateRange(const ObjectTransformRange& range) { + if (!finiteRange(range.minPitch, range.maxPitch) || !finiteRange(range.minYaw, range.maxYaw) || + !finiteRange(range.minRoll, range.maxRoll) || !finiteRange(range.minScaleX, range.maxScaleX) || + !finiteRange(range.minScaleY, range.maxScaleY) || !finiteRange(range.minScaleZ, range.maxScaleZ)) + return fail("random transform ranges must be finite and ordered", "transform"); + if (range.minScaleX == 0.f || range.maxScaleX == 0.f || range.minScaleY == 0.f || range.maxScaleY == 0.f || + range.minScaleZ == 0.f || range.maxScaleZ == 0.f) + return fail("random scale bounds must be non-zero", "transform.scale"); + return Result::success(); +} + +std::uint64_t mix(std::uint64_t value) { + value += 0x9e3779b97f4a7c15ULL; + value = (value ^ (value >> 30U)) * 0xbf58476d1ce4e5b9ULL; + value = (value ^ (value >> 27U)) * 0x94d049bb133111ebULL; + return value ^ (value >> 31U); +} + +struct RandomStream { + std::uint64_t state; + std::uint64_t next() { + state = mix(state); + return state; + } + float unit() { return float(next() >> 40U) * (1.f / 16777216.f); } + float between(float minimum, float maximum) { return minimum + (maximum - minimum) * unit(); } +}; + +std::uint64_t pointKey(const ProcgenPoint& point, std::size_t index, std::uint32_t seed) { + if (point.id != 0) return mix(point.id ^ (std::uint64_t(seed) << 32U)); + const auto qx = std::int64_t(std::llround(double(point.x) * 1000.0)); + const auto qy = std::int64_t(std::llround(double(point.y) * 1000.0)); + const auto qz = std::int64_t(std::llround(double(point.z) * 1000.0)); + return mix(std::uint64_t(qx) ^ (mix(std::uint64_t(qy)) << 1U) ^ (mix(std::uint64_t(qz)) << 2U) ^ + std::uint64_t(index) ^ (std::uint64_t(seed) << 32U)); +} + +float orientedYaw(const ProcgenPoint& point, const PointSet& orientation, float cellSize, bool invert) { + struct Direction { + float x; + float z; + float yaw; + }; + const Direction cardinal[] = { + {0.f, 1.f, invert ? 90.f : 270.f}, {1.f, 0.f, 0.f}, {0.f, -1.f, invert ? 270.f : 90.f}, {-1.f, 0.f, 180.f}}; + const float tolerance = std::max(0.0001f, cellSize * 0.01f); + const auto matches = [&](float x, float z) { + return std::any_of(orientation.points().begin(), orientation.points().end(), [&](const ProcgenPoint& other) { + return std::abs(other.x - x) <= tolerance && std::abs(other.z - z) <= tolerance; + }); + }; + for (const auto& direction : cardinal) + if (matches(point.x + direction.x * cellSize, point.z + direction.z * cellSize)) return direction.yaw; + const Direction diagonal[] = {{1.f, 1.f, 90.f}, {1.f, -1.f, 0.f}, {-1.f, -1.f, 180.f}, {-1.f, 1.f, 180.f}}; + for (const auto& direction : diagonal) + if (matches(point.x + direction.x * cellSize, point.z + direction.z * cellSize)) return direction.yaw; + return 0.f; +} + +void applyRandomTransform(ProcgenPoint& point, const ObjectTransformRange& range, RandomStream& random) { + point.pitch += random.between(range.minPitch, range.maxPitch); + point.yaw += random.between(range.minYaw, range.maxYaw); + point.roll += random.between(range.minRoll, range.maxRoll); + if (range.uniformScale) { + const float scale = random.between(range.minScaleX, range.maxScaleX); + point.scaleX *= scale; + point.scaleY *= scale; + point.scaleZ *= scale; + } else { + point.scaleX *= random.between(range.minScaleX, range.maxScaleX); + point.scaleY *= random.between(range.minScaleY, range.maxScaleY); + point.scaleZ *= random.between(range.minScaleZ, range.maxScaleZ); + } +} + +Result tagPoint(PointSet& output, int index, const std::string& asset, std::string role, std::int64_t parent) { + auto result = output.trySetStringAttribute(index, "asset", asset); + if (!result.ok()) return result; + result = output.trySetStringAttribute(index, "object_role", role); + if (!result.ok()) return result; + return output.trySetIntAttribute(index, "parent_index", parent); +} + +} // namespace + +Result ObjectBuildLayer::addAsset(std::string asset, float weight) { + if (asset.empty()) return fail("asset id is empty", "asset"); + if (!std::isfinite(weight) || weight <= 0.f) return fail("asset weight must be finite and positive", asset); + assets_.push_back({std::move(asset), weight}); + return Result::success(); +} + +void ObjectBuildLayer::clearAssets() { assets_.clear(); } +void ObjectBuildLayer::setSeed(std::uint32_t seed) noexcept { seed_ = seed == 0 ? 1 : seed; } +void ObjectBuildLayer::setLayerOffset(float x, float y, float z) noexcept { + offsetX_ = x; + offsetY_ = y; + offsetZ_ = z; +} + +Result ObjectBuildLayer::setLayerScale(float x, float y, float z) { + if (!std::isfinite(x) || !std::isfinite(y) || !std::isfinite(z) || x == 0.f || y == 0.f || z == 0.f) + return fail("layer scale must be finite and non-zero", "scale"); + scaleX_ = x; + scaleY_ = y; + scaleZ_ = z; + return Result::success(); +} + +Result ObjectBuildLayer::setPositionRadius(float radius) { + if (!std::isfinite(radius) || radius < 0.f) return fail("position radius must be finite and non-negative"); + positionRadius_ = radius; + return Result::success(); +} + +Result ObjectBuildLayer::setRandomTransform(const ObjectTransformRange& range) { + auto valid = validateRange(range); + if (!valid.ok()) return valid; + transform_ = range; + return Result::success(); +} + +Result ObjectBuildLayer::setRandomRotation(float minPitch, float maxPitch, float minYaw, float maxYaw, + float minRoll, float maxRoll) { + auto candidate = transform_; + candidate.minPitch = minPitch; + candidate.maxPitch = maxPitch; + candidate.minYaw = minYaw; + candidate.maxYaw = maxYaw; + candidate.minRoll = minRoll; + candidate.maxRoll = maxRoll; + return setRandomTransform(candidate); +} + +Result ObjectBuildLayer::setRandomScale(float minX, float maxX, float minY, float maxY, float minZ, float maxZ, + bool uniform) { + auto candidate = transform_; + candidate.minScaleX = minX; + candidate.maxScaleX = maxX; + candidate.minScaleY = minY; + candidate.maxScaleY = maxY; + candidate.minScaleZ = minZ; + candidate.maxScaleZ = maxZ; + candidate.uniformScale = uniform; + return setRandomTransform(candidate); +} + +Result ObjectBuildLayer::setOrientation(float cellSize, float yawOffset, bool invert) { + if (!std::isfinite(cellSize) || cellSize <= 0.f) return fail("orientation cell size must be positive"); + if (!std::isfinite(yawOffset)) return fail("orientation yaw offset must be finite"); + orient_ = true; + orientationCellSize_ = cellSize; + orientationYawOffset_ = yawOffset; + invertOrientation_ = invert; + return Result::success(); +} + +void ObjectBuildLayer::disableOrientation() noexcept { orient_ = false; } + +void ObjectBuildLayer::setPlaceOnTop(bool enabled, bool useLowest, float topOffset) noexcept { + placeOnTop_ = enabled; + useLowest_ = useLowest; + topOffset_ = std::isfinite(topOffset) ? topOffset : 0.f; +} + +Result ObjectBuildLayer::addChildRule(std::string asset, int count, float radius, + const ObjectTransformRange& range) { + if (asset.empty()) return fail("child asset id is empty", "child.asset"); + if (count < 0) return fail("child count must be non-negative", asset); + if (!std::isfinite(radius) || radius < 0.f) + return fail("child radius must be finite and non-negative", asset); + auto valid = validateRange(range); + if (!valid.ok()) return valid; + children_.push_back({std::move(asset), count, radius, range}); + return Result::success(); +} + +Result ObjectBuildLayer::addChild(std::string asset, int count, float radius, float minScale, float maxScale, + float minYaw, float maxYaw) { + ObjectTransformRange range; + range.minScaleX = minScale; + range.maxScaleX = maxScale; + range.minScaleY = minScale; + range.maxScaleY = maxScale; + range.minScaleZ = minScale; + range.maxScaleZ = maxScale; + range.uniformScale = true; + range.minYaw = minYaw; + range.maxYaw = maxYaw; + return addChildRule(std::move(asset), count, radius, range); +} + +void ObjectBuildLayer::clearChildRules() { children_.clear(); } + +Result ObjectBuildLayer::build(const PointSet& source, const PointSet* orientation) const { + if (assets_.empty()) return fail("object build layer has no assets", "assets"); + if (orient_ && !orientation) return fail("orientation is enabled but no orientation layer was supplied"); + float totalWeight = 0.f; + for (const auto& asset : assets_) totalWeight += asset.weight; + if (!std::isfinite(totalWeight) || totalWeight <= 0.f) return fail("total asset weight is invalid"); + + std::size_t childCount = 0; + for (const auto& child : children_) childCount += std::size_t(child.count); + PointSet output; + output.reserve(source.points().size() * (childCount + 1)); + for (std::size_t sourceIndex = 0; sourceIndex < source.points().size(); ++sourceIndex) { + const auto& input = source.points()[sourceIndex]; + RandomStream random{pointKey(input, sourceIndex, seed_)}; + float selection = random.unit() * totalWeight; + const Asset* selected = &assets_.back(); + for (const auto& asset : assets_) { + selection -= asset.weight; + if (selection < 0.f) { + selected = &asset; + break; + } + } + + auto appended = output.appendPointFrom(source, sourceIndex); + if (!appended.ok()) return Result::failure(appended.status()); + const int parentIndex = appended.value(); + auto& parent = output.mutablePoint(std::size_t(parentIndex)); + if (positionRadius_ > 0.f) { + const float angle = random.unit() * 6.283185307179586f; + const float radius = std::sqrt(random.unit()) * positionRadius_; + parent.x += std::cos(angle) * radius; + parent.z += std::sin(angle) * radius; + } + parent.x += offsetX_; + parent.y += offsetY_; + parent.z += offsetZ_; + if (placeOnTop_) { + const char* attribute = useLowest_ ? "surface_lowest_y" : "surface_highest_y"; + if (source.hasFloatAttribute(int(sourceIndex), attribute)) + parent.y = source.getFloatAttribute(int(sourceIndex), attribute, parent.y) + topOffset_ + offsetY_; + } + applyRandomTransform(parent, transform_, random); + parent.scaleX *= scaleX_; + parent.scaleY *= scaleY_; + parent.scaleZ *= scaleZ_; + if (orient_) + parent.yaw = orientedYaw(input, *orientation, orientationCellSize_, invertOrientation_) + + orientationYawOffset_ + random.between(transform_.minYaw, transform_.maxYaw); + auto tagged = tagPoint(output, parentIndex, selected->id, "parent", -1); + if (!tagged.ok()) return Result::failure(tagged.status()); + + for (std::size_t ruleIndex = 0; ruleIndex < children_.size(); ++ruleIndex) { + const auto& rule = children_[ruleIndex]; + RandomStream childRandom{mix(pointKey(input, sourceIndex, seed_) ^ (std::uint64_t(ruleIndex + 1) << 32U))}; + for (int childOrdinal = 0; childOrdinal < rule.count; ++childOrdinal) { + ProcgenPoint child = parent; + child.id = derivePointId(parent.id != 0 ? parent.id : pointKey(input, sourceIndex, seed_), + std::uint64_t(ruleIndex) * 1000000ULL + std::uint64_t(childOrdinal + 1)); + const float angle = childRandom.unit() * 6.283185307179586f; + const float radius = std::sqrt(childRandom.unit()) * rule.radius; + child.x += std::cos(angle) * radius; + child.z += std::sin(angle) * radius; + child.pitch = 0.f; + child.yaw = 0.f; + child.roll = 0.f; + child.scaleX = 1.f; + child.scaleY = 1.f; + child.scaleZ = 1.f; + applyRandomTransform(child, rule.transform, childRandom); + const int childIndex = output.appendPoint(child); + tagged = tagPoint(output, childIndex, rule.asset, "child", parentIndex); + if (!tagged.ok()) return Result::failure(tagged.status()); + } + } + } + return Result::success(std::move(output)); +} + +std::string ObjectBuildLayer::serializeDefinition() const { + std::ostringstream out; + out << "EVPCG_OBJECT_LAYER 1\n" << std::setprecision(9); + out << "SEED " << seed_ << '\n'; + out << "OFFSET " << offsetX_ << ' ' << offsetY_ << ' ' << offsetZ_ << '\n'; + out << "SCALE " << scaleX_ << ' ' << scaleY_ << ' ' << scaleZ_ << '\n'; + out << "POSITION_RADIUS " << positionRadius_ << '\n'; + out << "RANDOM " << transform_.minPitch << ' ' << transform_.maxPitch << ' ' << transform_.minYaw << ' ' + << transform_.maxYaw << ' ' << transform_.minRoll << ' ' << transform_.maxRoll << ' ' << transform_.minScaleX + << ' ' << transform_.maxScaleX << ' ' << transform_.minScaleY << ' ' << transform_.maxScaleY << ' ' + << transform_.minScaleZ << ' ' << transform_.maxScaleZ << ' ' << int(transform_.uniformScale) << '\n'; + out << "ORIENTATION " << int(orient_) << ' ' << orientationCellSize_ << ' ' << orientationYawOffset_ << ' ' + << int(invertOrientation_) << '\n'; + out << "PLACE " << int(placeOnTop_) << ' ' << int(useLowest_) << ' ' << topOffset_ << '\n'; + for (const auto& asset : assets_) out << "ASSET " << std::quoted(asset.id) << ' ' << asset.weight << '\n'; + for (const auto& child : children_) { + const auto& t = child.transform; + out << "CHILD " << std::quoted(child.asset) << ' ' << child.count << ' ' << child.radius << ' ' << t.minPitch + << ' ' << t.maxPitch << ' ' << t.minYaw << ' ' << t.maxYaw << ' ' << t.minRoll << ' ' << t.maxRoll << ' ' + << t.minScaleX << ' ' << t.maxScaleX << ' ' << t.minScaleY << ' ' << t.maxScaleY << ' ' << t.minScaleZ + << ' ' << t.maxScaleZ << ' ' << int(t.uniformScale) << '\n'; + } + out << "END\n"; + return out.str(); +} + +Result ObjectBuildLayer::deserializeDefinition(std::string_view definition) { + ObjectBuildLayer replacement; + std::istringstream input{std::string(definition)}; + std::string magic; + int version = 0; + if (!(input >> magic >> version) || magic != "EVPCG_OBJECT_LAYER" || version != 1) + return fail("invalid object layer header"); + std::string line; + std::getline(input, line); + bool ended = false; + while (std::getline(input, line)) { + if (line.empty()) continue; + std::istringstream record(line); + std::string kind; + record >> kind; + if (kind == "END") { + ended = true; + break; + } + if (kind == "SEED") { + std::uint32_t seed = 0; + if (!(record >> seed)) return fail("invalid SEED record"); + replacement.setSeed(seed); + } else if (kind == "OFFSET") { + float x = 0.f, y = 0.f, z = 0.f; + if (!(record >> x >> y >> z) || !std::isfinite(x) || !std::isfinite(y) || !std::isfinite(z)) + return fail("invalid OFFSET record"); + replacement.setLayerOffset(x, y, z); + } else if (kind == "SCALE") { + float x = 0.f, y = 0.f, z = 0.f; + if (!(record >> x >> y >> z)) return fail("invalid SCALE record"); + auto applied = replacement.setLayerScale(x, y, z); + if (!applied.ok()) return applied; + } else if (kind == "POSITION_RADIUS") { + float radius = 0.f; + if (!(record >> radius)) return fail("invalid POSITION_RADIUS record"); + auto applied = replacement.setPositionRadius(radius); + if (!applied.ok()) return applied; + } else if (kind == "RANDOM") { + ObjectTransformRange range; + int uniform = 0; + if (!(record >> range.minPitch >> range.maxPitch >> range.minYaw >> range.maxYaw >> range.minRoll >> + range.maxRoll >> range.minScaleX >> range.maxScaleX >> range.minScaleY >> range.maxScaleY >> + range.minScaleZ >> range.maxScaleZ >> uniform) || + (uniform != 0 && uniform != 1)) + return fail("invalid RANDOM record"); + range.uniformScale = uniform != 0; + auto applied = replacement.setRandomTransform(range); + if (!applied.ok()) return applied; + } else if (kind == "ORIENTATION") { + int enabled = 0, invert = 0; + float cell = 0.f, offset = 0.f; + if (!(record >> enabled >> cell >> offset >> invert) || (enabled != 0 && enabled != 1) || + (invert != 0 && invert != 1)) + return fail("invalid ORIENTATION record"); + if (enabled) { + auto applied = replacement.setOrientation(cell, offset, invert != 0); + if (!applied.ok()) return applied; + } + } else if (kind == "PLACE") { + int enabled = 0, lowest = 0; + float offset = 0.f; + if (!(record >> enabled >> lowest >> offset) || (enabled != 0 && enabled != 1) || + (lowest != 0 && lowest != 1) || !std::isfinite(offset)) + return fail("invalid PLACE record"); + replacement.setPlaceOnTop(enabled != 0, lowest != 0, offset); + } else if (kind == "ASSET") { + std::string asset; + float weight = 0.f; + if (!(record >> std::quoted(asset) >> weight)) return fail("invalid ASSET record"); + auto applied = replacement.addAsset(std::move(asset), weight); + if (!applied.ok()) return applied; + } else if (kind == "CHILD") { + std::string asset; + int count = 0, uniform = 0; + float radius = 0.f; + ObjectTransformRange range; + if (!(record >> std::quoted(asset) >> count >> radius >> range.minPitch >> range.maxPitch >> range.minYaw >> + range.maxYaw >> range.minRoll >> range.maxRoll >> range.minScaleX >> range.maxScaleX >> + range.minScaleY >> range.maxScaleY >> range.minScaleZ >> range.maxScaleZ >> uniform) || + (uniform != 0 && uniform != 1)) + return fail("invalid CHILD record"); + range.uniformScale = uniform != 0; + auto applied = replacement.addChildRule(std::move(asset), count, radius, range); + if (!applied.ok()) return applied; + } else { + return fail("unknown object layer record: " + kind); + } + record >> std::ws; + if (!record.eof()) return fail("trailing object layer record data"); + } + if (!ended) return fail("object layer END record is missing"); + *this = std::move(replacement); + return Result::success(); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/ObjectBuildLayer.h b/src/modules/procgen/ObjectBuildLayer.h new file mode 100644 index 00000000..538af7d4 --- /dev/null +++ b/src/modules/procgen/ObjectBuildLayer.h @@ -0,0 +1,125 @@ +#pragma once + +#include "common/Result.h" +#include "procgen/PointSet.h" + +#include +#include +#include + +namespace eve::procgen { + +/** @brief Random transform interval used by one object build rule. */ +struct ObjectTransformRange { + float minPitch = 0.f; + float maxPitch = 0.f; + float minYaw = 0.f; + float maxYaw = 0.f; + float minRoll = 0.f; + float maxRoll = 0.f; + float minScaleX = 1.f; + float maxScaleX = 1.f; + float minScaleY = 1.f; + float maxScaleY = 1.f; + float minScaleZ = 1.f; + float maxScaleZ = 1.f; + bool uniformScale = false; +}; + +/** + * @brief Deterministic TileWorldCreator-style object build layer. + * + * Input and output are owned values. Each source point emits one parent object point; + * child rules emit additional points carrying `asset`, `object_role` and `parent_index` + * attributes. The layer never creates scene objects or retains input pointers. + * @thread Affine; configure and build on the owning thread. + */ +class ObjectBuildLayer { +public: + /** @brief Add a weighted asset candidate. Zero and negative weights are rejected. */ + [[nodiscard]] Result addAsset(std::string asset, float weight); + void clearAssets(); + + /** @brief Set the named deterministic random stream seed. */ + void setSeed(std::uint32_t seed) noexcept; + /** @brief Set fixed layer translation applied after source transforms. */ + void setLayerOffset(float x, float y, float z) noexcept; + /** @brief Set fixed component-wise scale multiplier. Values must be non-zero. */ + [[nodiscard]] Result setLayerScale(float x, float y, float z); + /** @brief Set circular XZ position jitter radius. */ + [[nodiscard]] Result setPositionRadius(float radius); + /** @brief Configure independent random Euler and scale intervals for parent objects. */ + [[nodiscard]] Result setRandomTransform(const ObjectTransformRange& range); + /** @brief Update only the parent Euler randomization interval. */ + [[nodiscard]] Result setRandomRotation(float minPitch, float maxPitch, float minYaw, float maxYaw, + float minRoll, float maxRoll); + /** @brief Update only the parent scale randomization interval. */ + [[nodiscard]] Result setRandomScale(float minX, float maxX, float minY, float maxY, float minZ, float maxZ, + bool uniform); + /** @brief Enable orientation toward the first matching N/E/S/W then diagonal orientation point. */ + [[nodiscard]] Result setOrientation(float cellSize, float yawOffset, bool invert); + void disableOrientation() noexcept; + /** @brief Project output Y to `surface_lowest_y` or `surface_highest_y`, plus offset, when present. */ + void setPlaceOnTop(bool enabled, bool useLowest, float topOffset) noexcept; + + /** + * @brief Add a child-spawn rule with its own deterministic transform stream. + * @param asset Asset identifier written to emitted child points. + * @param count Children emitted per parent. + * @param radius Circular XZ scatter radius around the parent. + * @param range Random rotation and scale interval. + */ + [[nodiscard]] Result addChildRule(std::string asset, int count, float radius, + const ObjectTransformRange& range); + /** @brief Script-friendly child rule using uniform scale and yaw ranges. */ + [[nodiscard]] Result addChild(std::string asset, int count, float radius, float minScale, float maxScale, + float minYaw, float maxYaw); + void clearChildRules(); + + /** + * @brief Build attributed object points from source points. + * @param source Placement points; their attributes and transforms are preserved for parents. + * @param orientation Optional point layer used to derive cardinal orientation. + * @return Complete result, or a diagnostic without mutating the layer or either input. + * @cost O(P*A + P*C + P*O) where P is source points, A assets, C emitted children, + * and O orientation points when orientation is enabled. + */ + [[nodiscard]] Result build(const PointSet& source, const PointSet* orientation = nullptr) const; + /** @brief Serialize all layer settings using the versioned EVPCG object-layer schema. */ + [[nodiscard]] std::string serializeDefinition() const; + /** @brief Atomically replace settings from schema version 1; unknown records are rejected. */ + [[nodiscard]] Result deserializeDefinition(std::string_view definition); + +private: + struct Asset { + std::string id; + float weight = 1.f; + }; + struct ChildRule { + std::string asset; + int count = 0; + float radius = 0.f; + ObjectTransformRange transform; + }; + + std::vector assets_; + std::vector children_; + ObjectTransformRange transform_; + std::uint32_t seed_ = 1; + float offsetX_ = 0.f; + float offsetY_ = 0.f; + float offsetZ_ = 0.f; + float scaleX_ = 1.f; + float scaleY_ = 1.f; + float scaleZ_ = 1.f; + float positionRadius_ = 0.f; + bool orient_ = false; + bool invertOrientation_ = false; + float orientationCellSize_ = 1.f; + float orientationYawOffset_ = 0.f; + bool placeOnTop_ = false; + bool useLowest_ = false; + float topOffset_ = 0.f; +}; + +} // namespace eve::procgen diff --git a/src/modules/procgen/Procgen.cpp b/src/modules/procgen/Procgen.cpp index cbd77c12..b63d0183 100644 --- a/src/modules/procgen/Procgen.cpp +++ b/src/modules/procgen/Procgen.cpp @@ -5,6 +5,7 @@ #include "common/ProcgenWorldQuery.h" #include "common/SquirrelBinding.h" #include "procgen/BiomeScript.h" +#include "procgen/GridMeshGraphScript.h" #include "procgen/PointGraphScript.h" #include "procgen/ProcgenCapabilities.h" #include "procgen/RuntimeGenerationScript.h" @@ -135,8 +136,8 @@ ssq::Table makeOwnedNativeProxy(HSQUIRRELVM vm, eve::Result&& reference, Re false, false); } - const SQInteger top = sq_gettop(vm); - const size_t hashCode = eve::script::detail::squirrelTypeHash(); + const SQInteger top = sq_gettop(vm); + const size_t hashCode = eve::script::detail::squirrelTypeHash(); sq_pushobject(vm, ssq::detail::getClassObj(vm, hashCode)); if (SQ_FAILED(sq_createinstance(vm, -1))) { sq_settop(vm, top); @@ -201,6 +202,18 @@ ssq::Table makeOwnedPointSetProxy(HSQUIRRELVM vm, eve::Result&& reference) { + auto* module = Procgen::create(); + return makeOwnedNativeProxy( + vm, std::move(reference), [module](ProcgenGridHandleRef ref) { return module->resolve(ref); }, + [](ProcgenGridHandleRef ref) { + auto* owner = ModuleManager::getInstance("Procgen"); + return owner ? owner->release(ref) + : procgenBindingFailure(eve::DiagnosticCode::StaleHandle, + "Procgen module is no longer loaded", "grid"); + }); +} + ssq::Table makeOwnedSpatialProxy(HSQUIRRELVM vm, eve::Result&& reference) { auto* module = Procgen::create(); return makeOwnedNativeProxy( @@ -679,7 +692,7 @@ eve::Result Procgen::transformPoints3DHandle(ProcgenPo float translateY, float translateZ, float pitchDegrees, float yawDegrees, float rollDegrees, float scaleX, float scaleY, - float scaleZ) { + float scaleZ) { auto view = resolvePointSet(input); if (!view.isBound()) return procgenBindingFailure(eve::DiagnosticCode::StaleHandle, @@ -691,7 +704,7 @@ eve::Result Procgen::transformPoints3DHandle(ProcgenPo eve::Result Procgen::copyPointsHandle(ProcgenPointSetHandleRef source, ProcgenPointSetHandleRef targets, - bool inheritTargetAttributes) { + bool inheritTargetAttributes) { auto sourceView = resolvePointSet(source); auto targetView = resolvePointSet(targets); if (!sourceView.isBound() || !targetView.isBound()) @@ -720,7 +733,7 @@ eve::Result Procgen::mathFloatAttributeHandle(ProcgenP const std::string& attribute, const std::string& outputAttribute, const std::string& operation, float operand, - float defaultValue) { + float defaultValue) { auto view = resolvePointSet(input); if (!view.isBound()) return procgenBindingFailure( @@ -772,7 +785,7 @@ eve::Result Procgen::densityCullHandle(ProcgenPointSet eve::Result Procgen::projectToWorldHandle(ProcgenPointSetHandleRef input, float maxY, float minY, std::uint64_t maskBits, - bool keepUnmatched) { + bool keepUnmatched) { auto points = resolvePointSet(input); if (!points.isBound()) return procgenBindingFailure(eve::DiagnosticCode::StaleHandle, @@ -789,7 +802,7 @@ eve::Result Procgen::projectToWorldHandle(ProcgenPoint output.reserve(points->points().size()); for (size_t sourceIndex = 0; sourceIndex < points->points().size(); ++sourceIndex) { const auto& source = points->points()[sourceIndex]; - auto queryResult = query->projectDown(source.x, source.z, maxY, minY, maskBits); + auto queryResult = query->projectDown(source.x, source.z, maxY, minY, maskBits); if (!queryResult.ok()) return procgenBindingFailure( eve::DiagnosticCode::Failed, "world-query provider failed to execute projection", "worldQuery"); @@ -800,12 +813,12 @@ eve::Result Procgen::projectToWorldHandle(ProcgenPoint continue; } ProcgenPoint projected = source; - projected.x = hit.x; - projected.y = hit.y; - projected.z = hit.z; - projected.normalX = hit.normalX; - projected.normalY = hit.normalY; - projected.normalZ = hit.normalZ; + projected.x = hit.x; + projected.y = hit.y; + projected.z = hit.z; + projected.normalX = hit.normalX; + projected.normalY = hit.normalY; + projected.normalZ = hit.normalZ; const int outputIndex = std::move(output.appendPointFrom(*points, sourceIndex)).expect("projectToWorld attribute schema"); output.mutablePoint(size_t(outputIndex)) = std::move(projected); @@ -1342,6 +1355,14 @@ eve::script::Borrowed Procgen::resolveSplinePath(ProcgenSplinePathHa return splinePaths_.resolve(reference); } +eve::script::Borrowed Procgen::resolveGridGraph(ProcgenGridGraphHandleRef reference) noexcept { + return gridGraphs_.resolve(reference); +} + +eve::script::Borrowed Procgen::resolveMeshGraph(ProcgenMeshGraphHandleRef reference) noexcept { + return meshGraphs_.resolve(reference); +} + eve::script::Borrowed Procgen::resolveBiomeRules(ProcgenBiomeRulesHandleRef reference) noexcept { return biomeRules_.resolve(reference); } @@ -1368,6 +1389,8 @@ eve::Result Procgen::release(ProcgenDynamicMeshUvPaintSessionHandleRef ref return dynamicMeshUvPaintSessions_.erase(reference); } eve::Result Procgen::release(ProcgenSplinePathHandleRef reference) { return splinePaths_.erase(reference); } +eve::Result Procgen::release(ProcgenGridGraphHandleRef reference) { return gridGraphs_.erase(reference); } +eve::Result Procgen::release(ProcgenMeshGraphHandleRef reference) { return meshGraphs_.erase(reference); } eve::Result Procgen::release(ProcgenBiomeRulesHandleRef reference) { return biomeRules_.erase(reference); } eve::Result Procgen::release(ProcgenShapeGrammarHandleRef reference) { return shapeGrammars_.erase(reference); } eve::Result Procgen::release(ProcgenLSystemHandleRef reference) { return lsystems_.erase(reference); } @@ -1386,6 +1409,8 @@ bool Procgen::isStale(ProcgenDynamicMeshUvPaintSessionHandleRef reference) const return dynamicMeshUvPaintSessions_.isStale(reference); } bool Procgen::isStale(ProcgenSplinePathHandleRef reference) const noexcept { return splinePaths_.isStale(reference); } +bool Procgen::isStale(ProcgenGridGraphHandleRef reference) const noexcept { return gridGraphs_.isStale(reference); } +bool Procgen::isStale(ProcgenMeshGraphHandleRef reference) const noexcept { return meshGraphs_.isStale(reference); } bool Procgen::isStale(ProcgenBiomeRulesHandleRef reference) const noexcept { return biomeRules_.isStale(reference); } bool Procgen::isStale(ProcgenShapeGrammarHandleRef reference) const noexcept { return shapeGrammars_.isStale(reference); @@ -1624,7 +1649,7 @@ std::string Procgen::getSystemDebugDiffReport(const std::string& name) const { for (const auto& stageName : current->second.debugStageOrder) { const int currentCount = current->second.debugStages.at(stageName).getCount(); const auto oldStage = previous->second.debugStages.find(stageName); - const int oldCount = oldStage == previous->second.debugStages.end() ? 0 : oldStage->second.getCount(); + const int oldCount = oldStage == previous->second.debugStages.end() ? 0 : oldStage->second.getCount(); report << "\n debug " << stageName << " points=" << currentCount << " delta="; if (currentCount >= oldCount) report << "+"; report << currentCount - oldCount; @@ -1637,7 +1662,7 @@ std::string Procgen::getSystemDebugDiffReport(const std::string& name) const { return report.str(); } -bool Procgen::runGenerate(const std::string &algorithmId, const Params ¶ms, Grid2D &out) { +bool Procgen::runGenerate(const std::string& algorithmId, const Params& params, Grid2D& out) { lastError_.clear(); GeneratorRegistry::instance().registerBuiltins(); if (!GeneratorRegistry::instance().generate(algorithmId, params, out, lastError_)) { @@ -1710,18 +1735,18 @@ eve::Result Procgen::applyToLayer(ProcgenGridHandleRef grid, const std::st return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); } -void Procgen::setPaletteGid(const std::string &palette, const std::string &semantic, int gid) { +void Procgen::setPaletteGid(const std::string& palette, const std::string& semantic, int gid) { palettes_.setGid(palette, semantic, gid); } -int Procgen::getPaletteGid(const std::string &palette, const std::string &semantic) const { +int Procgen::getPaletteGid(const std::string& palette, const std::string& semantic) const { return palettes_.getGid(palette, semantic); } namespace { -const ParamDescriptor *algorithmParam(const std::string &algorithmId, int index) { - const GeneratorDescriptor *descriptor = GeneratorRegistry::instance().descriptor(algorithmId); +const ParamDescriptor* algorithmParam(const std::string& algorithmId, int index) { + const GeneratorDescriptor* descriptor = GeneratorRegistry::instance().descriptor(algorithmId); if (!descriptor || index < 0 || index >= int(descriptor->params.size())) return nullptr; return &descriptor->params[size_t(index)]; } @@ -1739,55 +1764,55 @@ std::string Procgen::getAlgorithmId(int index) const { return algorithmIdsCache_[size_t(index)]; } -bool Procgen::hasAlgorithm(const std::string &algorithmId) const { +bool Procgen::hasAlgorithm(const std::string& algorithmId) const { return GeneratorRegistry::instance().has(algorithmId); } eve::Result Procgen::getAlgorithmSchema(const std::string& algorithmId) const { - const RecipeDescriptor *schema = GeneratorRegistry::instance().descriptor(algorithmId); + const RecipeDescriptor* schema = GeneratorRegistry::instance().descriptor(algorithmId); if (!schema) return procgenBindingFailure(eve::DiagnosticCode::NotFound, "algorithm schema was not found", "algorithm"); return eve::Result::success(*schema); } -std::string Procgen::getAlgorithmDisplayName(const std::string &algorithmId) const { - const GeneratorDescriptor *descriptor = GeneratorRegistry::instance().descriptor(algorithmId); +std::string Procgen::getAlgorithmDisplayName(const std::string& algorithmId) const { + const GeneratorDescriptor* descriptor = GeneratorRegistry::instance().descriptor(algorithmId); return descriptor ? descriptor->displayName : std::string{}; } -std::string Procgen::getAlgorithmCategory(const std::string &algorithmId) const { - const GeneratorDescriptor *descriptor = GeneratorRegistry::instance().descriptor(algorithmId); +std::string Procgen::getAlgorithmCategory(const std::string& algorithmId) const { + const GeneratorDescriptor* descriptor = GeneratorRegistry::instance().descriptor(algorithmId); return descriptor ? descriptor->category : std::string{}; } -int Procgen::getAlgorithmParamCount(const std::string &algorithmId) const { - const GeneratorDescriptor *descriptor = GeneratorRegistry::instance().descriptor(algorithmId); +int Procgen::getAlgorithmParamCount(const std::string& algorithmId) const { + const GeneratorDescriptor* descriptor = GeneratorRegistry::instance().descriptor(algorithmId); return descriptor ? int(descriptor->params.size()) : 0; } -std::string Procgen::getAlgorithmParamKey(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +std::string Procgen::getAlgorithmParamKey(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param ? param->key : std::string{}; } -std::string Procgen::getAlgorithmParamLabel(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +std::string Procgen::getAlgorithmParamLabel(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param ? param->displayName : std::string{}; } -std::string Procgen::getAlgorithmParamDescription(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +std::string Procgen::getAlgorithmParamDescription(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param ? param->description : std::string{}; } -std::string Procgen::getAlgorithmParamCategory(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +std::string Procgen::getAlgorithmParamCategory(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param ? param->category : std::string{}; } -std::string Procgen::getAlgorithmParamKind(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +std::string Procgen::getAlgorithmParamKind(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); if (!param) return {}; switch (param->kind) { case ParamKind::Integer: return "int"; @@ -1799,49 +1824,48 @@ std::string Procgen::getAlgorithmParamKind(const std::string &algorithmId, int i return {}; } -std::string Procgen::getAlgorithmParamDefault(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +std::string Procgen::getAlgorithmParamDefault(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param ? param->defaultValue : std::string{}; } -bool Procgen::algorithmParamHasMinimum(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +bool Procgen::algorithmParamHasMinimum(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param && param->hasMinimum; } -bool Procgen::algorithmParamHasMaximum(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +bool Procgen::algorithmParamHasMaximum(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param && param->hasMaximum; } -float Procgen::getAlgorithmParamMinimum(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +float Procgen::getAlgorithmParamMinimum(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param ? float(param->minimum) : 0.f; } -float Procgen::getAlgorithmParamMaximum(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +float Procgen::getAlgorithmParamMaximum(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param ? float(param->maximum) : 0.f; } -float Procgen::getAlgorithmParamStep(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +float Procgen::getAlgorithmParamStep(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param ? float(param->step) : 0.f; } -bool Procgen::isAlgorithmParamAdvanced(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +bool Procgen::isAlgorithmParamAdvanced(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param && param->advanced; } -int Procgen::getAlgorithmParamChoiceCount(const std::string &algorithmId, int index) const { - const ParamDescriptor *param = algorithmParam(algorithmId, index); +int Procgen::getAlgorithmParamChoiceCount(const std::string& algorithmId, int index) const { + const ParamDescriptor* param = algorithmParam(algorithmId, index); return param ? int(param->choices.size()) : 0; } -std::string Procgen::getAlgorithmParamChoice(const std::string &algorithmId, int paramIndex, - int choiceIndex) const { - const ParamDescriptor *param = algorithmParam(algorithmId, paramIndex); +std::string Procgen::getAlgorithmParamChoice(const std::string& algorithmId, int paramIndex, int choiceIndex) const { + const ParamDescriptor* param = algorithmParam(algorithmId, paramIndex); if (!param || choiceIndex < 0 || choiceIndex >= int(param->choices.size())) return {}; return param->choices[size_t(choiceIndex)]; } @@ -1928,12 +1952,12 @@ eve::Result Procgen::generateNormalImageHandle(cons return procgenBindingFailure(eve::DiagnosticCode::StaleHandle, "generated albedo image handle is stale", "image"); } - const int w = albedo->getWidth(); - const int h = albedo->getHeight(); + const int w = albedo->getWidth(); + const int h = albedo->getHeight(); auto* px = static_cast(albedo->getData()); std::vector height(size_t(w * h)); for (int i = 0; i < w * h; ++i) { - const size_t o = size_t(i) * 4u; + const size_t o = size_t(i) * 4u; height[size_t(i)] = (float(px[o]) * 0.299f + float(px[o + 1]) * 0.587f + float(px[o + 2]) * 0.114f) / 255.f; } const bool seamless = input->getInt("seamless", 1) != 0; @@ -2001,14 +2025,14 @@ std::string Procgen::getTextureRecipeId(int index) const { return textureRecipeIdsCache_[size_t(index)]; } -bool Procgen::hasTextureRecipe(const std::string &recipeId) const { +bool Procgen::hasTextureRecipe(const std::string& recipeId) const { TextureRecipeRegistry::instance().registerBuiltins(); return TextureRecipeRegistry::instance().has(recipeId); } eve::Result Procgen::getTextureRecipeSchema(const std::string& recipeId) const { TextureRecipeRegistry::instance().registerBuiltins(); - const RecipeDescriptor *schema = TextureRecipeRegistry::instance().descriptor(recipeId); + const RecipeDescriptor* schema = TextureRecipeRegistry::instance().descriptor(recipeId); if (!schema) return procgenBindingFailure(eve::DiagnosticCode::NotFound, "texture recipe schema was not found", "recipe"); @@ -2140,14 +2164,14 @@ std::string Procgen::getPbrRecipeId(int index) const { return pbrRecipeIdsCache_[size_t(index)]; } -bool Procgen::hasPbrRecipe(const std::string &recipeId) const { +bool Procgen::hasPbrRecipe(const std::string& recipeId) const { PbrRecipeRegistry::instance().registerPbrBuiltins(); return PbrRecipeRegistry::instance().has(recipeId); } eve::Result Procgen::getPbrRecipeSchema(const std::string& recipeId) const { PbrRecipeRegistry::instance().registerPbrBuiltins(); - const RecipeDescriptor *schema = PbrRecipeRegistry::instance().descriptor(recipeId); + const RecipeDescriptor* schema = PbrRecipeRegistry::instance().descriptor(recipeId); if (!schema) return procgenBindingFailure(eve::DiagnosticCode::NotFound, "PBR recipe schema was not found", "recipe"); @@ -2346,14 +2370,14 @@ std::string Procgen::getMeshRecipeId(int index) const { return meshRecipeIdsCache_[size_t(index)]; } -bool Procgen::hasMeshRecipe(const std::string &recipeId) const { +bool Procgen::hasMeshRecipe(const std::string& recipeId) const { MeshRecipeRegistry::instance().registerBuiltins(); return MeshRecipeRegistry::instance().has(recipeId); } eve::Result Procgen::getMeshRecipeSchema(const std::string& recipeId) const { MeshRecipeRegistry::instance().registerBuiltins(); - const RecipeDescriptor *schema = MeshRecipeRegistry::instance().descriptor(recipeId); + const RecipeDescriptor* schema = MeshRecipeRegistry::instance().descriptor(recipeId); if (!schema) return procgenBindingFailure(eve::DiagnosticCode::NotFound, "mesh recipe schema was not found", "recipe"); @@ -2397,8 +2421,8 @@ eve::Result Procgen::newHeightmapHandle(int width, in eve::Result Procgen::adoptHeightmap(Heightmap heightmap) { if (heightmap.getWidth() <= 0 || heightmap.getHeight() <= 0) - return procgenBindingFailure( - eve::DiagnosticCode::InvalidArgument, "decoded heightmap has no samples", "heightmap"); + return procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + "decoded heightmap has no samples", "heightmap"); return ownProcgenObject(ownership_->heightmaps, std::make_unique(std::move(heightmap))); } @@ -2528,78 +2552,86 @@ TerrainLayers* Procgen::analyzeTerrainScaled(Heightmap* heightmap, float riverTh lastError_ = "analyzeTerrainScaled: coordinateScale must be positive"; return nullptr; } - HydrologyMap hydrology = TerrainPipeline::buildHydrology( - *heightmap, riverThreshold, seaLevel, coordinateScale); - ClimateMap climate = TerrainPipeline::buildClimate( - *heightmap, hydrology, seaLevel, latitude, coordinateScale); + HydrologyMap hydrology = TerrainPipeline::buildHydrology(*heightmap, riverThreshold, seaLevel, coordinateScale); + ClimateMap climate = TerrainPipeline::buildClimate(*heightmap, hydrology, seaLevel, latitude, coordinateScale); return new TerrainLayers(std::move(hydrology), std::move(climate)); } -data::ByteData *Procgen::bakeTerrainAsset(Heightmap *heightmap, TerrainLayers *layers, - int chunkSize) { +data::ByteData* Procgen::bakeTerrainAsset(Heightmap* heightmap, TerrainLayers* layers, int chunkSize) { lastError_.clear(); if (!heightmap || !layers) { lastError_ = "bakeTerrainAsset: heightmap and layers are required"; return nullptr; } std::vector bytes; - if (!TerrainAsset::bake(*heightmap, layers->hydrology(), layers->climate(), chunkSize, bytes, - &lastError_)) return nullptr; + if (!TerrainAsset::bake(*heightmap, layers->hydrology(), layers->climate(), chunkSize, bytes, &lastError_)) + return nullptr; return new data::ByteData(bytes.data(), bytes.size()); } -TerrainMeshChunk *Procgen::buildTerrainChunk(Heightmap *heightmap, TerrainLayers *layers, - int originX, int originY, int cellsX, int cellsY, - int lod, float cellSize, float heightScale, +TerrainMeshChunk* Procgen::buildTerrainChunk(Heightmap* heightmap, TerrainLayers* layers, int originX, int originY, + int cellsX, int cellsY, int lod, float cellSize, float heightScale, float skirtDepth) { lastError_.clear(); - if (!heightmap) { lastError_ = "buildTerrainChunk: heightmap is required"; return nullptr; } + if (!heightmap) { + lastError_ = "buildTerrainChunk: heightmap is required"; + return nullptr; + } TerrainMeshSettings settings; - settings.originX = originX; settings.originY = originY; - settings.cellsX = cellsX; settings.cellsY = cellsY; settings.lod = lod; - settings.cellSize = cellSize; settings.heightScale = heightScale; settings.skirtDepth = skirtDepth; - auto *chunk = new TerrainMeshChunk(); + settings.originX = originX; + settings.originY = originY; + settings.cellsX = cellsX; + settings.cellsY = cellsY; + settings.lod = lod; + settings.cellSize = cellSize; + settings.heightScale = heightScale; + settings.skirtDepth = skirtDepth; + auto* chunk = new TerrainMeshChunk(); if (!TerrainMeshBuilder::build(*heightmap, layers, settings, *chunk, &lastError_)) { - delete chunk; return nullptr; + delete chunk; + return nullptr; } return chunk; } -int Procgen::selectTerrainLod(Heightmap *heightmap, int originX, int originY, - int cellsX, int cellsY, int maxLod, float cellSize, - float heightScale, float cameraDistance, float viewportHeight, +int Procgen::selectTerrainLod(Heightmap* heightmap, int originX, int originY, int cellsX, int cellsY, int maxLod, + float cellSize, float heightScale, float cameraDistance, float viewportHeight, float verticalFovDegrees, float targetPixelError) { lastError_.clear(); - if (!heightmap) { lastError_ = "selectTerrainLod: heightmap is required"; return -1; } + if (!heightmap) { + lastError_ = "selectTerrainLod: heightmap is required"; + return -1; + } TerrainMeshSettings settings; - settings.originX = originX; settings.originY = originY; - settings.cellsX = cellsX; settings.cellsY = cellsY; - settings.cellSize = cellSize; settings.heightScale = heightScale; - const int lod = TerrainLodSelector::select(*heightmap, settings, maxLod, cameraDistance, - viewportHeight, verticalFovDegrees, - targetPixelError); + settings.originX = originX; + settings.originY = originY; + settings.cellsX = cellsX; + settings.cellsY = cellsY; + settings.cellSize = cellSize; + settings.heightScale = heightScale; + const int lod = TerrainLodSelector::select(*heightmap, settings, maxLod, cameraDistance, viewportHeight, + verticalFovDegrees, targetPixelError); if (lod < 0) lastError_ = "selectTerrainLod: invalid bounds or projection settings"; return lod; } -graphics::Mesh *Procgen::generateTerrainChunkMesh(TerrainMeshChunk *chunk, - graphics::Graphics *gfx) { +graphics::Mesh* Procgen::generateTerrainChunkMesh(TerrainMeshChunk* chunk, graphics::Graphics* gfx) { lastError_.clear(); if (!chunk || !gfx || chunk->mesh().empty()) { lastError_ = "generateTerrainChunkMesh: chunk and graphics are required"; return nullptr; } - const MeshBuild &mesh = chunk->mesh(); + const MeshBuild& mesh = chunk->mesh(); return gfx->newMeshFromArrays(mesh.positions().data(), mesh.normals().data(), mesh.uvs().data(), mesh.getVertexCount(), mesh.indices().data(), mesh.getIndexCount()); } graphics::Mesh* Procgen::generateTerrainRiverMesh(Heightmap* heightmap, TerrainLayers* layers, graphics::Graphics* gfx, int originX, int originY, int cellsX, int cellsY, float cellSize, - float heightScale, float minWidth, float maxWidth, - float heightOffset) { + float heightScale, float minWidth, float maxWidth, + float heightOffset) { return generateTerrainRiverMeshAdvanced(heightmap, layers, gfx, originX, originY, cellsX, cellsY, cellSize, - heightScale, minWidth, maxWidth, heightOffset, 0.f, 0.30f); + heightScale, minWidth, maxWidth, heightOffset, 0.f, 0.30f); } graphics::Mesh* Procgen::generateTerrainRiverMeshAdvanced(Heightmap* heightmap, TerrainLayers* layers, @@ -2621,7 +2653,7 @@ graphics::Mesh* Procgen::generateTerrainRiverMeshAdvanced(Heightmap* heightmap, settings.heightScale = heightScale; settings.minWidth = minWidth; settings.maxWidth = maxWidth; - settings.heightOffset = heightOffset; + settings.heightOffset = heightOffset; settings.minSurfaceSlope = minSurfaceSlope; settings.maxSurfaceSlope = maxSurfaceSlope; MeshBuild river; @@ -2655,18 +2687,18 @@ graphics::Mesh* Procgen::generateTerrainLakeMesh(Heightmap* heightmap, TerrainLa lake.getVertexCount(), lake.indices().data(), lake.getIndexCount()); } -image::ImageData *Procgen::generateTerrainSplatMap(TerrainMeshChunk *chunk) { +image::ImageData* Procgen::generateTerrainSplatMap(TerrainMeshChunk* chunk) { lastError_.clear(); if (!chunk || chunk->getSplatWidth() <= 0 || chunk->getSplatHeight() <= 0) { lastError_ = "generateTerrainSplatMap: a built terrain chunk is required"; return nullptr; } - auto *image = new image::ImageData(chunk->getSplatWidth(), chunk->getSplatHeight(), "RGBA8"); - auto *pixels = static_cast(image->getData()); + auto* image = new image::ImageData(chunk->getSplatWidth(), chunk->getSplatHeight(), "RGBA8"); + auto* pixels = static_cast(image->getData()); for (int vertex = 0; vertex < chunk->getBaseVertexCount(); ++vertex) { - std::array quantized{}; + std::array quantized{}; std::array remainder{}; - int total = 0; + int total = 0; for (int channel = 0; channel < 4; ++channel) { const float scaled = std::clamp(chunk->getMaterialWeight(vertex, channel), 0.f, 1.f) * 255.f; quantized[channel] = int(std::floor(scaled)); @@ -2685,24 +2717,24 @@ image::ImageData *Procgen::generateTerrainSplatMap(TerrainMeshChunk *chunk) { return image; } -image::ImageData *Procgen::generateTerrainAlbedoMap(TerrainMeshChunk *chunk) { +image::ImageData* Procgen::generateTerrainAlbedoMap(TerrainMeshChunk* chunk) { lastError_.clear(); if (!chunk || chunk->getSplatWidth() <= 0 || chunk->getSplatHeight() <= 0) { lastError_ = "generateTerrainAlbedoMap: a built terrain chunk is required"; return nullptr; } static constexpr std::array, 4> palette{{ - {{0.55f, 0.40f, 0.22f}}, // sand, dry soil, and river sediment - {{0.15f, 0.38f, 0.12f}}, // vegetation - {{0.36f, 0.35f, 0.33f}}, // exposed rock - {{0.88f, 0.91f, 0.94f}}, // snow + {{0.55f, 0.40f, 0.22f}}, // sand, dry soil, and river sediment + {{0.15f, 0.38f, 0.12f}}, // vegetation + {{0.36f, 0.35f, 0.33f}}, // exposed rock + {{0.88f, 0.91f, 0.94f}}, // snow }}; - auto *image = new image::ImageData(chunk->getSplatWidth(), chunk->getSplatHeight(), "RGBA8"); - auto *pixels = static_cast(image->getData()); + auto* image = new image::ImageData(chunk->getSplatWidth(), chunk->getSplatHeight(), "RGBA8"); + auto* pixels = static_cast(image->getData()); for (int vertex = 0; vertex < chunk->getBaseVertexCount(); ++vertex) { - const int biome = chunk->getBiome(vertex); + const int biome = chunk->getBiome(vertex); std::array semanticColor{}; - bool useSemanticColor = true; + bool useSemanticColor = true; if (biome == int(Biome::Ocean)) semanticColor = {0.035f, 0.16f, 0.25f}; else if (biome == int(Biome::River)) @@ -2730,33 +2762,32 @@ image::ImageData *Procgen::generateTerrainAlbedoMap(TerrainMeshChunk *chunk) { } namespace { -float diagnosticScale(const std::vector &values, float exposure) { +float diagnosticScale(const std::vector& values, float exposure) { if (std::isfinite(exposure) && exposure > 0.f) return exposure; std::vector positive; positive.reserve(values.size()); - for (float value : values) if (std::isfinite(value) && value > 0.f) positive.push_back(value); + for (float value : values) + if (std::isfinite(value) && value > 0.f) positive.push_back(value); if (positive.empty()) return 1.f; - const size_t percentile = std::min(positive.size() - 1, - size_t(std::floor(float(positive.size() - 1) * 0.99f))); + const size_t percentile = std::min(positive.size() - 1, size_t(std::floor(float(positive.size() - 1) * 0.99f))); std::nth_element(positive.begin(), positive.begin() + ptrdiff_t(percentile), positive.end()); return 1.f / std::max(1e-8f, positive[percentile]); } enum class ErosionImageMode { Combined, Wear, Deposit }; -image::ImageData *erosionDiagnosticImage(TerrainErosionMap *map, float exposure, - ErosionImageMode mode) { - if (!map || map->width <= 0 || map->height <= 0 || - map->wear.size() != size_t(map->width) * size_t(map->height) || - map->deposition.size() != map->wear.size()) return nullptr; - const float wearScale = diagnosticScale(map->wear, exposure); +image::ImageData* erosionDiagnosticImage(TerrainErosionMap* map, float exposure, ErosionImageMode mode) { + if (!map || map->width <= 0 || map->height <= 0 || map->wear.size() != size_t(map->width) * size_t(map->height) || + map->deposition.size() != map->wear.size()) + return nullptr; + const float wearScale = diagnosticScale(map->wear, exposure); const float depositScale = diagnosticScale(map->deposition, exposure); - auto *result = new image::ImageData(map->width, map->height, "RGBA8"); - auto *pixels = static_cast(result->getData()); + auto* result = new image::ImageData(map->width, map->height, "RGBA8"); + auto* pixels = static_cast(result->getData()); for (size_t i = 0; i < map->wear.size(); ++i) { - const float wear = std::sqrt(std::clamp(map->wear[i] * wearScale, 0.f, 1.f)); + const float wear = std::sqrt(std::clamp(map->wear[i] * wearScale, 0.f, 1.f)); const float deposit = std::sqrt(std::clamp(map->deposition[i] * depositScale, 0.f, 1.f)); - float r = 0.025f, g = 0.035f, b = 0.050f; + float r = 0.025f, g = 0.035f, b = 0.050f; if (mode == ErosionImageMode::Wear) { r += 0.95f * wear; g += 0.30f * wear; @@ -2770,7 +2801,7 @@ image::ImageData *erosionDiagnosticImage(TerrainErosionMap *map, float exposure, g += 0.30f * wear + 0.78f * deposit; b += 0.035f * wear + 0.95f * deposit; } - pixels[i * 4u] = uint8_t(std::lround(std::clamp(r, 0.f, 1.f) * 255.f)); + pixels[i * 4u] = uint8_t(std::lround(std::clamp(r, 0.f, 1.f) * 255.f)); pixels[i * 4u + 1u] = uint8_t(std::lround(std::clamp(g, 0.f, 1.f) * 255.f)); pixels[i * 4u + 2u] = uint8_t(std::lround(std::clamp(b, 0.f, 1.f) * 255.f)); pixels[i * 4u + 3u] = 255; @@ -2779,35 +2810,34 @@ image::ImageData *erosionDiagnosticImage(TerrainErosionMap *map, float exposure, } } // namespace -image::ImageData *Procgen::generateTerrainErosionMap(TerrainErosionMap *erosion, float exposure) { +image::ImageData* Procgen::generateTerrainErosionMap(TerrainErosionMap* erosion, float exposure) { lastError_.clear(); - image::ImageData *result = erosionDiagnosticImage(erosion, exposure, ErosionImageMode::Combined); + image::ImageData* result = erosionDiagnosticImage(erosion, exposure, ErosionImageMode::Combined); if (!result) lastError_ = "generateTerrainErosionMap: valid erosion diagnostics are required"; return result; } -image::ImageData *Procgen::generateTerrainWearMap(TerrainErosionMap *erosion, float exposure) { +image::ImageData* Procgen::generateTerrainWearMap(TerrainErosionMap* erosion, float exposure) { lastError_.clear(); - image::ImageData *result = erosionDiagnosticImage(erosion, exposure, ErosionImageMode::Wear); + image::ImageData* result = erosionDiagnosticImage(erosion, exposure, ErosionImageMode::Wear); if (!result) lastError_ = "generateTerrainWearMap: valid erosion diagnostics are required"; return result; } -image::ImageData *Procgen::generateTerrainDepositionMap( - TerrainErosionMap *erosion, float exposure) { +image::ImageData* Procgen::generateTerrainDepositionMap(TerrainErosionMap* erosion, float exposure) { lastError_.clear(); - image::ImageData *result = erosionDiagnosticImage(erosion, exposure, ErosionImageMode::Deposit); + image::ImageData* result = erosionDiagnosticImage(erosion, exposure, ErosionImageMode::Deposit); if (!result) lastError_ = "generateTerrainDepositionMap: valid erosion diagnostics are required"; return result; } -graphics::Shader *Procgen::createTerrainMaterialShader(graphics::Graphics *gfx) { +graphics::Shader* Procgen::createTerrainMaterialShader(graphics::Graphics* gfx) { lastError_.clear(); if (!gfx) { lastError_ = "createTerrainMaterialShader: graphics is required"; return nullptr; } - static const char *fragment = R"GLSL(#version 450 + static const char* fragment = R"GLSL(#version 450 layout(location=0) in vec3 vNormal; layout(location=1) in vec2 vUV; layout(location=2) in vec4 vTint; @@ -2905,19 +2935,19 @@ void main() { )GLSL"; try { return gfx->newMeshShader(fragment); - } catch (const std::exception &e) { + } catch (const std::exception& e) { lastError_ = std::string("createTerrainMaterialShader: ") + e.what(); return nullptr; } } -graphics::Shader *Procgen::createTerrainWaterShader(graphics::Graphics *gfx) { +graphics::Shader* Procgen::createTerrainWaterShader(graphics::Graphics* gfx) { lastError_.clear(); if (!gfx) { lastError_ = "createTerrainWaterShader: graphics is required"; return nullptr; } - static const char *fragment = R"GLSL(#version 450 + static const char* fragment = R"GLSL(#version 450 layout(location=0) in vec3 vNormal; layout(location=1) in vec2 vUV; layout(location=2) in vec4 vTint; @@ -2960,19 +2990,20 @@ void main() { )GLSL"; try { return gfx->newMeshShader(fragment); - } catch (const std::exception &e) { + } catch (const std::exception& e) { lastError_ = std::string("createTerrainWaterShader: ") + e.what(); return nullptr; } } -void Procgen::expose(ssq::Table &table) { +void Procgen::expose(ssq::Table& table) { const HSQUIRRELVM vm = table.getHandle(); - auto cls = table.addClass(name, Procgen::create, false); + auto cls = table.addClass(name, Procgen::create, false); expose(cls); exposeBiomeRules(table); exposePointGraph(table); exposeMeshModifierGraph(table); + exposeGridMeshGraphs(table); exposeShapeGrammar(table); auto recipe = table.addClass( @@ -3026,15 +3057,55 @@ void Procgen::expose(ssq::Table &table) { auto grid = table.addClass("ProcgenGrid2D", std::function([]() -> Grid2D* { return nullptr; }), true); - grid.addFunc("resize", &Grid2D::resize); + const HSQUIRRELVM gridVm = grid.getHandle(); + grid.addFunc("ownership", [](Grid2D* value) { + return nativeProxyReference(value).has_value() ? std::string("owned") + : std::string("value"); + }); + grid.addFunc("ownerEpoch", [](Grid2D* value) { + const auto reference = nativeProxyReference(value); + return reference ? static_cast(reference->ownerEpoch) : std::int64_t{0}; + }); + grid.addFunc("handle", [](Grid2D* value) { + const auto reference = nativeProxyReference(value); + return reference ? static_cast(reference->packed()) : std::int64_t{0}; + }); + grid.addFunc("isStale", [](Grid2D* value) { + const auto reference = nativeProxyReference(value); + return reference ? Procgen::isStale(*reference) : false; + }); + grid.addFunc("release", [gridVm](Grid2D* value) { + const auto reference = nativeProxyReference(value); + if (!reference) + return eve::script::projectResult( + gridVm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + "grid value is not an owned procgen proxy", "grid")); + return eve::script::projectResult(gridVm, Procgen::release(*reference)); + }); + grid.addFunc("resize", [gridVm](Grid2D* value, int width, int height) { + value->resize(width, height); + return eve::script::projectResult(gridVm, eve::Result::success()); + }); grid.addFunc("getWidth", &Grid2D::getWidth); grid.addFunc("getHeight", &Grid2D::getHeight); - grid.addFunc("setCell", &Grid2D::setCell); + grid.addFunc("setCell", [gridVm](Grid2D* value, int x, int y, int semantic) { + value->setCell(x, y, semantic); + return eve::script::projectResult(gridVm, eve::Result::success()); + }); grid.addFunc("getCell", &Grid2D::getCell); - grid.addFunc("setDetail", &Grid2D::setDetail); + grid.addFunc("setDetail", [gridVm](Grid2D* value, int x, int y, int detail) { + value->setDetail(x, y, detail); + return eve::script::projectResult(gridVm, eve::Result::success()); + }); grid.addFunc("getDetail", &Grid2D::getDetail); - grid.addFunc("fill", &Grid2D::fill); - grid.addFunc("setMeta", &Grid2D::setMeta); + grid.addFunc("fill", [gridVm](Grid2D* value, int semantic) { + value->fill(semantic); + return eve::script::projectResult(gridVm, eve::Result::success()); + }); + grid.addFunc("setMeta", [gridVm](Grid2D* value, const std::string& key, const std::string& data) { + value->setMeta(key, data); + return eve::script::projectResult(gridVm, eve::Result::success()); + }); grid.addFunc("getMeta", &Grid2D::getMeta); grid.addFunc("clearObjects", &Grid2D::clearObjects); grid.addFunc("addObjectAt", &Grid2D::addObjectAt); @@ -3128,7 +3199,7 @@ void Procgen::expose(ssq::Table &table) { lsystem.addFunc("addRule", &LSystem::addRule); lsystem.addFunc("addRules", [](LSystem* ls, char symbol, ssq::Array productions, ssq::Array weights) { ls->addRules(symbol, productions.convert(), weights.convert()); - }); + }); lsystem.addFunc("clearRules", &LSystem::clearRules); lsystem.addFunc("setAngle", &LSystem::setAngle); lsystem.addFunc("setStep", &LSystem::setStep); @@ -3178,8 +3249,7 @@ void Procgen::expose(ssq::Table &table) { throw std::out_of_range("getTargetId: index is out of range"); return std::to_string(value->targetOrder[std::size_t(index)]); }); - pointDelta.addFunc("getBaseFingerprint", - [](PointDelta* value) { return std::to_string(value->baseFingerprint); }); + pointDelta.addFunc("getBaseFingerprint", [](PointDelta* value) { return std::to_string(value->baseFingerprint); }); pointDelta.addFunc("getTargetFingerprint", [](PointDelta* value) { return std::to_string(value->targetFingerprint); }); @@ -3942,17 +4012,22 @@ void Procgen::expose(ssq::Table &table) { terrainMesh.addFunc("getGeometricError", &TerrainMeshChunk::getGeometricError); terrainMesh.addFunc("getBiome", &TerrainMeshChunk::getBiome); terrainMesh.addFunc("getMaterialWeight", &TerrainMeshChunk::getMaterialWeight); - terrainMesh.addFunc("getPositionX", [](const TerrainMeshChunk *c, int i) { return c ? c->mesh().getPositionX(i) : 0.f; }); - terrainMesh.addFunc("getPositionY", [](const TerrainMeshChunk *c, int i) { return c ? c->mesh().getPositionY(i) : 0.f; }); - terrainMesh.addFunc("getPositionZ", [](const TerrainMeshChunk *c, int i) { return c ? c->mesh().getPositionZ(i) : 0.f; }); - terrainMesh.addFunc("getNormalX", [](const TerrainMeshChunk *c, int i) { return c ? c->mesh().getNormalX(i) : 0.f; }); - terrainMesh.addFunc("getNormalY", [](const TerrainMeshChunk *c, int i) { return c ? c->mesh().getNormalY(i) : 0.f; }); - terrainMesh.addFunc("getNormalZ", [](const TerrainMeshChunk *c, int i) { return c ? c->mesh().getNormalZ(i) : 0.f; }); - terrainMesh.addFunc("getIndex", [](const TerrainMeshChunk *c, int i) { return c ? c->mesh().getIndex(i) : 0; }); + terrainMesh.addFunc("getPositionX", + [](const TerrainMeshChunk* c, int i) { return c ? c->mesh().getPositionX(i) : 0.f; }); + terrainMesh.addFunc("getPositionY", + [](const TerrainMeshChunk* c, int i) { return c ? c->mesh().getPositionY(i) : 0.f; }); + terrainMesh.addFunc("getPositionZ", + [](const TerrainMeshChunk* c, int i) { return c ? c->mesh().getPositionZ(i) : 0.f; }); + terrainMesh.addFunc("getNormalX", + [](const TerrainMeshChunk* c, int i) { return c ? c->mesh().getNormalX(i) : 0.f; }); + terrainMesh.addFunc("getNormalY", + [](const TerrainMeshChunk* c, int i) { return c ? c->mesh().getNormalY(i) : 0.f; }); + terrainMesh.addFunc("getNormalZ", + [](const TerrainMeshChunk* c, int i) { return c ? c->mesh().getNormalZ(i) : 0.f; }); + terrainMesh.addFunc("getIndex", [](const TerrainMeshChunk* c, int i) { return c ? c->mesh().getIndex(i) : 0; }); auto cloud = table.addClass( - "ProcgenCloudField", - std::function([]() -> CloudField * { return nullptr; }), true); + "ProcgenCloudField", std::function([]() -> CloudField* { return nullptr; }), true); cloud.addFunc("setSeed", &CloudField::setSeed); cloud.addFunc("setWorldScale", &CloudField::setWorldScale); cloud.addFunc("setCoverage", &CloudField::setCoverage); @@ -3980,27 +4055,26 @@ void Procgen::expose(ssq::Table &table) { pbr.addFunc("getMetallic", &PbrTextureSet::getMetallic); pbr.addFunc("getHeight", &PbrTextureSet::getHeight); pbr.addFunc("getAo", &PbrTextureSet::getAo); - pbr.addFunc("getAlbedoWidth", [](const PbrTextureSet *s) { return s->albedo->getWidth(); }); - pbr.addFunc("getAlbedoHeight", [](const PbrTextureSet *s) { return s->albedo->getHeight(); }); - pbr.addFunc("getNormalWidth", [](const PbrTextureSet *s) { return s->normal->getWidth(); }); - pbr.addFunc("getRoughnessWidth", [](const PbrTextureSet *s) { return s->roughness->getWidth(); }); - pbr.addFunc("getMetallicWidth", [](const PbrTextureSet *s) { return s->metallic->getWidth(); }); - pbr.addFunc("getHeightWidth", [](const PbrTextureSet *s) { return s->height->getWidth(); }); - pbr.addFunc("getAoWidth", [](const PbrTextureSet *s) { return s->ao->getWidth(); }); - pbr.addFunc("hasAllMaps", [](const PbrTextureSet *s) { + pbr.addFunc("getAlbedoWidth", [](const PbrTextureSet* s) { return s->albedo->getWidth(); }); + pbr.addFunc("getAlbedoHeight", [](const PbrTextureSet* s) { return s->albedo->getHeight(); }); + pbr.addFunc("getNormalWidth", [](const PbrTextureSet* s) { return s->normal->getWidth(); }); + pbr.addFunc("getRoughnessWidth", [](const PbrTextureSet* s) { return s->roughness->getWidth(); }); + pbr.addFunc("getMetallicWidth", [](const PbrTextureSet* s) { return s->metallic->getWidth(); }); + pbr.addFunc("getHeightWidth", [](const PbrTextureSet* s) { return s->height->getWidth(); }); + pbr.addFunc("getAoWidth", [](const PbrTextureSet* s) { return s->ao->getWidth(); }); + pbr.addFunc("hasAllMaps", [](const PbrTextureSet* s) { return s->albedo && s->normal && s->roughness && s->metallic && s->height && s->ao; }); } -void Procgen::expose(ssq::Class &cls) { +void Procgen::expose(ssq::Class& cls) { cls.addFunc("getName", &Procgen::getName); cls.addFunc("newParams", [vm = cls.getHandle()](Procgen*) -> ssq::Table { return makeOwnedProxy( vm, Procgen::newParamsHandle(), [](ProcgenParamsHandleRef ref) { return Procgen::release(ref); }); }); cls.addFunc("newGrid", [vm = cls.getHandle()](Procgen*, int width, int height) -> ssq::Table { - return makeOwnedProxy( - vm, Procgen::newGridHandle(width, height), [](ProcgenGridHandleRef ref) { return Procgen::release(ref); }); + return makeOwnedGridProxy(vm, Procgen::newGridHandle(width, height)); }); cls.addFunc("newOutput", [vm = cls.getHandle()](Procgen*) -> ssq::Table { auto* module = Procgen::create(); @@ -4038,60 +4112,60 @@ void Procgen::expose(ssq::Class &cls) { ? owner->releasePointSet(ref) : procgenBindingFailure(eve::DiagnosticCode::StaleHandle, "Procgen module is no longer loaded", "pointSet"); - }); - }); + }); + }); cls.addFunc("filterHeight", [vm = cls.getHandle()](Procgen* value, PointSet* input, float minimum, float maximum) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( - vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( + vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, "filterHeight requires owned points", "points")); - return makeOwnedPointSetProxy(vm, value->filterHeightHandle(*reference, minimum, maximum)); - }); + return makeOwnedPointSetProxy(vm, value->filterHeightHandle(*reference, minimum, maximum)); + }); cls.addFunc("filterDensity", [vm = cls.getHandle()](Procgen* value, PointSet* input, float minimum, float maximum) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( - vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( + vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, "filterDensity requires owned points", "points")); - return makeOwnedPointSetProxy(vm, value->filterDensityHandle(*reference, minimum, maximum)); - }); + return makeOwnedPointSetProxy(vm, value->filterDensityHandle(*reference, minimum, maximum)); + }); cls.addFunc("filterBox", [vm = cls.getHandle()](Procgen* value, PointSet* input, float minX, float minY, float minZ, - float maxX, float maxY, float maxZ) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( - vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + float maxX, float maxY, float maxZ) { + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( + vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, "filterBox requires owned points", "points")); return makeOwnedPointSetProxy(vm, value->filterBoxHandle(*reference, minX, minY, minZ, maxX, maxY, maxZ, false)); - }); + }); cls.addFunc("excludeBox", [vm = cls.getHandle()](Procgen* value, PointSet* input, float minX, float minY, float minZ, float maxX, float maxY, float maxZ) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( - vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( + vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, "excludeBox requires owned points", "points")); return makeOwnedPointSetProxy(vm, value->filterBoxHandle(*reference, minX, minY, minZ, maxX, maxY, maxZ, true)); - }); + }); cls.addFunc("filterSlope", [vm = cls.getHandle()](Procgen* value, PointSet* input, float minimum, float maximum) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( - vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( + vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, "filterSlope requires owned points", "points")); - return makeOwnedPointSetProxy(vm, value->filterSlopeHandle(*reference, minimum, maximum)); - }); + return makeOwnedPointSetProxy(vm, value->filterSlopeHandle(*reference, minimum, maximum)); + }); cls.addFunc( "excludeRadius", [vm = cls.getHandle()](Procgen* value, PointSet* input, float x, float z, float radius) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( vm, procgenBindingFailure( eve::DiagnosticCode::InvalidArgument, "excludeRadius requires owned points", "points")); - return makeOwnedPointSetProxy(vm, value->excludeRadiusHandle(*reference, x, z, radius)); - }); + return makeOwnedPointSetProxy(vm, value->excludeRadiusHandle(*reference, x, z, radius)); + }); cls.addFunc("jitterPoints", [vm = cls.getHandle()](Procgen* value, PointSet* input, uint32_t seed, float amountX, float amountZ) { const auto reference = nativeProxyReference(input); @@ -4102,33 +4176,33 @@ void Procgen::expose(ssq::Class &cls) { return makeOwnedPointSetProxy(vm, value->jitterPointsHandle(*reference, seed, amountX, amountZ)); }); cls.addFunc("poissonDisk", [vm = cls.getHandle()](Procgen* value, int width, int depth, float radius, uint32_t seed, - int maxPoints) { - if (!value) - return makeOwnedPointSetProxy( - vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + int maxPoints) { + if (!value) + return makeOwnedPointSetProxy( + vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, "poissonDisk requires Procgen", "procgen")); - return makeOwnedPointSetProxy(vm, value->poissonDiskHandle(width, depth, radius, seed, maxPoints)); - }); + return makeOwnedPointSetProxy(vm, value->poissonDiskHandle(width, depth, radius, seed, maxPoints)); + }); cls.addFunc("mergePoints", [vm = cls.getHandle()](Procgen* value, PointSet* first, PointSet* second) { const auto firstRef = nativeProxyReference(first); const auto secondRef = nativeProxyReference(second); if (!value || !firstRef || !secondRef) return makeOwnedPointSetProxy( vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, - "mergePoints requires owned point sets", "points")); + "mergePoints requires owned point sets", "points")); return makeOwnedPointSetProxy(vm, value->mergePointsHandle(*firstRef, *secondRef)); }); cls.addFunc("unionPoints", [vm = cls.getHandle()](Procgen* value, PointSet* first, PointSet* second) { - const auto firstRef = nativeProxyReference(first); + const auto firstRef = nativeProxyReference(first); const auto secondRef = nativeProxyReference(second); if (!value || !firstRef || !secondRef) return makeOwnedPointSetProxy( vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, - "unionPoints requires owned point sets", "points")); + "unionPoints requires owned point sets", "points")); return makeOwnedPointSetProxy(vm, value->unionPointsHandle(*firstRef, *secondRef)); }); cls.addFunc("intersectPoints", [vm = cls.getHandle()](Procgen* value, PointSet* first, PointSet* second) { - const auto firstRef = nativeProxyReference(first); + const auto firstRef = nativeProxyReference(first); const auto secondRef = nativeProxyReference(second); if (!value || !firstRef || !secondRef) return makeOwnedPointSetProxy( @@ -4137,7 +4211,7 @@ void Procgen::expose(ssq::Class &cls) { return makeOwnedPointSetProxy(vm, value->intersectPointsHandle(*firstRef, *secondRef)); }); cls.addFunc("differencePoints", [vm = cls.getHandle()](Procgen* value, PointSet* first, PointSet* second) { - const auto firstRef = nativeProxyReference(first); + const auto firstRef = nativeProxyReference(first); const auto secondRef = nativeProxyReference(second); if (!value || !firstRef || !secondRef) return makeOwnedPointSetProxy( @@ -4147,98 +4221,98 @@ void Procgen::expose(ssq::Class &cls) { }); cls.addFunc("transformPoints", [vm = cls.getHandle()](Procgen* value, PointSet* input, float x, float y, float z, float yaw, float scaleX, float scaleY, float scaleZ) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( - vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( + vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, "transformPoints requires owned points", "points")); return makeOwnedPointSetProxy(vm, value->transformPointsHandle(*reference, x, y, z, yaw, scaleX, scaleY, scaleZ)); - }); + }); cls.addFunc("transformPoints3D", [vm = cls.getHandle()](Procgen* value, PointSet* input, float x, float y, float z, float pitch, float yaw, float roll, float scaleX, float scaleY, float scaleZ) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( - vm, procgenBindingFailure( + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( + vm, procgenBindingFailure( eve::DiagnosticCode::InvalidArgument, "transformPoints3D requires owned points", "points")); - return makeOwnedPointSetProxy( + return makeOwnedPointSetProxy( vm, value->transformPoints3DHandle(*reference, x, y, z, pitch, yaw, roll, scaleX, scaleY, scaleZ)); - }); + }); cls.addFunc("copyPoints", [vm = cls.getHandle()](Procgen* value, PointSet* source, PointSet* targets, - bool inheritTargetAttributes) { - const auto sourceRef = nativeProxyReference(source); - const auto targetRef = nativeProxyReference(targets); - if (!value || !sourceRef || !targetRef) - return makeOwnedPointSetProxy( + bool inheritTargetAttributes) { + const auto sourceRef = nativeProxyReference(source); + const auto targetRef = nativeProxyReference(targets); + if (!value || !sourceRef || !targetRef) + return makeOwnedPointSetProxy( vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, - "copyPoints requires owned point sets", "points")); + "copyPoints requires owned point sets", "points")); return makeOwnedPointSetProxy(vm, value->copyPointsHandle(*sourceRef, *targetRef, inheritTargetAttributes)); - }); + }); cls.addFunc("remapDensity", [vm = cls.getHandle()](Procgen* value, PointSet* input, float inputMin, float inputMax, float outputMin, float outputMax, bool clampOutput) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, - "remapDensity requires owned points", "points")); - return makeOwnedPointSetProxy( + "remapDensity requires owned points", "points")); + return makeOwnedPointSetProxy( vm, value->remapDensityHandle(*reference, inputMin, inputMax, outputMin, outputMax, clampOutput)); - }); + }); cls.addFunc( "mathFloatAttribute", [vm = cls.getHandle()](Procgen* value, PointSet* input, const std::string& attribute, const std::string& outputAttribute, const std::string& operation, float operand, float defaultValue) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) + const auto reference = nativeProxyReference(input); + if (!value || !reference) return makeOwnedPointSetProxy(vm, procgenBindingFailure( - eve::DiagnosticCode::InvalidArgument, - "mathFloatAttribute requires owned points", "points")); + eve::DiagnosticCode::InvalidArgument, + "mathFloatAttribute requires owned points", "points")); return makeOwnedPointSetProxy(vm, value->mathFloatAttributeHandle(*reference, attribute, outputAttribute, - operation, operand, defaultValue)); - }); + operation, operand, defaultValue)); + }); cls.addFunc("filterFloatAttribute", [vm = cls.getHandle()](Procgen* value, PointSet* input, const std::string& name, float minimum, float maximum, bool invert) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( vm, procgenBindingFailure( eve::DiagnosticCode::InvalidArgument, "filterFloatAttribute requires owned points", "points")); return makeOwnedPointSetProxy(vm, value->filterFloatAttributeHandle(*reference, name, minimum, maximum, invert)); - }); + }); cls.addFunc("filterStringAttribute", [vm = cls.getHandle()](Procgen* value, PointSet* input, const std::string& name, const std::string& expected, bool invert) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( vm, procgenBindingFailure( eve::DiagnosticCode::InvalidArgument, "filterStringAttribute requires owned points", "points")); return makeOwnedPointSetProxy(vm, value->filterStringAttributeHandle(*reference, name, expected, invert)); - }); + }); cls.addFunc( "densityCull", [vm = cls.getHandle()](Procgen* value, PointSet* input, uint32_t seed, float multiplier) { - const auto reference = nativeProxyReference(input); - if (!value || !reference) - return makeOwnedPointSetProxy( - vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + const auto reference = nativeProxyReference(input); + if (!value || !reference) + return makeOwnedPointSetProxy( + vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, "densityCull requires owned points", "points")); - return makeOwnedPointSetProxy(vm, value->densityCullHandle(*reference, seed, multiplier)); - }); + return makeOwnedPointSetProxy(vm, value->densityCullHandle(*reference, seed, multiplier)); + }); cls.addFunc("projectToWorld", [vm = cls.getHandle()](Procgen* value, PointSet* input, float maxY, float minY, - std::int64_t maskBits, bool keepUnmatched) { - const auto reference = nativeProxyReference(input); - if (!value || !reference || maskBits < 0) - return makeOwnedPointSetProxy( - vm, procgenBindingFailure( - eve::DiagnosticCode::InvalidArgument, - "projectToWorld requires owned points and a non-negative mask", "points")); - return makeOwnedPointSetProxy( + std::int64_t maskBits, bool keepUnmatched) { + const auto reference = nativeProxyReference(input); + if (!value || !reference || maskBits < 0) + return makeOwnedPointSetProxy( + vm, procgenBindingFailure( + eve::DiagnosticCode::InvalidArgument, + "projectToWorld requires owned points and a non-negative mask", "points")); + return makeOwnedPointSetProxy( vm, value->projectToWorldHandle(*reference, maxY, minY, static_cast(maskBits), keepUnmatched)); - }); + }); cls.addFunc("newTerrainSampler", [vm = cls.getHandle()](Procgen*) -> ssq::Table { auto* module = Procgen::create(); return makeOwnedNativeProxy( @@ -4264,16 +4338,16 @@ void Procgen::expose(ssq::Class &cls) { // referencing level owns that value and `hasSpacing` is false. cls.addFunc("loadTerrainFile", [vm = cls.getHandle()](Procgen*, const std::string& path, const std::string& format) -> ssq::Table { - return projectDecodedTerrainResult( - vm, loadTerrainFile(path, parseTerrainFileFormat(format))); + return projectDecodedTerrainResult(vm, loadTerrainFile(path, parseTerrainFileFormat(format))); }); cls.addFunc("loadTerrainBytes", [vm = cls.getHandle()](Procgen*, const std::string& bytes, const std::string& format) -> ssq::Table { const auto* data = reinterpret_cast(bytes.data()); return projectDecodedTerrainResult( vm, decodeTerrainFile(std::span(data, bytes.size()), - parseTerrainFileFormat(format))); - }); cls.addFunc("newCloudField", [vm = cls.getHandle()](Procgen*) -> ssq::Table { + parseTerrainFileFormat(format))); + }); + cls.addFunc("newCloudField", [vm = cls.getHandle()](Procgen*) -> ssq::Table { auto* module = Procgen::create(); return makeOwnedNativeProxy( vm, module->newCloudFieldHandle(), @@ -4308,9 +4382,7 @@ void Procgen::expose(ssq::Class &cls) { "generate params proxy must not be null", "params") .status(), false, false); - return makeOwnedProxy( - vm, module->generateHandle(algorithm, params->reference), - [](ProcgenGridHandleRef ref) { return Procgen::release(ref); }); + return makeOwnedGridProxy(vm, module->generateHandle(algorithm, params->reference)); }); cls.addFunc( "generateImage", @@ -4516,6 +4588,42 @@ void Procgen::expose(ssq::Class &cls) { "Procgen module is no longer loaded", "dynamicUvPaint"); }); }); + cls.addFunc("newGridGraph", [vm = cls.getHandle()](Procgen* value) -> ssq::Table { + if (!value) + return eve::script::projectStatusResult( + vm, + procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, "Procgen module must not be null", + "procgen") + .status(), + false, false); + return makeOwnedNativeProxy( + vm, value->newGridGraphHandle(), + [value](ProcgenGridGraphHandleRef ref) { return value->resolveGridGraph(ref); }, + [](ProcgenGridGraphHandleRef ref) { + auto* owner = ModuleManager::getInstance("Procgen"); + return owner ? owner->release(ref) + : procgenBindingFailure(eve::DiagnosticCode::StaleHandle, + "Procgen module is no longer loaded", "gridGraph"); + }); + }); + cls.addFunc("newMeshGraph", [vm = cls.getHandle()](Procgen* value) -> ssq::Table { + if (!value) + return eve::script::projectStatusResult( + vm, + procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, "Procgen module must not be null", + "procgen") + .status(), + false, false); + return makeOwnedNativeProxy( + vm, value->newMeshGraphHandle(), + [value](ProcgenMeshGraphHandleRef ref) { return value->resolveMeshGraph(ref); }, + [](ProcgenMeshGraphHandleRef ref) { + auto* owner = ModuleManager::getInstance("Procgen"); + return owner ? owner->release(ref) + : procgenBindingFailure(eve::DiagnosticCode::StaleHandle, + "Procgen module is no longer loaded", "meshGraph"); + }); + }); cls.addFunc("newBiomeRules", [vm = cls.getHandle()](Procgen* value) -> ssq::Table { if (!value) return eve::script::projectStatusResult( @@ -4554,13 +4662,13 @@ void Procgen::expose(ssq::Class &cls) { return makeOwnedSpatialProxy(vm, value->sphereVolumeHandle(x, y, z, radius)); }); cls.addFunc("polygonVolume", [vm = cls.getHandle()](Procgen* value, PointSet* points, float minY, float maxY) { - const auto reference = nativeProxyReference(points); - if (!value || !reference) - return makeOwnedSpatialProxy( - vm, procgenBindingFailure( + const auto reference = nativeProxyReference(points); + if (!value || !reference) + return makeOwnedSpatialProxy( + vm, procgenBindingFailure( eve::DiagnosticCode::InvalidArgument, "polygonVolume requires owned control points", "points")); - return makeOwnedSpatialProxy(vm, value->polygonVolumeHandle(*reference, minY, maxY)); - }); + return makeOwnedSpatialProxy(vm, value->polygonVolumeHandle(*reference, minY, maxY)); + }); cls.addFunc("pointData", [vm = cls.getHandle()](Procgen* value, PointSet* points) { const auto reference = nativeProxyReference(points); if (!value || !reference) @@ -4571,33 +4679,33 @@ void Procgen::expose(ssq::Class &cls) { }); cls.addFunc("heightfieldData", [vm = cls.getHandle()](Procgen* value, Heightmap* heightmap, float originX, float originZ, float cellSize, float heightScale) { - const auto reference = nativeProxyReference(heightmap); - if (!value || !reference) + const auto reference = nativeProxyReference(heightmap); + if (!value || !reference) return makeOwnedSpatialProxy(vm, procgenBindingFailure( - eve::DiagnosticCode::InvalidArgument, - "heightfieldData requires an owned heightmap", "heightmap")); + eve::DiagnosticCode::InvalidArgument, + "heightfieldData requires an owned heightmap", "heightmap")); return makeOwnedSpatialProxy(vm, value->heightfieldDataHandle(*reference, originX, originZ, cellSize, heightScale)); - }); + }); cls.addFunc("textureMaskData", [vm = cls.getHandle()](Procgen* value, Heightmap* values, float originX, float originZ, float cellSize, float minValue, float maxValue, float minY, float maxY) { - const auto reference = nativeProxyReference(values); - if (!value || !reference) + const auto reference = nativeProxyReference(values); + if (!value || !reference) return makeOwnedSpatialProxy(vm, procgenBindingFailure( - eve::DiagnosticCode::InvalidArgument, - "textureMaskData requires an owned scalar map", "values")); - return makeOwnedSpatialProxy( + eve::DiagnosticCode::InvalidArgument, + "textureMaskData requires an owned scalar map", "values")); + return makeOwnedSpatialProxy( vm, value->textureMaskDataHandle(*reference, originX, originZ, cellSize, minValue, maxValue, minY, maxY)); - }); + }); cls.addFunc("meshSurfaceData", [vm = cls.getHandle()](Procgen* value, MeshBuild* mesh, float tolerance) { - const auto reference = nativeProxyReference(mesh); - if (!value || !reference) - return makeOwnedSpatialProxy( - vm, procgenBindingFailure( + const auto reference = nativeProxyReference(mesh); + if (!value || !reference) + return makeOwnedSpatialProxy( + vm, procgenBindingFailure( eve::DiagnosticCode::InvalidArgument, "meshSurfaceData requires an owned mesh build", "mesh")); - return makeOwnedSpatialProxy(vm, value->meshSurfaceDataHandle(*reference, tolerance)); - }); + return makeOwnedSpatialProxy(vm, value->meshSurfaceDataHandle(*reference, tolerance)); + }); cls.addFunc("unionSpatial", [vm = cls.getHandle()](Procgen* value, SpatialData* first, SpatialData* second) { const auto firstRef = nativeProxyReference(first); const auto secondRef = nativeProxyReference(second); @@ -4608,49 +4716,49 @@ void Procgen::expose(ssq::Class &cls) { return makeOwnedSpatialProxy(vm, value->unionSpatialHandle(*firstRef, *secondRef)); }); cls.addFunc("intersectSpatial", [vm = cls.getHandle()](Procgen* value, SpatialData* first, SpatialData* second) { - const auto firstRef = nativeProxyReference(first); - const auto secondRef = nativeProxyReference(second); - if (!value || !firstRef || !secondRef) + const auto firstRef = nativeProxyReference(first); + const auto secondRef = nativeProxyReference(second); + if (!value || !firstRef || !secondRef) return makeOwnedSpatialProxy(vm, procgenBindingFailure( - eve::DiagnosticCode::InvalidArgument, - "intersectSpatial requires owned spatial data", "spatial")); - return makeOwnedSpatialProxy(vm, value->intersectSpatialHandle(*firstRef, *secondRef)); - }); + eve::DiagnosticCode::InvalidArgument, + "intersectSpatial requires owned spatial data", "spatial")); + return makeOwnedSpatialProxy(vm, value->intersectSpatialHandle(*firstRef, *secondRef)); + }); cls.addFunc("differenceSpatial", [vm = cls.getHandle()](Procgen* value, SpatialData* first, SpatialData* second) { - const auto firstRef = nativeProxyReference(first); - const auto secondRef = nativeProxyReference(second); - if (!value || !firstRef || !secondRef) + const auto firstRef = nativeProxyReference(first); + const auto secondRef = nativeProxyReference(second); + if (!value || !firstRef || !secondRef) return makeOwnedSpatialProxy(vm, procgenBindingFailure( - eve::DiagnosticCode::InvalidArgument, - "differenceSpatial requires owned spatial data", "spatial")); - return makeOwnedSpatialProxy(vm, value->differenceSpatialHandle(*firstRef, *secondRef)); - }); + eve::DiagnosticCode::InvalidArgument, + "differenceSpatial requires owned spatial data", "spatial")); + return makeOwnedSpatialProxy(vm, value->differenceSpatialHandle(*firstRef, *secondRef)); + }); cls.addFunc("sampleSpatial", [vm = cls.getHandle()](Procgen* value, SpatialData* spatial, float spacing, uint32_t seed, float jitter) { - const auto reference = nativeProxyReference(spatial); - if (!value || !reference) - return makeOwnedPointSetProxy( + const auto reference = nativeProxyReference(spatial); + if (!value || !reference) + return makeOwnedPointSetProxy( vm, procgenBindingFailure( eve::DiagnosticCode::InvalidArgument, "sampleSpatial requires owned spatial data", "spatial")); - return makeOwnedPointSetProxy(vm, value->sampleSpatialHandle(*reference, spacing, seed, jitter)); - }); + return makeOwnedPointSetProxy(vm, value->sampleSpatialHandle(*reference, spacing, seed, jitter)); + }); cls.addFunc( "filterSpatial", [vm = cls.getHandle()](Procgen* value, PointSet* points, SpatialData* spatial, bool invert) { - const auto pointsRef = nativeProxyReference(points); - const auto spatialRef = nativeProxyReference(spatial); - if (!value || !pointsRef || !spatialRef) - return makeOwnedPointSetProxy( + const auto pointsRef = nativeProxyReference(points); + const auto spatialRef = nativeProxyReference(spatial); + if (!value || !pointsRef || !spatialRef) + return makeOwnedPointSetProxy( vm, procgenBindingFailure( eve::DiagnosticCode::InvalidArgument, "filterSpatial requires owned inputs", "input")); - return makeOwnedPointSetProxy(vm, value->filterSpatialHandle(*pointsRef, *spatialRef, invert)); - }); + return makeOwnedPointSetProxy(vm, value->filterSpatialHandle(*pointsRef, *spatialRef, invert)); + }); cls.addFunc("projectToSpatial", [vm = cls.getHandle()](Procgen* value, PointSet* points, SpatialData* spatial) { const auto pointsRef = nativeProxyReference(points); const auto spatialRef = nativeProxyReference(spatial); if (!value || !pointsRef || !spatialRef) return makeOwnedPointSetProxy( vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, - "projectToSpatial requires owned inputs", "input")); + "projectToSpatial requires owned inputs", "input")); return makeOwnedPointSetProxy(vm, value->projectToSpatialHandle(*pointsRef, *spatialRef)); }); cls.addFunc("splineData", [vm = cls.getHandle()](Procgen* value, PointSet* points, float radius) -> ssq::Table { @@ -5004,22 +5112,24 @@ void Procgen::expose(ssq::Class &cls) { "generateTo requires params and owned output", "generateTo")); return eve::script::projectResult(vm, value->generateTo(algorithm, params->reference, *outputRef)); }); - cls.addFunc("autotileGrid", [vm = cls.getHandle()](Procgen* value, ScriptProcgenGrid* grid) { - if (!value || !grid) + cls.addFunc("autotileGrid", [vm = cls.getHandle()](Procgen* value, Grid2D* grid) { + const auto gridRef = nativeProxyReference(grid); + if (!value || !gridRef) return eve::script::projectResult( vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, - "autotileGrid requires a grid proxy", "grid")); - return eve::script::projectResult(vm, value->autotileGrid(grid->reference)); + "autotileGrid requires an owned grid proxy", "grid")); + return eve::script::projectResult(vm, value->autotileGrid(*gridRef)); }); cls.addFunc("randomSeed", &Procgen::randomSeed); - cls.addFunc("gridToJson", [vm = cls.getHandle()](Procgen* value, ScriptProcgenGrid* grid) { - if (!value || !grid) + cls.addFunc("gridToJson", [vm = cls.getHandle()](Procgen* value, Grid2D* grid) { + const auto gridRef = nativeProxyReference(grid); + if (!value || !gridRef) return eve::script::projectResult( vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, - "gridToJson requires a grid proxy", "grid"), + "gridToJson requires an owned grid proxy", "grid"), [](std::string&& json) { return eve::Value(std::move(json)); }); - return eve::script::projectResult(vm, value->gridToJson(grid->reference), + return eve::script::projectResult(vm, value->gridToJson(*gridRef), [](std::string&& json) { return eve::Value(std::move(json)); }); }); cls.addFunc("getTextureRecipeCount", &Procgen::getTextureRecipeCount); diff --git a/src/modules/procgen/Procgen.h b/src/modules/procgen/Procgen.h index 75a1b9e2..e16c0ff5 100644 --- a/src/modules/procgen/Procgen.h +++ b/src/modules/procgen/Procgen.h @@ -4,7 +4,9 @@ #include "common/SquirrelOwnership.h" #include "procgen/Biome.h" #include "procgen/Grid2D.h" +#include "procgen/GridGraph.h" #include "procgen/MeshBuild.h" +#include "procgen/MeshGraph.h" #include "procgen/OutputSpec.h" #include "procgen/Palette.h" #include "procgen/ParamSchema.h" @@ -18,13 +20,13 @@ #include "procgen/algorithms/LSystem.h" #include "procgen/core/ProcgenCore.h" #include "procgen/heightmap/Heightmap.h" -#include "procgen/heightmap/TerrainSampler.h" -#include "procgen/heightmap/TerrainPipeline.h" #include "procgen/heightmap/TerrainMesh.h" #include "procgen/mesh/MeshModifierGraph.h" #include "procgen/mesh/MeshDeformationSession.h" #include "procgen/mesh/DynamicMeshUvPaintSession.h" #include "procgen/spline/SplinePath.h" +#include "procgen/heightmap/TerrainPipeline.h" +#include "procgen/heightmap/TerrainSampler.h" #include "procgen/texture/CloudField.h" #include "procgen/texture/CloudShadow.h" @@ -95,6 +97,10 @@ struct ProcgenMeshDeformationSessionHandleTag {}; struct ProcgenDynamicMeshUvPaintSessionHandleTag {}; /** @brief Handle domain for module-owned 3D spline paths. */ struct ProcgenSplinePathHandleTag {}; +/** @brief Handle domain for module-owned grid graphs. */ +struct ProcgenGridGraphHandleTag {}; +/** @brief Handle domain for module-owned mesh graphs. */ +struct ProcgenMeshGraphHandleTag {}; /** @brief Handle domain for module-owned biome rules. */ struct ProcgenBiomeRulesHandleTag {}; /** @brief Handle domain for module-owned shape grammars. */ @@ -122,6 +128,8 @@ using ProcgenMeshDeformationSessionHandleRef = eve::script::RuntimeHandleRef; using ProcgenSplinePathHandleRef = eve::script::RuntimeHandleRef; +using ProcgenGridGraphHandleRef = eve::script::RuntimeHandleRef; +using ProcgenMeshGraphHandleRef = eve::script::RuntimeHandleRef; using ProcgenBiomeRulesHandleRef = eve::script::RuntimeHandleRef; using ProcgenShapeGrammarHandleRef = eve::script::RuntimeHandleRef; using ProcgenLSystemHandleRef = eve::script::RuntimeHandleRef; @@ -219,64 +227,67 @@ public: float translateZ, float yawDegrees, float scaleX, float scaleY, float scaleZ); [[nodiscard]] eve::Result transformPoints3DHandle( - ProcgenPointSetHandleRef input, float translateX, float translateY, float translateZ, - float pitchDegrees, float yawDegrees, float rollDegrees, float scaleX, float scaleY, - float scaleZ); - [[nodiscard]] eve::Result copyPointsHandle( - ProcgenPointSetHandleRef source, ProcgenPointSetHandleRef targets, - bool inheritTargetAttributes); - [[nodiscard]] eve::Result remapDensityHandle( - ProcgenPointSetHandleRef input, float inputMin, float inputMax, float outputMin, - float outputMax, bool clampOutput); - [[nodiscard]] eve::Result mathFloatAttributeHandle( - ProcgenPointSetHandleRef input, const std::string& attribute, - const std::string& outputAttribute, const std::string& operation, float operand, - float defaultValue); + ProcgenPointSetHandleRef input, float translateX, float translateY, float translateZ, float pitchDegrees, + float yawDegrees, float rollDegrees, float scaleX, float scaleY, float scaleZ); + [[nodiscard]] eve::Result copyPointsHandle(ProcgenPointSetHandleRef source, + ProcgenPointSetHandleRef targets, + bool inheritTargetAttributes); + [[nodiscard]] eve::Result remapDensityHandle(ProcgenPointSetHandleRef input, + float inputMin, float inputMax, + float outputMin, float outputMax, + bool clampOutput); + [[nodiscard]] eve::Result mathFloatAttributeHandle(ProcgenPointSetHandleRef input, + const std::string& attribute, + const std::string& outputAttribute, + const std::string& operation, + float operand, float defaultValue); [[nodiscard]] eve::Result filterFloatAttributeHandle(ProcgenPointSetHandleRef input, - const std::string &name, + const std::string& name, float minValue, float maxValue, bool invert); [[nodiscard]] eve::Result filterStringAttributeHandle(ProcgenPointSetHandleRef input, - const std::string &name, - const std::string &value, + const std::string& name, + const std::string& value, bool invert); [[nodiscard]] eve::Result densityCullHandle(ProcgenPointSetHandleRef input, uint32_t seed, float multiplier); /** @brief Project points through the optional world-query capability. */ - [[nodiscard]] eve::Result projectToWorldHandle( - ProcgenPointSetHandleRef input, float maxY, float minY, std::uint64_t maskBits, - bool keepUnmatched); + [[nodiscard]] eve::Result projectToWorldHandle(ProcgenPointSetHandleRef input, float maxY, + float minY, std::uint64_t maskBits, + bool keepUnmatched); // --- Spatial data and composable PCG domains --- [[nodiscard]] eve::Result pointDataHandle(ProcgenPointSetHandleRef points); [[nodiscard]] eve::Result boxVolumeHandle(float minX, float minY, float minZ, float maxX, float maxY, float maxZ); [[nodiscard]] eve::Result sphereVolumeHandle(float x, float y, float z, float radius); - [[nodiscard]] eve::Result polygonVolumeHandle( - ProcgenPointSetHandleRef controlPoints, float minY, float maxY); + [[nodiscard]] eve::Result polygonVolumeHandle(ProcgenPointSetHandleRef controlPoints, + float minY, float maxY); [[nodiscard]] eve::Result splineDataHandle(ProcgenPointSetHandleRef controlPoints, float radius); [[nodiscard]] eve::Result heightfieldDataHandle(ProcgenHeightmapHandleRef heightmap, float originX, float originZ, float cellSize, float heightScale); - [[nodiscard]] eve::Result textureMaskDataHandle( - ProcgenHeightmapHandleRef values, float originX, float originZ, float cellSize, - float minValue, float maxValue, float minY, float maxY); - [[nodiscard]] eve::Result meshSurfaceDataHandle( - ProcgenMeshBuildHandleRef mesh, float tolerance); + [[nodiscard]] eve::Result textureMaskDataHandle(ProcgenHeightmapHandleRef values, + float originX, float originZ, + float cellSize, float minValue, + float maxValue, float minY, + float maxY); + [[nodiscard]] eve::Result meshSurfaceDataHandle(ProcgenMeshBuildHandleRef mesh, + float tolerance); [[nodiscard]] eve::Result unionSpatialHandle(ProcgenSpatialDataHandleRef left, ProcgenSpatialDataHandleRef right); [[nodiscard]] eve::Result intersectSpatialHandle(ProcgenSpatialDataHandleRef left, ProcgenSpatialDataHandleRef right); [[nodiscard]] eve::Result differenceSpatialHandle(ProcgenSpatialDataHandleRef left, ProcgenSpatialDataHandleRef right); - [[nodiscard]] eve::Result sampleSpatialHandle(ProcgenSpatialDataHandleRef spatial, - float spacing, uint32_t seed, float jitter); - [[nodiscard]] eve::Result filterSpatialHandle(ProcgenPointSetHandleRef input, - ProcgenSpatialDataHandleRef spatial, - bool invert); - [[nodiscard]] eve::Result projectToSpatialHandle(ProcgenPointSetHandleRef input, - ProcgenSpatialDataHandleRef spatial); + [[nodiscard]] eve::Result sampleSpatialHandle(ProcgenSpatialDataHandleRef spatial, + float spacing, uint32_t seed, float jitter); + [[nodiscard]] eve::Result filterSpatialHandle(ProcgenPointSetHandleRef input, + ProcgenSpatialDataHandleRef spatial, + bool invert); + [[nodiscard]] eve::Result projectToSpatialHandle(ProcgenPointSetHandleRef input, + ProcgenSpatialDataHandleRef spatial); [[nodiscard]] eve::Result newRuntimeGenerationHandle(uint32_t worldSeed); [[nodiscard]] eve::Result newPointGraphHandle(); @@ -288,6 +299,8 @@ public: [[nodiscard]] eve::Result newDynamicMeshUvPaintSessionHandle(); /** @brief Allocate a module-owned 3D spline path. */ [[nodiscard]] eve::Result newSplinePathHandle(); + [[nodiscard]] eve::Result newGridGraphHandle(); + [[nodiscard]] eve::Result newMeshGraphHandle(); [[nodiscard]] eve::Result newBiomeRulesHandle(); [[nodiscard]] eve::Result newShapeGrammarHandle(); [[nodiscard]] eve::Result newLSystemHandle(); @@ -306,6 +319,8 @@ public: ProcgenDynamicMeshUvPaintSessionHandleRef reference) noexcept; /** @brief Resolve a spline path for the current synchronous call only. */ [[nodiscard]] eve::script::Borrowed resolveSplinePath(ProcgenSplinePathHandleRef reference) noexcept; + [[nodiscard]] eve::script::Borrowed resolveGridGraph(ProcgenGridGraphHandleRef reference) noexcept; + [[nodiscard]] eve::script::Borrowed resolveMeshGraph(ProcgenMeshGraphHandleRef reference) noexcept; [[nodiscard]] eve::script::Borrowed resolveBiomeRules(ProcgenBiomeRulesHandleRef reference) noexcept; [[nodiscard]] eve::script::Borrowed resolveShapeGrammar( ProcgenShapeGrammarHandleRef reference) noexcept; @@ -321,6 +336,8 @@ public: [[nodiscard]] eve::Result release(ProcgenDynamicMeshUvPaintSessionHandleRef reference); /** @brief Release a module-owned spline path. */ [[nodiscard]] eve::Result release(ProcgenSplinePathHandleRef reference); + [[nodiscard]] eve::Result release(ProcgenGridGraphHandleRef reference); + [[nodiscard]] eve::Result release(ProcgenMeshGraphHandleRef reference); [[nodiscard]] eve::Result release(ProcgenBiomeRulesHandleRef reference); [[nodiscard]] eve::Result release(ProcgenShapeGrammarHandleRef reference); [[nodiscard]] eve::Result release(ProcgenLSystemHandleRef reference); @@ -335,18 +352,20 @@ public: [[nodiscard]] bool isStale(ProcgenDynamicMeshUvPaintSessionHandleRef reference) const noexcept; /** @brief Report whether a spline path handle is stale. */ [[nodiscard]] bool isStale(ProcgenSplinePathHandleRef reference) const noexcept; + [[nodiscard]] bool isStale(ProcgenGridGraphHandleRef reference) const noexcept; + [[nodiscard]] bool isStale(ProcgenMeshGraphHandleRef reference) const noexcept; [[nodiscard]] bool isStale(ProcgenBiomeRulesHandleRef reference) const noexcept; [[nodiscard]] bool isStale(ProcgenShapeGrammarHandleRef reference) const noexcept; [[nodiscard]] bool isStale(ProcgenLSystemHandleRef reference) const noexcept; - [[nodiscard]] eve::Result publishInstances(const std::string &batchId, ProcgenPointSetHandleRef points, - const std::string &assetAttribute, - const std::string &defaultAsset); - [[nodiscard]] eve::Result removeInstances(const std::string &batchId); - [[nodiscard]] eve::Result publishCellInstances(const std::string &prefix, const ProcgenCellRequest &request, + [[nodiscard]] eve::Result publishInstances(const std::string& batchId, ProcgenPointSetHandleRef points, + const std::string& assetAttribute, + const std::string& defaultAsset); + [[nodiscard]] eve::Result removeInstances(const std::string& batchId); + [[nodiscard]] eve::Result publishCellInstances(const std::string& prefix, const ProcgenCellRequest& request, ProcgenPointSetHandleRef points, - const std::string &assetAttribute, - const std::string &defaultAsset); + const std::string& assetAttribute, + const std::string& defaultAsset); /** * @brief Replace a Scene cell batch from a complete RuntimeGeneration snapshot at an explicit revision. * @param prefix Non-empty namespace used by publishCellInstances. @@ -359,7 +378,7 @@ public: * @thread Synchronous on the Scene-owning thread; no input references are retained. * @reentrant Not reentrant for the same cell batch. */ - [[nodiscard]] eve::Result publishCellSnapshot(const std::string& prefix, + [[nodiscard]] eve::Result publishCellSnapshot(const std::string& prefix, const ProcgenCellRequest& request, ProcgenPointSetHandleRef points, uint64_t targetRevision, const std::string& assetAttribute, @@ -444,91 +463,91 @@ public: [[nodiscard]] eve::Result completeCellCleanupAtomic( const std::string& prefix, const std::vector& runtimes, const std::vector& requests); - int getPublishedInstanceCount(const std::string& batchId) const; - int getPublishedCreatedCount(const std::string& batchId) const; - int getPublishedReusedCount(const std::string &batchId) const; - int getPublishedRemovedCount(const std::string &batchId) const; - uint32_t deriveSeed(uint32_t parent, const std::string &scope) const; + int getPublishedInstanceCount(const std::string& batchId) const; + int getPublishedCreatedCount(const std::string& batchId) const; + int getPublishedReusedCount(const std::string& batchId) const; + int getPublishedRemovedCount(const std::string& batchId) const; + uint32_t deriveSeed(uint32_t parent, const std::string& scope) const; // --- Atomic script rebuilds --- /** @brief Starts a module-owned rebuild context and returns its handle. */ - [[nodiscard]] static eve::Result beginSystemHandle(const std::string &name, uint32_t seed); + [[nodiscard]] static eve::Result beginSystemHandle(const std::string& name, uint32_t seed); /** @brief Starts a cached module-owned rebuild context and returns its handle. */ - [[nodiscard]] static eve::Result beginCachedSystemHandle(const std::string &name, + [[nodiscard]] static eve::Result beginCachedSystemHandle(const std::string& name, uint32_t seed, - const std::string &buildKey); + const std::string& buildKey); /** @brief Resolves a rebuild context as a non-owning observation. */ [[nodiscard]] static eve::script::Borrowed resolve(ProcgenContextHandleRef reference) noexcept; /** @brief Releases a module-owned rebuild context. */ [[nodiscard]] static eve::Result release(ProcgenContextHandleRef reference); /** @brief Reports whether a rebuild context reference is stale. */ - [[nodiscard]] static bool isStale(ProcgenContextHandleRef reference) noexcept; - [[nodiscard]] eve::Result commitSystem(ProcgenContextHandleRef context); - [[nodiscard]] eve::Result abortSystem(ProcgenContextHandleRef context); - [[nodiscard]] eve::Result removeSystem(const std::string &name); - bool hasSystem(const std::string& name) const; - uint64_t getSystemRevision(const std::string& name) const; - uint32_t getSystemSeed(const std::string& name) const; - std::string getSystemBuildKey(const std::string& name) const; - int getSystemOutputCount(const std::string& name) const; - std::string getSystemOutputName(const std::string &name, int index) const; - int getSystemDebugStageCount(const std::string& name) const; - std::string getSystemDebugStageName(const std::string& name, int index) const; - [[nodiscard]] eve::Result getSystemOutputHandle(const std::string &name, - const std::string &outputName) const; - [[nodiscard]] eve::Result getSystemDebugStageHandle(const std::string &name, - const std::string &stageName) const; + [[nodiscard]] static bool isStale(ProcgenContextHandleRef reference) noexcept; + [[nodiscard]] eve::Result commitSystem(ProcgenContextHandleRef context); + [[nodiscard]] eve::Result abortSystem(ProcgenContextHandleRef context); + [[nodiscard]] eve::Result removeSystem(const std::string& name); + bool hasSystem(const std::string& name) const; + uint64_t getSystemRevision(const std::string& name) const; + uint32_t getSystemSeed(const std::string& name) const; + std::string getSystemBuildKey(const std::string& name) const; + int getSystemOutputCount(const std::string& name) const; + std::string getSystemOutputName(const std::string& name, int index) const; + int getSystemDebugStageCount(const std::string& name) const; + std::string getSystemDebugStageName(const std::string& name, int index) const; + [[nodiscard]] eve::Result getSystemOutputHandle(const std::string& name, + const std::string& outputName) const; + [[nodiscard]] eve::Result getSystemDebugStageHandle(const std::string& name, + const std::string& stageName) const; [[nodiscard]] eve::Result getPreviousSystemDebugStageHandle( - const std::string &name, const std::string &stageName) const; + const std::string& name, const std::string& stageName) const; /** @brief Revision number of the snapshot retained for hot-reload comparison. */ - uint64_t getPreviousSystemRevision(const std::string& name) const; - std::string getSystemDebugReport(const std::string& name) const; + uint64_t getPreviousSystemRevision(const std::string& name) const; + std::string getSystemDebugReport(const std::string& name) const; /** @brief Human-readable point-count changes between the current and previous commits. */ - std::string getSystemDebugDiffReport(const std::string& name) const; + std::string getSystemDebugDiffReport(const std::string& name) const; // --- Phase A: maps --- - [[nodiscard]] eve::Result generateHandle(const std::string &algorithmId, + [[nodiscard]] eve::Result generateHandle(const std::string& algorithmId, ProcgenParamsHandleRef params); - [[nodiscard]] eve::Result generateTo(const std::string &algorithmId, ProcgenParamsHandleRef params, + [[nodiscard]] eve::Result generateTo(const std::string& algorithmId, ProcgenParamsHandleRef params, ProcgenOutputHandleRef output); - [[nodiscard]] eve::Result applyToLayer(ProcgenGridHandleRef grid, const std::string &palette, - map::TileLayer &layer); + [[nodiscard]] eve::Result applyToLayer(ProcgenGridHandleRef grid, const std::string& palette, + map::TileLayer& layer); - void setPaletteGid(const std::string &palette, const std::string &semantic, int gid); - int getPaletteGid(const std::string &palette, const std::string &semantic) const; + void setPaletteGid(const std::string& palette, const std::string& semantic, int gid); + int getPaletteGid(const std::string& palette, const std::string& semantic) const; int getAlgorithmCount() const; std::string getAlgorithmId(int index) const; - bool hasAlgorithm(const std::string &algorithmId) const; + bool hasAlgorithm(const std::string& algorithmId) const; /** @brief Returns an owning copy of an algorithm schema or a diagnostic. */ - [[nodiscard]] eve::Result getAlgorithmSchema(const std::string &algorithmId) const; + [[nodiscard]] eve::Result getAlgorithmSchema(const std::string& algorithmId) const; /** @brief Human-readable algorithm label from its schema. * @param algorithmId Algorithm id. @return Display name or empty text. */ - std::string getAlgorithmDisplayName(const std::string &algorithmId) const; + std::string getAlgorithmDisplayName(const std::string& algorithmId) const; /** @brief Algorithm category from its schema. * @param algorithmId Algorithm id. @return Category or empty text. */ - std::string getAlgorithmCategory(const std::string &algorithmId) const; + std::string getAlgorithmCategory(const std::string& algorithmId) const; /** @brief Number of reflected parameters for one algorithm. * @param algorithmId Algorithm id. @return Parameter count. */ - int getAlgorithmParamCount(const std::string &algorithmId) const; + int getAlgorithmParamCount(const std::string& algorithmId) const; /** @brief Stable parameter key at an index. * @param algorithmId Algorithm id. @param index Parameter index. @return Key or empty text. */ - std::string getAlgorithmParamKey(const std::string &algorithmId, int index) const; + std::string getAlgorithmParamKey(const std::string& algorithmId, int index) const; /** @brief Human-readable parameter label at an index. * @param algorithmId Algorithm id. @param index Parameter index. @return Label or empty text. */ - std::string getAlgorithmParamLabel(const std::string &algorithmId, int index) const; + std::string getAlgorithmParamLabel(const std::string& algorithmId, int index) const; /** @brief Parameter help text at an index. * @param algorithmId Algorithm id. @param index Parameter index. @return Help text or empty text. */ - std::string getAlgorithmParamDescription(const std::string &algorithmId, int index) const; + std::string getAlgorithmParamDescription(const std::string& algorithmId, int index) const; /** @brief Parameter group at an index. * @param algorithmId Algorithm id. @param index Parameter index. @return Group or empty text. */ - std::string getAlgorithmParamCategory(const std::string &algorithmId, int index) const; + std::string getAlgorithmParamCategory(const std::string& algorithmId, int index) const; /** @brief Parameter kind as int, float, bool, string or choice. * @param algorithmId Algorithm id. @param index Parameter index. @return Kind or empty text. */ - std::string getAlgorithmParamKind(const std::string &algorithmId, int index) const; + std::string getAlgorithmParamKind(const std::string& algorithmId, int index) const; /** @brief Schema default encoded as text for lossless dynamic UI transport. * @param algorithmId Algorithm id. @param index Parameter index. @return Encoded default. */ - std::string getAlgorithmParamDefault(const std::string &algorithmId, int index) const; + std::string getAlgorithmParamDefault(const std::string& algorithmId, int index) const; /** @brief Return true when a numeric minimum is declared. * @param algorithmId Algorithm id. @param index Parameter index. @return Whether it exists. */ bool algorithmParamHasMinimum(const std::string &algorithmId, int index) const; @@ -537,28 +556,27 @@ public: bool algorithmParamHasMaximum(const std::string &algorithmId, int index) const; /** @brief Numeric minimum. * @param algorithmId Algorithm id. @param index Parameter index. @return Minimum or zero. */ - float getAlgorithmParamMinimum(const std::string &algorithmId, int index) const; + float getAlgorithmParamMinimum(const std::string& algorithmId, int index) const; /** @brief Numeric maximum. * @param algorithmId Algorithm id. @param index Parameter index. @return Maximum or zero. */ - float getAlgorithmParamMaximum(const std::string &algorithmId, int index) const; + float getAlgorithmParamMaximum(const std::string& algorithmId, int index) const; /** @brief Suggested numeric editing step. * @param algorithmId Algorithm id. @param index Parameter index. @return Step or zero. */ - float getAlgorithmParamStep(const std::string &algorithmId, int index) const; + float getAlgorithmParamStep(const std::string& algorithmId, int index) const; /** @brief Return true when a parameter should be hidden by compact inspectors. * @param algorithmId Algorithm id. @param index Parameter index. @return Whether it is advanced. */ - bool isAlgorithmParamAdvanced(const std::string &algorithmId, int index) const; + bool isAlgorithmParamAdvanced(const std::string& algorithmId, int index) const; /** @brief Number of allowed values for a choice parameter. * @param algorithmId Algorithm id. @param index Parameter index. @return Choice count. */ - int getAlgorithmParamChoiceCount(const std::string &algorithmId, int index) const; + int getAlgorithmParamChoiceCount(const std::string& algorithmId, int index) const; /** @brief Choice value at an index. * @param algorithmId Algorithm id. @param paramIndex Parameter index. * @param choiceIndex Choice index. @return Choice or empty text. */ - std::string getAlgorithmParamChoice(const std::string &algorithmId, int paramIndex, - int choiceIndex) const; + std::string getAlgorithmParamChoice(const std::string& algorithmId, int paramIndex, int choiceIndex) const; /** @brief Fill missing algorithm-specific values from schema defaults. * @param algorithmId Algorithm id. @param params Parameters to update. * @return Whether the schema exists. */ - [[nodiscard]] eve::Result applyAlgorithmDefaults(const std::string &algorithmId, + [[nodiscard]] eve::Result applyAlgorithmDefaults(const std::string& algorithmId, ProcgenParamsHandleRef params) const; /** @@ -573,7 +591,7 @@ public: [[nodiscard]] eve::Result gridToJson(ProcgenGridHandleRef grid) const; // --- Phase B: textures --- - [[nodiscard]] eve::Result generateImageHandle(const std::string &recipeId, + [[nodiscard]] eve::Result generateImageHandle(const std::string& recipeId, ProcgenParamsHandleRef params); /** * @brief Resolves a generated image as a non-owning observation. @@ -602,7 +620,7 @@ public: */ [[nodiscard]] static bool isStale(ProcgenImageHandleRef reference) noexcept; - [[nodiscard]] eve::Result generateNormalImageHandle(const std::string &recipeId, + [[nodiscard]] eve::Result generateNormalImageHandle(const std::string& recipeId, ProcgenParamsHandleRef params); /** * @brief Resolves a generated normal image as a non-owning observation. @@ -644,17 +662,17 @@ public: * @thread Graphics/Procgen owner thread; no synchronization is provided. * @reentrancy Does not invoke user callbacks. */ - [[nodiscard]] eve::script::Borrowed generateTextureBorrowed(const std::string &recipeId, + [[nodiscard]] eve::script::Borrowed generateTextureBorrowed(const std::string& recipeId, ProcgenParamsHandleRef params, - graphics::Graphics *gfx); + graphics::Graphics* gfx); int getTextureRecipeCount() const; std::string getTextureRecipeId(int index) const; - bool hasTextureRecipe(const std::string &recipeId) const; + bool hasTextureRecipe(const std::string& recipeId) const; /** @brief Returns an owning copy of a texture schema or a diagnostic. */ - [[nodiscard]] eve::Result getTextureRecipeSchema(const std::string &recipeId) const; + [[nodiscard]] eve::Result getTextureRecipeSchema(const std::string& recipeId) const; /** @brief Fill missing texture parameters in a module-owned parameter object. */ - [[nodiscard]] eve::Result applyTextureRecipeDefaults(const std::string &recipeId, + [[nodiscard]] eve::Result applyTextureRecipeDefaults(const std::string& recipeId, ProcgenParamsHandleRef params) const; // --- Phase E: dynamic clouds + cloud shadows --- @@ -685,7 +703,7 @@ public: * (call PbrTextureSet::destroy()). Recipes: pbr.soil/stone/rock/marble/water/ * ripple/wood/cloth/ornament/spot/zebra/wall/cement/mud/sky_cloud. */ - [[nodiscard]] eve::Result generatePbrMaterialHandle(const std::string &recipeId, + [[nodiscard]] eve::Result generatePbrMaterialHandle(const std::string& recipeId, ProcgenParamsHandleRef params); [[nodiscard]] eve::script::Borrowed resolvePbrMaterial(ProcgenPbrMaterialHandleRef) noexcept; [[nodiscard]] eve::Result releasePbrMaterial(ProcgenPbrMaterialHandleRef); @@ -693,11 +711,11 @@ public: int getPbrRecipeCount() const; std::string getPbrRecipeId(int index) const; - bool hasPbrRecipe(const std::string &recipeId) const; + bool hasPbrRecipe(const std::string& recipeId) const; /** @brief Returns an owning copy of a PBR schema or a diagnostic. */ - [[nodiscard]] eve::Result getPbrRecipeSchema(const std::string &recipeId) const; + [[nodiscard]] eve::Result getPbrRecipeSchema(const std::string& recipeId) const; /** @brief Fill missing PBR parameters in a module-owned parameter object. */ - [[nodiscard]] eve::Result applyPbrRecipeDefaults(const std::string &recipeId, + [[nodiscard]] eve::Result applyPbrRecipeDefaults(const std::string& recipeId, ProcgenParamsHandleRef params) const; // --- Mesh recipes (Marching Cubes, …) --- @@ -706,7 +724,7 @@ public: * Recipes include mesh.marchingcubes, mesh.hexplanet, mesh.castle and the * registered vegetation, building and linear-structure recipes. */ - [[nodiscard]] eve::Result buildMeshHandle(const std::string &recipeId, + [[nodiscard]] eve::Result buildMeshHandle(const std::string& recipeId, ProcgenParamsHandleRef params); [[nodiscard]] eve::script::Borrowed resolveMeshBuild(ProcgenMeshBuildHandleRef) noexcept; [[nodiscard]] eve::Result releaseMeshBuild(ProcgenMeshBuildHandleRef); @@ -718,7 +736,7 @@ public: * @param id Non-nil identity for this artifact instance. * @return Generated CPU artifact or a structured diagnostic. */ - [[nodiscard]] eve::Result buildArtifact(const std::string &recipeId, + [[nodiscard]] eve::Result buildArtifact(const std::string& recipeId, ProcgenParamsHandleRef params, ArtifactId id); /** * @brief Build then transactionally publish through requested optional adapters. @@ -729,11 +747,11 @@ public: * @return Publication receipt or an Unsupported/validation/adapter diagnostic. * @remarks Must run on the main/simulation thread. A failure leaves the store unchanged. */ - [[nodiscard]] eve::Result publishArtifact(const std::string &recipeId, + [[nodiscard]] eve::Result publishArtifact(const std::string& recipeId, ProcgenParamsHandleRef params, ArtifactId id, ArtifactPublishOptions options = {}); /** @brief Borrow the compatibility facade's published CPU artifact store. */ - [[nodiscard]] const ArtifactStore &artifactStore() const noexcept { return artifactStore_; } + [[nodiscard]] const ArtifactStore& artifactStore() const noexcept { return artifactStore_; } /** @brief Upload an existing/composed CPU mesh to Graphics. */ /** @brief Upload a CPU mesh and return a Graphics-owned borrowed mesh. */ [[nodiscard]] eve::script::Borrowed uploadMeshBorrowed(const MeshBuild &mesh, @@ -773,17 +791,17 @@ public: graphics::Graphics& gfx, float worldDiameter, float verticalFovDegrees); /** @brief Build + upload to GPU Mesh (owned by Graphics). */ - [[nodiscard]] eve::script::Borrowed generateMeshBorrowed(const std::string &recipeId, + [[nodiscard]] eve::script::Borrowed generateMeshBorrowed(const std::string& recipeId, ProcgenParamsHandleRef params, - graphics::Graphics *gfx); + graphics::Graphics* gfx); int getMeshRecipeCount() const; std::string getMeshRecipeId(int index) const; - bool hasMeshRecipe(const std::string &recipeId) const; + bool hasMeshRecipe(const std::string& recipeId) const; /** @brief Returns an owning copy of a mesh schema or a diagnostic. */ - [[nodiscard]] eve::Result getMeshRecipeSchema(const std::string &recipeId) const; + [[nodiscard]] eve::Result getMeshRecipeSchema(const std::string& recipeId) const; /** @brief Fill missing mesh parameters in a module-owned parameter object. */ - [[nodiscard]] eve::Result applyMeshRecipeDefaults(const std::string &recipeId, + [[nodiscard]] eve::Result applyMeshRecipeDefaults(const std::string& recipeId, ProcgenParamsHandleRef params) const; // --- Phase D: terrain height sampling --- @@ -802,100 +820,92 @@ public: [[nodiscard]] eve::Result releaseHeightmap(ProcgenHeightmapHandleRef); [[nodiscard]] bool isHeightmapStale(ProcgenHeightmapHandleRef) const noexcept; /** @brief Compatibility facade that classifies a heightmap into a semantic Grid2D. */ - bool heightmapToGrid(Heightmap *heightmap, Params *params, Grid2D *out); + bool heightmapToGrid(Heightmap* heightmap, Params* params, Grid2D* out); /** @brief Compatibility facade that applies mass-conserving thermal erosion in place. */ - bool erodeTerrainThermal(Heightmap *heightmap, int iterations, float talus, float strength); + bool erodeTerrainThermal(Heightmap* heightmap, int iterations, float talus, float strength); /** @brief Compatibility facade that applies deterministic grid water/sediment erosion. */ - bool erodeTerrainHydraulic(Heightmap *heightmap, int iterations, float rainfall, - float evaporation, float capacity, float erosion, - float deposition); + bool erodeTerrainHydraulic(Heightmap* heightmap, int iterations, float rainfall, float evaporation, float capacity, + float erosion, float deposition); /** @brief Compatibility facade that cuts drainage-connected river valleys in place. */ - bool erodeTerrainFluvial(Heightmap *heightmap, int iterations, float riverThreshold, - float incision, float maxDepth, float bankWidth); + bool erodeTerrainFluvial(Heightmap* heightmap, int iterations, float riverThreshold, float incision, float maxDepth, + float bankWidth); /** @brief Compatibility facade with independent incision and spill-sill limits. */ - bool erodeTerrainFluvialAdvanced(Heightmap *heightmap, int iterations, float riverThreshold, - float incision, float maxDepth, float bankWidth, - float maxBreachDepth); + bool erodeTerrainFluvialAdvanced(Heightmap* heightmap, int iterations, float riverThreshold, float incision, + float maxDepth, float bankWidth, float maxBreachDepth); /** @brief Compatibility facade with explicit raster-to-physical scaling. */ - bool erodeTerrainFluvialScaled(Heightmap *heightmap, int iterations, float riverThreshold, - float incision, float maxDepth, float bankWidth, - float maxBreachDepth, float coordinateScale); + bool erodeTerrainFluvialScaled(Heightmap* heightmap, int iterations, float riverThreshold, float incision, + float maxDepth, float bankWidth, float maxBreachDepth, float coordinateScale); /** @brief Compatibility facade for detailed erosion. @ownership Caller owns the result. */ - TerrainErosionMap *erodeTerrainFluvialDetailed( - Heightmap *heightmap, int iterations, float riverThreshold, float incision, - float maxDepth, float bankWidth, float maxBreachDepth, float coordinateScale); + TerrainErosionMap* erodeTerrainFluvialDetailed(Heightmap* heightmap, int iterations, float riverThreshold, + float incision, float maxDepth, float bankWidth, + float maxBreachDepth, float coordinateScale); /** @brief Compatibility facade for terrain analysis. @ownership Caller owns the result. */ - TerrainLayers *analyzeTerrain(Heightmap *heightmap, float riverThreshold, float seaLevel, - float latitude); + TerrainLayers* analyzeTerrain(Heightmap* heightmap, float riverThreshold, float seaLevel, float latitude); /** @brief Compatibility facade for scaled analysis. @ownership Caller owns the result. */ - TerrainLayers *analyzeTerrainScaled(Heightmap *heightmap, float riverThreshold, float seaLevel, - float latitude, float coordinateScale); + TerrainLayers* analyzeTerrainScaled(Heightmap* heightmap, float riverThreshold, float seaLevel, float latitude, + float coordinateScale); /** @brief Compatibility facade for EVTR baking. @ownership Caller owns the result. */ - data::ByteData *bakeTerrainAsset(Heightmap *heightmap, TerrainLayers *layers, int chunkSize); + data::ByteData* bakeTerrainAsset(Heightmap* heightmap, TerrainLayers* layers, int chunkSize); /** @brief Compatibility facade for LOD chunks. @ownership Caller owns the result. */ - TerrainMeshChunk *buildTerrainChunk(Heightmap *heightmap, TerrainLayers *layers, - int originX, int originY, int cellsX, int cellsY, int lod, - float cellSize, float heightScale, float skirtDepth); + TerrainMeshChunk* buildTerrainChunk(Heightmap* heightmap, TerrainLayers* layers, int originX, int originY, + int cellsX, int cellsY, int lod, float cellSize, float heightScale, + float skirtDepth); /** @brief Select terrain LOD from measured screen-space geometric error. */ - int selectTerrainLod(Heightmap *heightmap, int originX, int originY, int cellsX, int cellsY, - int maxLod, float cellSize, float heightScale, float cameraDistance, - float viewportHeight, float verticalFovDegrees, float targetPixelError); + int selectTerrainLod(Heightmap* heightmap, int originX, int originY, int cellsX, int cellsY, int maxLod, + float cellSize, float heightScale, float cameraDistance, float viewportHeight, + float verticalFovDegrees, float targetPixelError); /** @brief Compatibility upload facade. @lifetime Returned mesh is owned by Graphics. */ - graphics::Mesh *generateTerrainChunkMesh(TerrainMeshChunk *chunk, graphics::Graphics *gfx); + graphics::Mesh* generateTerrainChunkMesh(TerrainMeshChunk* chunk, graphics::Graphics* gfx); /** @brief Compatibility river mesh facade. @lifetime Returned mesh is owned by Graphics. */ - graphics::Mesh *generateTerrainRiverMesh(Heightmap *heightmap, TerrainLayers *layers, - graphics::Graphics *gfx, int originX, int originY, - int cellsX, int cellsY, float cellSize, - float heightScale, float minWidth, float maxWidth, - float heightOffset); + graphics::Mesh* generateTerrainRiverMesh(Heightmap* heightmap, TerrainLayers* layers, graphics::Graphics* gfx, + int originX, int originY, int cellsX, int cellsY, float cellSize, + float heightScale, float minWidth, float maxWidth, float heightOffset); /** @brief Compatibility slope-banded river facade. @lifetime Returned mesh is owned by Graphics. */ - graphics::Mesh *generateTerrainRiverMeshAdvanced( - Heightmap *heightmap, TerrainLayers *layers, graphics::Graphics *gfx, - int originX, int originY, int cellsX, int cellsY, float cellSize, - float heightScale, float minWidth, float maxWidth, float heightOffset, - float minSurfaceSlope, float maxSurfaceSlope); + graphics::Mesh* generateTerrainRiverMeshAdvanced(Heightmap* heightmap, TerrainLayers* layers, + graphics::Graphics* gfx, int originX, int originY, int cellsX, + int cellsY, float cellSize, float heightScale, float minWidth, + float maxWidth, float heightOffset, float minSurfaceSlope, + float maxSurfaceSlope); /** @brief Compatibility lake mesh facade. @lifetime Returned mesh is owned by Graphics. */ - graphics::Mesh *generateTerrainLakeMesh(Heightmap *heightmap, TerrainLayers *layers, - graphics::Graphics *gfx, int originX, int originY, - int cellsX, int cellsY, float cellSize, - float heightScale, float minimumDepth, - float heightOffset); + graphics::Mesh* generateTerrainLakeMesh(Heightmap* heightmap, TerrainLayers* layers, graphics::Graphics* gfx, + int originX, int originY, int cellsX, int cellsY, float cellSize, + float heightScale, float minimumDepth, float heightOffset); /** @brief Compatibility splat facade. @ownership Caller owns the returned image. */ - image::ImageData *generateTerrainSplatMap(TerrainMeshChunk *chunk); + image::ImageData* generateTerrainSplatMap(TerrainMeshChunk* chunk); /** @brief Compatibility albedo facade. @ownership Caller owns the returned image. */ - image::ImageData *generateTerrainAlbedoMap(TerrainMeshChunk *chunk); + image::ImageData* generateTerrainAlbedoMap(TerrainMeshChunk* chunk); /** @brief Compatibility erosion facade. @ownership Caller owns the returned image. */ - image::ImageData *generateTerrainErosionMap(TerrainErosionMap *erosion, float exposure); + image::ImageData* generateTerrainErosionMap(TerrainErosionMap* erosion, float exposure); /** @brief Compatibility wear facade. @ownership Caller owns the returned image. */ - image::ImageData *generateTerrainWearMap(TerrainErosionMap *erosion, float exposure); + image::ImageData* generateTerrainWearMap(TerrainErosionMap* erosion, float exposure); /** @brief Compatibility deposition facade. @ownership Caller owns the returned image. */ - image::ImageData *generateTerrainDepositionMap(TerrainErosionMap *erosion, float exposure); + image::ImageData* generateTerrainDepositionMap(TerrainErosionMap* erosion, float exposure); /** @brief Compatibility material facade. @lifetime Returned shader is owned by Graphics. */ - graphics::Shader *createTerrainMaterialShader(graphics::Graphics *gfx); + graphics::Shader* createTerrainMaterialShader(graphics::Graphics* gfx); /** @brief Compatibility water facade. @lifetime Returned shader is owned by Graphics. */ - graphics::Shader *createTerrainWaterShader(graphics::Graphics *gfx); + graphics::Shader* createTerrainWaterShader(graphics::Graphics* gfx); [[nodiscard]] eve::Result generateHeightmapHandle(ProcgenParamsHandleRef params); /** @brief Classify a heightmap into a module-owned grid using params bands. */ [[nodiscard]] eve::Result heightmapToGrid(ProcgenHeightmapHandleRef heightmap, ProcgenParamsHandleRef params); - PaletteTable &palettes() { return palettes_; } + PaletteTable& palettes() { return palettes_; } private: - bool runGenerate(const std::string &algorithmId, const Params ¶ms, Grid2D &out); + bool runGenerate(const std::string& algorithmId, const Params& params, Grid2D& out); ArtifactId nextCompatibilityArtifactId() noexcept; struct OwnershipState; std::unique_ptr ownership_; - PaletteTable palettes_; + PaletteTable palettes_; mutable std::string lastError_; - mutable std::vector algorithmIdsCache_; - mutable std::vector textureRecipeIdsCache_; - mutable std::vector pbrRecipeIdsCache_; - mutable std::vector meshRecipeIdsCache_; - std::unordered_map systems_; - std::unordered_map previousSystems_; + mutable std::vector algorithmIdsCache_; + mutable std::vector textureRecipeIdsCache_; + mutable std::vector pbrRecipeIdsCache_; + mutable std::vector meshRecipeIdsCache_; + std::unordered_map systems_; + std::unordered_map previousSystems_; ArtifactStore artifactStore_; std::uint64_t nextArtifactSequence_ = 1; eve::script::RuntimeObjectRegistry params_; @@ -910,6 +920,8 @@ private: eve::script::RuntimeObjectRegistry dynamicMeshUvPaintSessions_; eve::script::RuntimeObjectRegistry splinePaths_; + eve::script::RuntimeObjectRegistry gridGraphs_; + eve::script::RuntimeObjectRegistry meshGraphs_; eve::script::RuntimeObjectRegistry biomeRules_; eve::script::RuntimeObjectRegistry shapeGrammars_; eve::script::RuntimeObjectRegistry lsystems_; diff --git a/src/modules/procgen/ProcgenGraph.cpp b/src/modules/procgen/ProcgenGraph.cpp index ee72ded4..710374d6 100644 --- a/src/modules/procgen/ProcgenGraph.cpp +++ b/src/modules/procgen/ProcgenGraph.cpp @@ -1,5 +1,7 @@ #include "procgen/Procgen.h" +#include "procgen/GridGraph.h" +#include "procgen/MeshGraph.h" #include "procgen/PointGraph.h" #include "procgen/mesh/MeshModifierGraph.h" @@ -25,4 +27,12 @@ eve::Result Procgen::newSplinePathHandle() { return splinePaths_.emplace(std::make_unique()); } +eve::Result Procgen::newGridGraphHandle() { + return gridGraphs_.emplace(std::make_unique()); +} + +eve::Result Procgen::newMeshGraphHandle() { + return meshGraphs_.emplace(std::make_unique()); +} + } // namespace eve::procgen diff --git a/src/modules/procgen/TilePreset.cpp b/src/modules/procgen/TilePreset.cpp new file mode 100644 index 00000000..c6475e0b --- /dev/null +++ b/src/modules/procgen/TilePreset.cpp @@ -0,0 +1,126 @@ +#include "procgen/TilePreset.h" + +#include +#include + +namespace eve::procgen { +namespace { + +template +bool contains(const std::array& values, std::uint16_t value) noexcept { + return std::find(values.begin(), values.end(), value) != values.end(); +} + +constexpr std::array cornerFill = {27, 31, 54, 55, 91, 95, 118, 119, 216, 217, 220, + 221, 283, 287, 310, 311, 347, 351, 374, 375, 432, 433, + 436, 437, 472, 473, 476, 477, 496, 497, 500, 501}; +constexpr std::array cornerWay = {26, 30, 50, 51, 90, 94, 114, 115, 152, 153, 156, + 157, 176, 177, 180, 181, 240, 241, 244, 245, 282, 286, + 306, 307, 346, 370, 371, 408, 409, 412, 413}; +constexpr std::array edgeWay = {56, 57, 60, 61, 120, 121, 124, 125, 146, 147, 150, + 151, 210, 211, 214, 215, 312, 313, 316, 317, 376, 377, + 380, 381, 402, 403, 406, 407, 466, 467, 470, 471}; +constexpr std::array edgeFill = {63, 127, 219, 223, 319, 383, 438, 439, + 475, 479, 502, 503, 504, 505, 508, 509}; +constexpr std::array threeWay = {58, 122, 154, 158, 178, 179, 184, 185, + 188, 189, 242, 243, 314, 378, 410, 414}; +constexpr std::array threeWayFill = {191, 251, 446, 506}; +constexpr std::array deadEnd = { + 18, 19, 22, 23, 24, 25, 28, 29, 48, 49, 52, 53, 82, 83, 86, 87, 88, 89, 92, 93, 112, 113, + 116, 117, 144, 145, 148, 149, 208, 209, 212, 213, 274, 275, 278, 279, 280, 281, 284, 285, 304, 305, 308, 309, + 338, 339, 342, 343, 344, 345, 348, 349, 368, 369, 372, 373, 400, 401, 404, 405, 464, 465, 468, 469}; +constexpr std::array single = {16, 17, 20, 21, 80, 81, 84, 85, + 272, 273, 276, 277, 336, 337, 340, 341}; +constexpr std::array edgeCornerFill = {59, 62, 123, 126, 155, 159, 182, 183, 218, 222, 246, + 247, 248, 249, 252, 253, 315, 318, 379, 382, 411, 415, + 434, 435, 440, 441, 444, 445, 474, 478, 498, 499}; +constexpr std::array threeCorner = {187, 190, 250, 442}; +constexpr std::array doubleCorner = {254, 443}; +constexpr std::array interiorCorner = {255, 447, 507, 510}; + +constexpr std::array rotationZero = {312, 120, 56, 24, 89, 280, 58, 152, + 186, 95, 218, 222, 434, 474, 498}; +constexpr std::array rotation90 = { + 22, 23, 18, 19, 82, 83, 86, 87, 274, 275, 278, 279, 338, 339, 342, 343, 146, 147, 151, 210, 211, 214, + 215, 403, 406, 407, 466, 467, 470, 471, 438, 439, 502, 503, 178, 179, 242, 243, 191, 54, 59, 123, 248, 249, + 252, 253, 315, 379, 55, 118, 310, 311, 374, 375, 26, 30, 90, 94, 119, 282, 286, 346, 507, 254, 250}; +constexpr std::array rotation180 = {48, 49, 52, 53, 112, 113, 116, 117, 304, 305, 308, 309, 368, + 369, 372, 373, 50, 51, 114, 115, 306, 307, 370, 371, 504, 505, + 508, 509, 432, 433, 436, 437, 496, 497, 500, 501, 187, 184, 185, + 188, 189, 446, 155, 159, 182, 183, 246, 247, 411, 415, 255}; +constexpr std::array rotation270 = { + 144, 145, 148, 149, 208, 209, 212, 213, 400, 401, 404, 405, 464, 465, 468, 469, 154, 158, + 410, 414, 506, 216, 217, 220, 472, 476, 473, 477, 176, 177, 180, 181, 221, 240, 241, 244, + 245, 190, 150, 402, 223, 219, 475, 479, 62, 126, 318, 382, 440, 441, 444, 445, 447}; +constexpr std::array mirrorX = {59, 123, 182, 183, 218, 222, 246, 247, + 315, 379, 440, 441, 444, 445, 474, 478}; + +TilePresetKind classify(std::uint16_t c) noexcept { + if (contains(cornerWay, c)) return TilePresetKind::CornerWay; + if (contains(cornerFill, c)) return TilePresetKind::CornerFill; + if (contains(edgeWay, c)) return TilePresetKind::EdgeWay; + if (contains(edgeFill, c)) return TilePresetKind::EdgeFill; + if (c == 511) return TilePresetKind::Fill; + if (contains(single, c)) return TilePresetKind::Single; + if (contains(threeWay, c)) return TilePresetKind::ThreeWay; + if (contains(threeWayFill, c)) return TilePresetKind::ThreeWayFill; + if (contains(threeCorner, c)) return TilePresetKind::ThreeCorner; + if (contains(deadEnd, c)) return TilePresetKind::DeadEnd; + if (c == 186) return TilePresetKind::FourWay; + if (contains(edgeCornerFill, c)) return TilePresetKind::EdgeCornerFill; + if (contains(doubleCorner, c)) return TilePresetKind::DoubleCorner; + if (contains(interiorCorner, c)) return TilePresetKind::InteriorCorner; + return TilePresetKind::None; +} + +} // namespace + +std::uint16_t toTilePresetConfiguration(std::uint8_t mask) noexcept { + std::uint16_t c = 1u << 4u; + if (mask & (1u << 0u)) c |= 1u << 1u; // N -> top + if (mask & (1u << 1u)) c |= 1u << 5u; // E -> right + if (mask & (1u << 2u)) c |= 1u << 7u; // S -> bottom + if (mask & (1u << 3u)) c |= 1u << 3u; // W -> left + if (mask & (1u << 4u)) c |= 1u << 2u; // NE -> top-right + if (mask & (1u << 5u)) c |= 1u << 8u; // SE -> bottom-right + if (mask & (1u << 6u)) c |= 1u << 6u; // SW -> bottom-left + if (mask & (1u << 7u)) c |= 1u << 0u; // NW -> top-left + return c; +} + +TilePresetVariant resolveTilePreset(std::uint8_t mask) noexcept { + const auto c = toTilePresetConfiguration(mask); + int rotation = 0; + if (contains(rotation90, c)) + rotation = 90; + else if (contains(rotation180, c)) + rotation = 180; + else if (contains(rotation270, c)) + rotation = 270; + else if (contains(rotationZero, c)) + rotation = 0; + return {classify(c), rotation, contains(mirrorX, c), c}; +} + +std::string_view tilePresetKindName(TilePresetKind kind) noexcept { + switch (kind) { + case TilePresetKind::DeadEnd: return "dead_end"; + case TilePresetKind::Single: return "single"; + case TilePresetKind::Fill: return "fill"; + case TilePresetKind::CornerWay: return "corner_way"; + case TilePresetKind::CornerFill: return "corner_fill"; + case TilePresetKind::InteriorCorner: return "interior_corner"; + case TilePresetKind::DoubleCorner: return "double_corner"; + case TilePresetKind::EdgeWay: return "edge_way"; + case TilePresetKind::EdgeFill: return "edge_fill"; + case TilePresetKind::ThreeWay: return "three_way"; + case TilePresetKind::ThreeWayFill: return "three_way_fill"; + case TilePresetKind::EdgeCornerFill: return "edge_corner_fill"; + case TilePresetKind::ThreeCorner: return "three_corner"; + case TilePresetKind::FourWay: return "four_way"; + case TilePresetKind::None: return "none"; + } + return "none"; +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/TilePreset.h b/src/modules/procgen/TilePreset.h new file mode 100644 index 00000000..0451c82d --- /dev/null +++ b/src/modules/procgen/TilePreset.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +namespace eve::procgen { + +/** @brief TileWorldCreator-compatible standard-grid tile categories. */ +enum class TilePresetKind : std::uint8_t { + None, + DeadEnd, + Single, + Fill, + CornerWay, + CornerFill, + InteriorCorner, + DoubleCorner, + EdgeWay, + EdgeFill, + ThreeWay, + ThreeWayFill, + EdgeCornerFill, + ThreeCorner, + FourWay, +}; + +/** @brief Stable build semantics resolved from one eight-neighbour autotile mask. */ +struct TilePresetVariant { + TilePresetKind kind = TilePresetKind::None; + /** Clockwise rotation in degrees, restricted to 0, 90, 180 or 270. */ + int rotationDegrees = 0; + /** Mirror the selected tile mesh along its local X axis after rotation. */ + bool mirrorX = false; + /** Original TileWorldCreator 3x3 configuration value, including the center bit. */ + std::uint16_t configuration = 16; +}; + +/** + * @brief Convert EVEngine's N/E/S/W/NE/SE/SW/NW mask to the reference 3x3 configuration. + * @param neighbourMask Eight-neighbour occupancy mask produced by `grid.autotile`. + * @return Nine-bit row-major configuration with the occupied center bit set. + */ +[[nodiscard]] std::uint16_t toTilePresetConfiguration(std::uint8_t neighbourMask) noexcept; + +/** + * @brief Resolve the exact TileWorldCreator 4.3.5 standard-grid category and transform tables. + * @param neighbourMask Eight-neighbour occupancy mask produced by `grid.autotile`. + * @return Deterministic tile category, rotation, mirror flag and reference configuration. + * @note Configuration 350 is intentionally `None`, matching the reference table's unclassified entry. + */ +[[nodiscard]] TilePresetVariant resolveTilePreset(std::uint8_t neighbourMask) noexcept; + +/** @brief Stable lowercase identifier used in mesh build-layer group names. */ +[[nodiscard]] std::string_view tilePresetKindName(TilePresetKind kind) noexcept; + +} // namespace eve::procgen diff --git a/src/modules/procgen/algorithms/RoguelikeGenerator.cpp b/src/modules/procgen/algorithms/RoguelikeGenerator.cpp index 0cf9a37b..74be59fc 100644 --- a/src/modules/procgen/algorithms/RoguelikeGenerator.cpp +++ b/src/modules/procgen/algorithms/RoguelikeGenerator.cpp @@ -734,6 +734,26 @@ bool autotileGridInPlace(Grid2D &grid) { return grid.getWidth() > 0 && grid.getHeight() > 0; } +eve::Result autotileOccupiedGridInPlace(Grid2D &grid) { + if (grid.getWidth() <= 0 || grid.getHeight() <= 0) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "autotile grid dimensions must be positive", {}, {}, + "procgen.gridGraph")); + constexpr int dx[] = {0, 1, 0, -1, 1, 1, -1, -1}; + constexpr int dy[] = {-1, 0, 1, 0, -1, 1, 1, -1}; + for (int y = 0; y < grid.getHeight(); ++y) { + for (int x = 0; x < grid.getWidth(); ++x) { + int mask = 0; + if (uint32_t(grid.getCell(x, y)) != Semantic::Empty) + for (int direction = 0; direction < 8; ++direction) + if (uint32_t(grid.getCell(x + dx[direction], y + dy[direction])) != Semantic::Empty) + mask |= 1 << direction; + grid.setDetail(x, y, mask); + } + } + return eve::Result::success(); +} + uint32_t randomSeedValue() { std::random_device rd; const uint32_t v = rd(); diff --git a/src/modules/procgen/algorithms/RoguelikeGenerator.h b/src/modules/procgen/algorithms/RoguelikeGenerator.h index 5db5a15d..d905db0e 100644 --- a/src/modules/procgen/algorithms/RoguelikeGenerator.h +++ b/src/modules/procgen/algorithms/RoguelikeGenerator.h @@ -1,5 +1,7 @@ #pragma once +#include "common/Result.h" + #include namespace eve::procgen { @@ -17,6 +19,14 @@ void registerRoguelikeGenerator(GeneratorRegistry ®istry); */ bool autotileGridInPlace(Grid2D &grid); +/** + * @brief Write an eight-neighbour occupancy mask into every non-empty cell's detail value. + * @param grid Dense grid mutated in place; empty cells receive detail zero. + * @return Success after writing all masks; InvalidArgument for unusable dimensions. + * @thread Caller-owned grid only; not safe for concurrent mutation. + */ +[[nodiscard]] eve::Result autotileOccupiedGridInPlace(Grid2D &grid); + /** @brief Produce a fresh seed suitable for regenerating a level (never 0). */ uint32_t randomSeedValue(); diff --git a/test/procgen_build_layer_stack.cpp b/test/procgen_build_layer_stack.cpp new file mode 100644 index 00000000..e4d681ee --- /dev/null +++ b/test/procgen_build_layer_stack.cpp @@ -0,0 +1,69 @@ +#include "procgen/BuildLayerStack.h" +#include "procgen/Semantic.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::procgen; + +TEST_CASE("procgen.buildLayers.executesOrderedTileAndObjectDefinitions") { + Grid2D grid; + grid.resize(2, 1); + grid.setCell(0, 0, int(Semantic::Floor)); + grid.setCell(1, 0, int(Semantic::Floor)); + + PointSet points; + points.add(2.f, 0.f, 3.f); + ObjectBuildLayer objects; + REQUIRE(objects.addAsset("props/crate", 1.f).ok()); + objects.setSeed(15); + + BuildLayerStack stack; + REQUIRE(stack.addTileLayer("floor", true, 2.f, 0.5f, "floor").ok()); + REQUIRE(stack.addObjectLayer("props", true, objects).ok()); + REQUIRE(stack.addTileLayer("disabled", false, 1.f, 1.f, "disabled").ok()); + CHECK_EQ(stack.getLayerCount(), 3); + CHECK_EQ(stack.getLayerId(1), "props"); + CHECK_EQ(stack.getLayerType(0), "tiles"); + CHECK(!stack.isLayerEnabled(2)); + + auto result = stack.execute(grid, points); + REQUIRE(result.ok()); + CHECK_EQ(result.value().getCount(), 2); + CHECK_EQ(result.value().getId(0), "floor"); + CHECK_EQ(result.value().getType(0), "mesh"); + CHECK_EQ(result.value().getType(1), "points"); + auto mesh = result.value().getMesh(0); + REQUIRE(mesh.ok()); + CHECK_EQ(mesh.value().getIndexCount(), 48); + auto built = result.value().getPoints(1); + REQUIRE(built.ok()); + CHECK_EQ(built.value().getStringAttribute(0, "asset", ""), "props/crate"); + CHECK(!result.value().getPoints(0).ok()); + CHECK(!result.value().getMesh(1).ok()); +} + +TEST_CASE("procgen.buildLayers.roundTripsAndRejectsDamageAtomically") { + ObjectBuildLayer objects; + REQUIRE(objects.addAsset("props/a", 2.f).ok()); + REQUIRE(objects.addAsset("props/b", 1.f).ok()); + REQUIRE(objects.setPositionRadius(0.4f).ok()); + REQUIRE(objects.addChild("props/child", 2, 1.f, 0.5f, 1.5f, -20.f, 20.f).ok()); + const auto objectDefinition = objects.serializeDefinition(); + ObjectBuildLayer restoredObject; + REQUIRE(restoredObject.deserializeDefinition(objectDefinition).ok()); + CHECK_EQ(restoredObject.serializeDefinition(), objectDefinition); + CHECK(!restoredObject.deserializeDefinition("EVPCG_OBJECT_LAYER 1\nUNKNOWN 1\nEND\n").ok()); + CHECK_EQ(restoredObject.serializeDefinition(), objectDefinition); + + BuildLayerStack stack; + REQUIRE(stack.addObjectLayer("objects", true, objects).ok()); + REQUIRE(stack.addTileLayer("tiles", false, 1.25f, 0.3f, "terrain main").ok()); + const auto definition = stack.serializeDefinition(); + BuildLayerStack restored; + REQUIRE(restored.deserializeDefinition(definition).ok()); + CHECK_EQ(restored.serializeDefinition(), definition); + CHECK(!restored.deserializeDefinition("EVPCG_BUILD_LAYERS 1\nOBJECT \"bad\" 1 xyz\nEND\n").ok()); + CHECK_EQ(restored.serializeDefinition(), definition); + CHECK(!restored.addTileLayer("objects", true, 1.f, 0.f, "duplicate").ok()); + CHECK(!restored.setEnabled("missing", true).ok()); +} diff --git a/test/procgen_grid_graph.cpp b/test/procgen_grid_graph.cpp new file mode 100644 index 00000000..f196a79f --- /dev/null +++ b/test/procgen_grid_graph.cpp @@ -0,0 +1,279 @@ +#include "procgen/GeneratorRegistry.h" +#include "procgen/GridGraph.h" +#include "procgen/MeshGraph.h" +#include "procgen/Params.h" +#include "procgen/PointGraph.h" +#include "procgen/Semantic.h" +#include "procgen/TilePreset.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::procgen; + +TEST_CASE("procgen.tilePreset.matchesReferenceStandardGridTables") { + const auto isolated = resolveTilePreset(0); + CHECK_EQ(isolated.configuration, 16); + CHECK_EQ(isolated.kind, TilePresetKind::Single); + CHECK_EQ(isolated.rotationDegrees, 0); + CHECK(!isolated.mirrorX); + + const auto north = resolveTilePreset(1u << 0u); + CHECK_EQ(north.configuration, 18); + CHECK_EQ(north.kind, TilePresetKind::DeadEnd); + CHECK_EQ(north.rotationDegrees, 90); + + const auto east = resolveTilePreset(1u << 1u); + CHECK_EQ(east.configuration, 48); + CHECK_EQ(east.kind, TilePresetKind::DeadEnd); + CHECK_EQ(east.rotationDegrees, 180); + + const auto asymmetric = resolveTilePreset(139); + CHECK_EQ(asymmetric.configuration, 59); + CHECK_EQ(asymmetric.kind, TilePresetKind::EdgeCornerFill); + CHECK_EQ(asymmetric.rotationDegrees, 90); + CHECK(asymmetric.mirrorX); + + const auto filled = resolveTilePreset(255); + CHECK_EQ(filled.configuration, 511); + CHECK_EQ(filled.kind, TilePresetKind::Fill); + + // The source 4.3.5 table omits configuration 350; preserve that compatibility edge case. + const auto unclassified = resolveTilePreset(121); + CHECK_EQ(unclassified.configuration, 350); + CHECK_EQ(unclassified.kind, TilePresetKind::None); + + for (int mask = 0; mask <= 255; ++mask) { + const auto variant = resolveTilePreset(std::uint8_t(mask)); + CHECK((variant.configuration & 16u) != 0u); + const bool validRotation = variant.rotationDegrees == 0 || variant.rotationDegrees == 90 || + variant.rotationDegrees == 180 || variant.rotationDegrees == 270; + CHECK(validRotation); + } +} + +TEST_CASE("procgen.gridGraph.composesGridAndPointGraphs") { + Grid2D source; + source.resize(5, 5); + source.setCell(2, 2, int(Semantic::Grass)); + + PointGraph points; + REQUIRE(points.addNode("input", "input")); + REQUIRE(points.addNode("move", "transform")); + REQUIRE(points.connect("input", "move")); + REQUIRE(points.setNodeFloat("move", "y", 3.f)); + + GridGraph graph; + REQUIRE(graph.addNode("source", "grid.input").ok()); + REQUIRE(graph.addNode("grow", "grid.expand").ok()); + REQUIRE(graph.addNode("points", "convert.grid_to_points").ok()); + REQUIRE(graph.addNode("point_pass", "point.subgraph").ok()); + REQUIRE(graph.connect("source", "grow").ok()); + REQUIRE(graph.connect("grow", "points").ok()); + REQUIRE(graph.connect("points", "point_pass").ok()); + REQUIRE(graph.setNodeGrid("source", source).ok()); + REQUIRE(graph.setNodeInt("grow", "radius", 1).ok()); + REQUIRE(graph.setNodeFloat("points", "cellSize", 2.f).ok()); + REQUIRE(graph.setNodePointSubgraph("point_pass", points, "input", "move").ok()); + + auto result = graph.execute("point_pass"); + REQUIRE(result.ok()); + const auto* output = std::get_if(&result.value()); + REQUIRE(output != nullptr); + CHECK_EQ(output->getCount(), 9); + CHECK_EQ(output->getY(0), 3.f); + CHECK(output->hasIntAttribute(0, "cell_x")); + CHECK(output->hasIntAttribute(0, "semantic")); + + auto again = graph.execute("point_pass"); + REQUIRE(again.ok()); + CHECK_EQ(std::get(again.value()).getCount(), 9); +} + +TEST_CASE("procgen.gridGraph.rejectsTypeMismatchCyclesAndDimensionMismatch") { + GridGraph graph; + REQUIRE(graph.addNode("a", "grid.input").ok()); + REQUIRE(graph.addNode("b", "grid.input").ok()); + REQUIRE(graph.addNode("merge", "grid.union").ok()); + REQUIRE(graph.addNode("points", "convert.grid_to_points").ok()); + REQUIRE(graph.connect("a", "merge", 0).ok()); + REQUIRE(graph.connect("b", "merge", 1).ok()); + REQUIRE(graph.connect("merge", "points").ok()); + + auto badType = graph.connect("points", "merge", 0); + CHECK(!badType.ok()); + + Grid2D small; + small.resize(2, 2); + Grid2D large; + large.resize(3, 3); + REQUIRE(graph.setNodeGrid("a", small).ok()); + REQUIRE(graph.setNodeGrid("b", large).ok()); + auto mismatch = graph.execute("merge"); + CHECK(!mismatch.ok()); + + GridGraph cycle; + REQUIRE(cycle.addNode("left", "grid.invert").ok()); + REQUIRE(cycle.addNode("right", "grid.invert").ok()); + REQUIRE(cycle.connect("left", "right").ok()); + REQUIRE(cycle.connect("right", "left").ok()); + auto cyclic = cycle.validate("left"); + CHECK(!cyclic.ok()); +} + +TEST_CASE("procgen.gridGraph.coversGeneratorsSelectorsPathAndSerialization") { + GridGraph graph; + REQUIRE(graph.addNode("maze", "generate.maze").ok()); + REQUIRE(graph.setNodeInt("maze", "width", 17).ok()); + REQUIRE(graph.setNodeInt("maze", "height", 17).ok()); + REQUIRE(graph.setNodeInt("maze", "seed", 42).ok()); + REQUIRE(graph.addNode("border", "select.border").ok()); + REQUIRE(graph.connect("maze", "border").ok()); + + auto first = graph.execute("border"); + REQUIRE(first.ok()); + const auto* selected = std::get_if(&first.value()); + REQUIRE(selected != nullptr); + CHECK_EQ(selected->getWidth(), 17); + + GridGraph restored; + REQUIRE(restored.deserializeDefinition(graph.serializeDefinition()).ok()); + auto second = restored.execute("border"); + REQUIRE(second.ok()); + CHECK_EQ(std::get(second.value()).cells(), selected->cells()); + + Grid2D start; + start.resize(17, 17); + start.setCell(1, 1, int(Semantic::Floor)); + Grid2D target; + target.resize(17, 17); + target.setCell(15, 15, int(Semantic::Floor)); + REQUIRE(restored.addNode("start", "grid.input").ok()); + REQUIRE(restored.addNode("target", "grid.input").ok()); + REQUIRE(restored.addNode("path", "grid.path").ok()); + REQUIRE(restored.setNodeGrid("start", start).ok()); + REQUIRE(restored.setNodeGrid("target", target).ok()); + REQUIRE(restored.connect("maze", "path", 0).ok()); + REQUIRE(restored.connect("start", "path", 1).ok()); + REQUIRE(restored.connect("target", "path", 2).ok()); + CHECK(restored.execute("path").ok()); +} + +TEST_CASE("procgen.gridGraph.reusesRegisteredRoguelikeAndExtractsSemanticLayers") { + GeneratorRegistry::instance().registerBuiltins(); + for (const int seed : {7, 42, 436}) { + Params params; + params.setSize(31, 23); + params.setSeed(std::uint32_t(seed)); + params.setInt("roomCount", 9); + params.setString("layoutStyle", "clustered"); + params.setString("connectionStyle", "nearest"); + params.setString("corridorStyle", "l"); + + Grid2D expected; + std::string error; + REQUIRE(GeneratorRegistry::instance().generate("level.roguelike", params, expected, error)); + + GridGraph graph; + REQUIRE(graph.addNode("level", "generate.registry").ok()); + REQUIRE(graph.setNodeString("level", "algorithm", "level.roguelike").ok()); + REQUIRE(graph.setNodeInt("level", "width", 31).ok()); + REQUIRE(graph.setNodeInt("level", "height", 23).ok()); + REQUIRE(graph.setNodeInt("level", "seed", seed).ok()); + REQUIRE(graph.setNodeInt("level", "roomCount", 9).ok()); + REQUIRE(graph.setNodeString("level", "layoutStyle", "clustered").ok()); + REQUIRE(graph.setNodeString("level", "connectionStyle", "nearest").ok()); + REQUIRE(graph.setNodeString("level", "corridorStyle", "l").ok()); + REQUIRE(graph.addNode("floor", "select.semantic").ok()); + REQUIRE(graph.addNode("corridor", "select.semantic").ok()); + REQUIRE(graph.setNodeInt("floor", "semantic", int(Semantic::Floor)).ok()); + REQUIRE(graph.setNodeInt("corridor", "semantic", int(Semantic::Corridor)).ok()); + REQUIRE(graph.connect("level", "floor").ok()); + REQUIRE(graph.connect("level", "corridor").ok()); + + auto generated = graph.execute("level"); + REQUIRE(generated.ok()); + const auto& actual = std::get(generated.value()); + CHECK_EQ(actual.cells(), expected.cells()); + CHECK_EQ(actual.detail(), expected.detail()); + CHECK_EQ(actual.getObjectCount(), expected.getObjectCount()); + + auto floor = graph.execute("floor"); + auto corridor = graph.execute("corridor"); + REQUIRE(floor.ok()); + REQUIRE(corridor.ok()); + int selected = 0; + for (const auto cell : std::get(floor.value()).cells()) { + const bool valid = cell == Semantic::Empty || cell == Semantic::Floor; + CHECK(valid); + if (cell == Semantic::Floor) ++selected; + } + for (const auto cell : std::get(corridor.value()).cells()) { + const bool valid = cell == Semantic::Empty || cell == Semantic::Corridor; + CHECK(valid); + if (cell == Semantic::Corridor) ++selected; + } + CHECK(selected > 0); + + GridGraph restored; + REQUIRE(restored.deserializeDefinition(graph.serializeDefinition()).ok()); + auto restoredLevel = restored.execute("level"); + REQUIRE(restoredLevel.ok()); + CHECK_EQ(std::get(restoredLevel.value()).cells(), expected.cells()); + } + + GridGraph missing; + REQUIRE(missing.addNode("level", "generate.registry").ok()); + CHECK(!missing.execute("level").ok()); + REQUIRE(missing.setNodeString("level", "algorithm", "missing.generator").ok()); + CHECK(!missing.execute("level").ok()); +} + +TEST_CASE("procgen.meshGraph.composesGridPointAndMeshDomains") { + Grid2D grid; + grid.resize(2, 1); + grid.setCell(0, 0, int(Semantic::Floor)); + grid.setCell(1, 0, int(Semantic::Floor)); + GridGraph autotile; + REQUIRE(autotile.addNode("grid", "grid.input").ok()); + REQUIRE(autotile.addNode("autotile", "grid.autotile").ok()); + REQUIRE(autotile.setNodeGrid("grid", grid).ok()); + REQUIRE(autotile.connect("grid", "autotile").ok()); + auto autotiled = autotile.execute("autotile"); + REQUIRE(autotiled.ok()); + grid = std::get(autotiled.value()); + + MeshGraph tiles; + REQUIRE(tiles.addNode("grid", "grid.input").ok()); + REQUIRE(tiles.addNode("tiles", "mesh.grid_tiles").ok()); + REQUIRE(tiles.setNodeGrid("grid", grid).ok()); + REQUIRE(tiles.connect("grid", "tiles").ok()); + auto tiled = tiles.execute("tiles"); + REQUIRE(tiled.ok()); + CHECK_EQ(tiled.value().getIndexCount(), 48); + CHECK_EQ(tiled.value().getGroupCount(), 2); + CHECK(tiled.value().getGroupName(0).find("/dead_end/r180/normal/48") != std::string::npos); + CHECK(tiled.value().getGroupName(1).find("/dead_end/r0/normal/24") != std::string::npos); + + MeshGraph restoredTiles; + REQUIRE(restoredTiles.deserializeDefinition(tiles.serializeDefinition()).ok()); + REQUIRE(restoredTiles.setNodeGrid("grid", grid).ok()); + auto restoredMesh = restoredTiles.execute("tiles"); + REQUIRE(restoredMesh.ok()); + CHECK_EQ(restoredMesh.value().getIndexCount(), tiled.value().getIndexCount()); + CHECK(!restoredTiles.setNodeFloat("tiles", "misspelled", 1.f).ok()); + + PointSet points; + points.add(0.f, 0.f, 0.f); + points.add(4.f, 0.f, 0.f); + MeshGraph instances; + REQUIRE(instances.addNode("mesh", "mesh.input").ok()); + REQUIRE(instances.addNode("points", "point.input").ok()); + REQUIRE(instances.addNode("instances", "mesh.instance_points").ok()); + REQUIRE(instances.setNodeMesh("mesh", tiled.value()).ok()); + REQUIRE(instances.setNodePoints("points", points).ok()); + REQUIRE(instances.connect("mesh", "instances", 0).ok()); + REQUIRE(instances.connect("points", "instances", 1).ok()); + auto result = instances.execute("instances"); + REQUIRE(result.ok()); + CHECK_EQ(result.value().getIndexCount(), tiled.value().getIndexCount() * 2); +} diff --git a/test/procgen_incremental_build.cpp b/test/procgen_incremental_build.cpp new file mode 100644 index 00000000..ae9f41dd --- /dev/null +++ b/test/procgen_incremental_build.cpp @@ -0,0 +1,118 @@ +#include "procgen/IncrementalBuild.h" +#include "procgen/Semantic.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::procgen; + +namespace { + +bool containsChange(const IncrementalBuildDelta& delta, int x, int z, bool removed) { + for (int index = 0; index < delta.getCount(); ++index) + if (delta.getClusterX(index) == x && delta.getClusterZ(index) == z && delta.isRemoved(index) == removed) + return true; + return false; +} + +} // namespace + +TEST_CASE("procgen.incrementalBuild.rebuildsOnlyDirtyClustersAndEmitsRemovals") { + Grid2D grid; + grid.resize(8, 2); + grid.setCell(1, 0, int(Semantic::Floor)); + grid.setCell(5, 0, int(Semantic::Floor)); + PointSet points; + const int leftPoint = points.add(1.5f, 0.f, 0.5f); + const int rightPoint = points.add(5.5f, 0.f, 0.5f); + + ObjectBuildLayer objects; + REQUIRE(objects.addAsset("props/crate", 1.f).ok()); + BuildLayerStack stack; + REQUIRE(stack.addTileLayer("floor", true, 1.f, 0.f, "floor").ok()); + REQUIRE(stack.addObjectLayer("props", true, objects).ok()); + IncrementalBuildExecutor incremental; + auto first = incremental.update(stack, grid, points, 4, 1.f); + REQUIRE(first.ok()); + CHECK_EQ(first.value().getCount(), 2); + CHECK_EQ(incremental.getCachedClusterCount(), 2); + CHECK(containsChange(first.value(), 0, 0, false)); + CHECK(containsChange(first.value(), 1, 0, false)); + CHECK(incremental.getCachedArtifacts(0, 0).ok()); + + auto unchanged = incremental.update(stack, grid, points, 4, 1.f); + REQUIRE(unchanged.ok()); + CHECK_EQ(unchanged.value().getCount(), 0); + + grid.setDetail(1, 0, 7); + auto interior = incremental.update(stack, grid, points, 4, 1.f); + REQUIRE(interior.ok()); + CHECK_EQ(interior.value().getCount(), 1); + CHECK(containsChange(interior.value(), 0, 0, false)); + + grid.setCell(3, 0, int(Semantic::Floor)); + auto boundary = incremental.update(stack, grid, points, 4, 1.f); + REQUIRE(boundary.ok()); + CHECK_EQ(boundary.value().getCount(), 2); + CHECK(containsChange(boundary.value(), 0, 0, false)); + CHECK(containsChange(boundary.value(), 1, 0, false)); + + points.setPosition(rightPoint, 6.5f, 0.f, 0.5f); + auto pointEdit = incremental.update(stack, grid, points, 4, 1.f); + REQUIRE(pointEdit.ok()); + CHECK_EQ(pointEdit.value().getCount(), 1); + CHECK(containsChange(pointEdit.value(), 1, 0, false)); + + grid.setCell(1, 0, int(Semantic::Empty)); + grid.setCell(3, 0, int(Semantic::Empty)); + points.setPosition(leftPoint, 5.75f, 0.f, 0.5f); + auto removed = incremental.update(stack, grid, points, 4, 1.f); + REQUIRE(removed.ok()); + CHECK(containsChange(removed.value(), 0, 0, true)); + CHECK_EQ(incremental.getCachedClusterCount(), 1); + CHECK(!incremental.getCachedArtifacts(0, 0).ok()); +} + +TEST_CASE("procgen.incrementalBuild.partitionsGlobalMeshAndPreservesCacheOnFailure") { + Grid2D grid; + grid.resize(8, 1); + for (int x = 0; x < 8; ++x) grid.setCell(x, 0, int(Semantic::Floor)); + PointSet points; + points.add(1.f, 0.f, 0.f); + + ObjectBuildLayer objects; + REQUIRE(objects.addAsset("props/crate", 1.f).ok()); + BuildLayerStack stack; + REQUIRE(stack.addTileLayer("floor", true, 1.f, 0.f, "floor").ok()); + REQUIRE(stack.addObjectLayer("props", true, objects).ok()); + IncrementalBuildExecutor incremental; + auto delta = incremental.update(stack, grid, points, 4, 1.f); + REQUIRE(delta.ok()); + REQUIRE_EQ(delta.value().getCount(), 2); + auto leftArtifacts = incremental.getCachedArtifacts(0, 0); + auto rightArtifacts = incremental.getCachedArtifacts(1, 0); + REQUIRE(leftArtifacts.ok()); + REQUIRE(rightArtifacts.ok()); + auto leftMesh = leftArtifacts.value().getMesh(0); + auto rightMesh = rightArtifacts.value().getMesh(0); + REQUIRE(leftMesh.ok()); + REQUIRE(rightMesh.ok()); + auto full = stack.execute(grid, points); + REQUIRE(full.ok()); + auto fullMesh = full.value().getMesh(0); + REQUIRE(fullMesh.ok()); + CHECK_EQ(leftMesh.value().getIndexCount() + rightMesh.value().getIndexCount(), fullMesh.value().getIndexCount()); + CHECK(leftMesh.value().getPositionX(0) < 4.f); + CHECK(rightMesh.value().getPositionX(0) >= 4.f); + + ObjectBuildLayer oriented; + REQUIRE(oriented.addAsset("props/oriented", 1.f).ok()); + REQUIRE(oriented.setOrientation(1.f, 0.f, false).ok()); + BuildLayerStack failingStack; + REQUIRE(failingStack.addObjectLayer("oriented", true, oriented).ok()); + auto failed = incremental.update(failingStack, grid, points, 4, 1.f); + CHECK(!failed.ok()); + CHECK_EQ(incremental.getCachedClusterCount(), 2); + CHECK(incremental.getCachedArtifacts(0, 0).ok()); + CHECK(!incremental.update(stack, grid, points, 0, 1.f).ok()); + CHECK(!incremental.update(stack, grid, points, 4, 0.f).ok()); +} diff --git a/test/procgen_object_build_layer.cpp b/test/procgen_object_build_layer.cpp new file mode 100644 index 00000000..27c61b86 --- /dev/null +++ b/test/procgen_object_build_layer.cpp @@ -0,0 +1,74 @@ +#include "procgen/ObjectBuildLayer.h" +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include + +using namespace eve::procgen; + +TEST_CASE("procgen.objectBuild.weightedTransformsOrientationChildrenAndPlacement") { + PointSet source; + for (int index = 0; index < 32; ++index) { + const int point = source.add(float(index), 2.f, 0.f); + REQUIRE(source.trySetPointId(point, std::uint64_t(index + 1)).ok()); + REQUIRE(source.trySetFloatAttribute(point, "surface_highest_y", 10.f + float(index)).ok()); + } + PointSet orientation; + orientation.add(0.f, 0.f, 1.f); // North has priority for the first source point. + + ObjectBuildLayer layer; + REQUIRE(layer.addAsset("tree/oak", 3.f).ok()); + REQUIRE(layer.addAsset("tree/birch", 1.f).ok()); + layer.setSeed(77); + layer.setLayerOffset(1.f, 2.f, 3.f); + REQUIRE(layer.setLayerScale(2.f, 3.f, 4.f).ok()); + REQUIRE(layer.setRandomRotation(5.f, 5.f, 7.f, 7.f, 9.f, 9.f).ok()); + REQUIRE(layer.setRandomScale(0.5f, 0.5f, 0.5f, 0.5f, 0.5f, 0.5f, true).ok()); + REQUIRE(layer.setOrientation(1.f, 10.f, false).ok()); + layer.setPlaceOnTop(true, false, 0.25f); + REQUIRE(layer.addChild("grass/tuft", 2, 0.5f, 0.8f, 1.2f, -30.f, 30.f).ok()); + + auto first = layer.build(source, &orientation); + auto again = layer.build(source, &orientation); + REQUIRE(first.ok()); + REQUIRE(again.ok()); + CHECK_EQ(first.value().getCount(), 96); + CHECK_EQ(again.value().getCount(), first.value().getCount()); + + std::set selectedAssets; + for (int index = 0; index < first.value().getCount(); ++index) { + CHECK_EQ(first.value().getX(index), again.value().getX(index)); + CHECK_EQ(first.value().getY(index), again.value().getY(index)); + CHECK_EQ(first.value().getZ(index), again.value().getZ(index)); + CHECK_EQ(first.value().getYaw(index), again.value().getYaw(index)); + CHECK_EQ(first.value().getStringAttribute(index, "asset", ""), + again.value().getStringAttribute(index, "asset", "")); + const auto role = first.value().getStringAttribute(index, "object_role", ""); + if (role == "parent") selectedAssets.insert(first.value().getStringAttribute(index, "asset", "")); + } + CHECK_EQ(selectedAssets.size(), 2); + + CHECK_EQ(first.value().getStringAttribute(0, "object_role", ""), "parent"); + CHECK_EQ(first.value().getYaw(0), 287.f); // North 270 + offset 10 + fixed random yaw 7. + CHECK_EQ(first.value().getPitch(0), 5.f); + CHECK_EQ(first.value().getRoll(0), 9.f); + CHECK_EQ(first.value().getScaleX(0), 1.f); + CHECK_EQ(first.value().getScaleY(0), 1.5f); + CHECK_EQ(first.value().getScaleZ(0), 2.f); + CHECK_EQ(first.value().getY(0), 12.25f); // surface 10 + top 0.25 + layer Y 2. + CHECK_EQ(first.value().getStringAttribute(1, "object_role", ""), "child"); + CHECK_EQ(first.value().getIntAttribute(1, "parent_index", -2), 0); +} + +TEST_CASE("procgen.objectBuild.rejectsInvalidConfigurationWithoutPartialOutput") { + PointSet source; + source.add(0.f, 0.f, 0.f); + ObjectBuildLayer layer; + CHECK(!layer.build(source).ok()); + CHECK(!layer.addAsset("", 1.f).ok()); + CHECK(!layer.addAsset("tree", 0.f).ok()); + CHECK(!layer.setLayerScale(1.f, 0.f, 1.f).ok()); + CHECK(!layer.setPositionRadius(-1.f).ok()); + CHECK(!layer.setOrientation(0.f, 0.f, false).ok()); + CHECK(!layer.addChild("grass", -1, 1.f, 1.f, 1.f, 0.f, 0.f).ok()); +} diff --git a/test/procgen_result_contract.cpp b/test/procgen_result_contract.cpp index eadfdf61..e0c81fa5 100644 --- a/test/procgen_result_contract.cpp +++ b/test/procgen_result_contract.cpp @@ -172,8 +172,73 @@ TEST_CASE("procgen.result.squirrelExecutionAndSchedulerShareProjection") { assert(graph.validateResult().ok); local result = graph.executeResult("source"); assert(result.ok && result.hasValue && result.value.getCount() == 1); + local objectLayer = eve.ProcgenObjectBuildLayer(); + assert(objectLayer.addAsset("props/rock", 1.0).ok); + objectLayer.setSeed(99); + assert(objectLayer.setRandomScale(0.8, 1.2, 0.8, 1.2, 0.8, 1.2, true).ok); + assert(objectLayer.addChild("props/rubble", 2, 0.5, 0.2, 0.4, -180.0, 180.0).ok); + local builtObjects = objectLayer.build(result.value); + assert(builtObjects.ok && builtObjects.value.getCount() == 3); + assert(builtObjects.value.getStringAttribute(0, "asset", "") == "props/rock"); + assert(builtObjects.value.getStringAttribute(1, "object_role", "") == "child"); + assert(builtObjects.value.getIntAttribute(1, "parent_index", -2) == 0); + local buildStack = eve.ProcgenBuildLayerStack(); + assert(buildStack.addTileLayer("tiles", true, 1.0, 0.25, "script").ok); + assert(buildStack.addObjectLayer("objects", true, objectLayer).ok); + local stackDefinition = buildStack.serializeDefinition(); + local restoredStack = eve.ProcgenBuildLayerStack(); + assert(restoredStack.deserializeDefinition(stackDefinition).ok); + local stackGrid = procgen.newGrid(1, 1).value; + assert(stackGrid.setCell(0, 0, 2).ok); + local stackResult = restoredStack.execute(stackGrid, result.value); + assert(stackResult.ok && stackResult.value.getCount() == 2); + assert(stackResult.value.getType(0) == "mesh" && stackResult.value.getMesh(0).ok); + assert(stackResult.value.getType(1) == "points" && stackResult.value.getPoints(1).ok); + local incremental = eve.ProcgenIncrementalBuildExecutor(); + local firstDelta = incremental.update(restoredStack, stackGrid, result.value, 1, 1.0); + assert(firstDelta.ok && firstDelta.value.getCount() == 1); + assert(firstDelta.value.getClusterX(0) == 0 && !firstDelta.value.isRemoved(0)); + assert(firstDelta.value.getArtifacts(0).ok); + local stableDelta = incremental.update(restoredStack, stackGrid, result.value, 1, 1.0); + assert(stableDelta.ok && stableDelta.value.getCount() == 0); + assert(incremental.getCachedClusterCount() == 1); + assert(incremental.getCachedArtifacts(0, 0).ok); local missing = graph.executeResult("missing"); assert(!missing.ok && missing.value == null && missing.diagnostics[0].path == "missing"); + local gridGraph = procgen.newGridGraph().value; + assert(gridGraph.addNode("noise", "generate.random_noise").ok); + assert(gridGraph.setNodeInt("noise", "width", 4).ok); + assert(gridGraph.setNodeInt("noise", "height", 4).ok); + assert(gridGraph.setNodeInt("noise", "seed", 7).ok); + assert(gridGraph.setNodeFloat("noise", "x", 1.0).ok); + local generatedGrid = gridGraph.execute("noise"); + assert(generatedGrid.ok && generatedGrid.value.getWidth() == 4); + local ownedGrid = procgen.newGrid(3, 2); + assert(ownedGrid.ok && ownedGrid.value.fill(0).ok); + assert(ownedGrid.value.setCell(1, 1, 1).ok); + local ownedInputGraph = procgen.newGridGraph().value; + assert(ownedInputGraph.addNode("input", "grid.input").ok); + assert(ownedInputGraph.setNodeGrid("input", ownedGrid.value).ok); + local ownedGridCopy = ownedInputGraph.execute("input"); + assert(ownedGridCopy.ok && ownedGridCopy.value.getCell(1, 1) == 1); + local registryGraph = procgen.newGridGraph().value; + assert(registryGraph.addNode("level", "generate.registry").ok); + assert(registryGraph.setNodeString("level", "algorithm", "level.roguelike").ok); + assert(registryGraph.setNodeInt("level", "width", 25).ok); + assert(registryGraph.setNodeInt("level", "height", 19).ok); + assert(registryGraph.setNodeInt("level", "seed", 435).ok); + assert(registryGraph.addNode("floors", "select.semantic").ok); + assert(registryGraph.setNodeInt("floors", "semantic", 2).ok); + assert(registryGraph.connect("level", "floors", 0).ok); + local semanticFloors = registryGraph.execute("floors"); + assert(semanticFloors.ok && semanticFloors.value.getWidth() == 25); + local meshGraph = procgen.newMeshGraph().value; + assert(meshGraph.addNode("grid", "grid.input").ok); + assert(meshGraph.addNode("tiles", "mesh.grid_tiles").ok); + assert(meshGraph.setNodeGrid("grid", generatedGrid.value).ok); + assert(meshGraph.connect("grid", "tiles", 0).ok); + local generatedMesh = meshGraph.execute("tiles"); + assert(generatedMesh.ok && generatedMesh.value.getIndexCount() > 0); local runtime = procgen.newRuntimeGeneration(19).value; local empty = runtime.nextCleanupRequest(); assert(empty.ok && empty.value == null);