From c61bf922460e0a1f80c44fe60763fddbbea7c71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E9=A3=8E=E9=80=8D=E9=81=A5=E6=B8=B8?= Date: Tue, 15 Sep 2026 07:25:24 +0800 Subject: [PATCH] feat(archspace): interactive tools, catalog furniture, parametric openings (#395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(archspace): add Pascal-like architectural space editor domain Introduce archspace as a document model separate from RTS building and housegen, with editing DomainOperations, automation commands, mesh bake, tests, and a thin example host. Co-authored-by: 西风逍遥游 * feat(archspace): make document editing and viewport bake path usable Complete wall/opening/item helpers, opening cutout mesh bake, property undo inverses, automation commands, and an example that uploads the baked mesh and captures a screenshot as evidence the domain is more than a shell. Co-authored-by: 西风逍遥游 * docs(archspace): document ArchSpaceDocument script bindings Source quality CI failed because newly bound ArchSpaceDocument methods were missing from docs/usr/modules/archspace.md. Add an API quick reference that covers every addFunc name. Co-authored-by: 西风逍遥游 * feat(archspace): bake catalog furniture and parametric opening reveals Replace item marker boxes with multi-part catalog meshes, and carve wall openings with merged cavities plus explicit reveal liners (not CSG/IFC). Co-authored-by: 西风逍遥游 * feat(archspace): add wall, opening, and item placement editor tools Interactive tools preview on move and commit DomainOperations through IEditAuthority on pointer up, matching the building editor tool pattern. Co-authored-by: 西风逍遥游 * test(archspace): cover interactive tools, catalog bake, and reveals Add authority-routed tool tests, assert reveal/catalog primitives, and document the honest parametric opening + furniture catalog limits. Co-authored-by: 西风逍遥游 --------- Co-authored-by: Cursor Agent --- cmake/module_manifest/orchestration.cmake | 7 + .../rendering_simulation.cmake | 3 + docs/dev/2026-09-14-archspace-editor.md | 92 +++ docs/usr/MODULES.md | 1 + docs/usr/modules/archspace.md | 105 +++ examples/archspace-editor/README.md | 18 + examples/archspace-editor/config.nut | 8 + examples/archspace-editor/main.nut | 83 +++ src/modules/archspace/ArchSpace.cpp | 121 ++++ src/modules/archspace/ArchSpace.h | 13 + src/modules/archspace/ArchSpaceCatalog.cpp | 221 ++++++ src/modules/archspace/ArchSpaceCatalog.h | 56 ++ src/modules/archspace/ArchSpaceDocument.cpp | 403 +++++++++++ src/modules/archspace/ArchSpaceDocument.h | 120 ++++ src/modules/archspace/ArchSpaceTypes.h | 190 ++++++ .../editing/ArchSpaceEditingCommands.cpp | 210 ++++++ .../editing/ArchSpaceEditingCommands.h | 10 + .../archspace/editing/ArchSpaceTarget.cpp | 627 ++++++++++++++++++ .../archspace/editing/ArchSpaceTarget.h | 137 ++++ .../archspace/editor/ArchSpaceEditorModule.h | 28 + .../archspace/editor/ArchSpaceTools.cpp | 338 ++++++++++ .../archspace/editor/EditorArchSpaceTarget.h | 10 + .../archspace/editor/EditorArchSpaceTools.h | 176 +++++ .../archspace/editor/archspace_editor.cpp | 70 ++ test/editor_archspace_target.cpp | 184 +++++ test/editor_archspace_tools.cpp | 225 +++++++ 26 files changed, 3456 insertions(+) create mode 100644 docs/dev/2026-09-14-archspace-editor.md create mode 100644 docs/usr/modules/archspace.md create mode 100644 examples/archspace-editor/README.md create mode 100644 examples/archspace-editor/config.nut create mode 100644 examples/archspace-editor/main.nut create mode 100644 src/modules/archspace/ArchSpace.cpp create mode 100644 src/modules/archspace/ArchSpace.h create mode 100644 src/modules/archspace/ArchSpaceCatalog.cpp create mode 100644 src/modules/archspace/ArchSpaceCatalog.h create mode 100644 src/modules/archspace/ArchSpaceDocument.cpp create mode 100644 src/modules/archspace/ArchSpaceDocument.h create mode 100644 src/modules/archspace/ArchSpaceTypes.h create mode 100644 src/modules/archspace/editing/ArchSpaceEditingCommands.cpp create mode 100644 src/modules/archspace/editing/ArchSpaceEditingCommands.h create mode 100644 src/modules/archspace/editing/ArchSpaceTarget.cpp create mode 100644 src/modules/archspace/editing/ArchSpaceTarget.h create mode 100644 src/modules/archspace/editor/ArchSpaceEditorModule.h create mode 100644 src/modules/archspace/editor/ArchSpaceTools.cpp create mode 100644 src/modules/archspace/editor/EditorArchSpaceTarget.h create mode 100644 src/modules/archspace/editor/EditorArchSpaceTools.h create mode 100644 src/modules/archspace/editor/archspace_editor.cpp create mode 100644 test/editor_archspace_target.cpp create mode 100644 test/editor_archspace_tools.cpp diff --git a/cmake/module_manifest/orchestration.cmake b/cmake/module_manifest/orchestration.cmake index e3d5ec2e..8729c3ac 100644 --- a/cmake/module_manifest/orchestration.cmake +++ b/cmake/module_manifest/orchestration.cmake @@ -37,6 +37,9 @@ eve_declare_module(NAME housegen_editing LAYER 5 DEPS editing OPTIONAL_DEPS housegen GROUP 3d) +eve_declare_module(NAME archspace_editing LAYER 5 + DEPS archspace editing + GROUP 3d) eve_declare_module(NAME camera_editing LAYER 5 DEPS camera editing GROUP 3d web) @@ -150,6 +153,10 @@ eve_declare_module(NAME graphics_editor LAYER 7 SCRIPT GraphicsEditorModule SLOT eve_declare_module(NAME fluids_editor LAYER 7 DEPS editor fluids fluids_editing graphics_editor GROUP 3d web) eve_declare_module(NAME hd2d_editor LAYER 7 DEPS editor hd2d_editing GROUP 3d) eve_declare_module(NAME housegen_editor LAYER 7 DEPS domain_gizmo_editor editor housegen_editing GROUP 3d) +eve_declare_module(NAME archspace_editor LAYER 7 + DEPS archspace_editing editor + SCRIPT ArchSpaceEditorModule SLOT archspaceEditor + GROUP 3d) eve_declare_module(NAME input_editor LAYER 7 DEPS editor input_editing GROUP 2d 3d web) eve_declare_module(NAME lighting_editor LAYER 7 DEPS editor lighting_editing GROUP 3d web) eve_declare_module(NAME localization_editor LAYER 7 DEPS editor localization_editing GROUP 2d 3d web) diff --git a/cmake/module_manifest/rendering_simulation.cmake b/cmake/module_manifest/rendering_simulation.cmake index 5ac00c0f..3159f75d 100644 --- a/cmake/module_manifest/rendering_simulation.cmake +++ b/cmake/module_manifest/rendering_simulation.cmake @@ -123,6 +123,9 @@ eve_declare_module(NAME spritestack LIB EVSpriteStack LAYER 4 SCRIPT SpriteStack eve_declare_module(NAME housegen LIB EVHouseGen LAYER 4 SCRIPT HouseGen DEPS data graphics image model3d GROUP 3d) +eve_declare_module(NAME archspace LIB EVArchSpace LAYER 4 SCRIPT ArchSpace SLOT archspace + DEPS data + GROUP 3d) eve_declare_module(NAME card LAYER 4 SCRIPT Card DEPS attributes decision definitions effects graphics transaction) eve_declare_module(NAME demo LAYER 4 SCRIPT Demo diff --git a/docs/dev/2026-09-14-archspace-editor.md b/docs/dev/2026-09-14-archspace-editor.md new file mode 100644 index 00000000..e5c4ea51 --- /dev/null +++ b/docs/dev/2026-09-14-archspace-editor.md @@ -0,0 +1,92 @@ +# ArchSpace 建筑空间编辑器 + +日期:2026-09-14 +状态:v1 落地(文档模型 + editing target + 自动化命令 + 预览网格) +参考:[pascalorg/editor](https://github.com/pascalorg/editor) + +## 1. 决策 + +EVEngine **不**把 RTS `building` 放置域扩成建筑 CAD,也不把 `housegen` 程序化拼装当成交互式楼层编辑器。 + +新增独立领域 **`archspace`(建筑空间)**,对标 Pascal 的局部优先建筑场景模型: + +| Pascal | ArchSpace | +|--------|-----------| +| Site / Building / Level | `site` / `building` / `level` | +| Wall / Slab / Zone / Item | `wall` / `slab` / `zone` / `item` | +| Door / Window on wall | `opening`(挂在 `wall` 下) | +| Select / Wall / Zone / Item / Slab tools | `IEditorTool` + 领域命令;宿主自绘工具栏 | +| MCP scene tools | `archspace.*.v1` EditingCommand + `eve_editor_*` | +| Plugin node kinds | v1 内置 kinds;后续用 registry 扩展 | + +产品边界与现有编辑器策略一致:引擎提供 **UI 无关的文档、事务、选择、预览与自动化命令**;项目用 `editor` / HUD / MCP host 组合自己的壳。 + +## 2. 模块切分 + +```text +archspace 运行时文档与几何(无 UI) +archspace_editing ArchSpaceDocumentTarget、校验、DomainOperation、命令规划 +archspace_editor Automation target factory、命令注册、脚本 facade +``` + +依赖方向:`archspace` ← `archspace_editing` ← `archspace_editor` ← 项目宿主。 +`runtime-3d` 可只留 `archspace`;authoring profile 再拉 editing/editor。 + +明确 **不是**: + +- `building`:格子占用 / 鬼影 / 校验策略的经营放置; +- `housegen`:组件库 + 种子请求 → 布局实例; +- `scene`:通用场景树 TRS。 + +## 3. 文档模型 + +扁平 `nodes: id → node`,父子用 `parentId` + `children[]`(与 Pascal 相同)。 + +```text +site + └── building + └── level + ├── wall → opening* + ├── slab + ├── zone + └── item +``` + +坐标:右手系,**XZ 为楼层平面,Y 向上**,单位米。墙用 level 局部 `start/end`;slab/zone 用多边形;item 用位置+yaw;opening 用墙参数 `t∈[0,1]`、宽高、底标高。 + +权威状态在 `ArchSpaceDocument` / `ArchSpaceDocumentTarget`。渲染网格是可重建投影(`ArchSpaceMeshBake`),不是第二真相。 + +## 4. 编辑契约 + +- 稳定 target id + revision; +- 变更一律 `DomainOperation`(`archspace.document.replace.v1`),带 inverse; +- `createRoom` 等高层 API 一次事务写入墙环 + slab + zone; +- `validate()` 检查父子 kind、重 id、非有限数、退化几何、opening 越界; +- snapshot schema `eve.archspace.document` / version 1; +- gizmo:墙线段、zone 折线、item 点、opening 标记; +- mesh bake:墙挤出盒 + slab 扇形三角化,供任意 viewport 上传。 + +## 5. 自动化 / Agent + +| 命令 id | 作用 | +|---------|------| +| `archspace.bootstrap.v1` | site→building→level 骨架 | +| `archspace.room.create.v1` | 矩形或多边形房间 | +| `archspace.wall.create.v1` | 单段墙 | +| `archspace.opening.create.v1` | 门/窗 | +| `archspace.item.place.v1` | 放置物件 | +| `archspace.node.delete.v1` | 级联删除 | +| `archspace.property.set.v1` | Inspector 属性 | + +MCP / Agent 路径:`eve_editor_target_create` type=`archspace` → `eve_editor_execute` / observe → `eve_screenshot`。不新增绕过 editing 事务的旁路写入。 + +## 6. v1 非目标 + +- IFC 导入、CSG 开洞真几何、屋顶/楼梯系统、材质槽位库、浏览器 WebGPU 宿主、独立 CLI 发行包。 +这些可在文档模型稳定后按 Pascal 能力增量叠加,且不得回流进 `building`/`housegen`。 + +## 7. 验收 + +1. 单元测试:房间创建、opening、属性撤销、非法 snapshot 原子拒绝、mesh bake 三角形数; +2. `examples/archspace-editor`:脚本组装工作区并打印文档摘要; +3. architecture contracts / module depgraph / 新增公共 API 的 Result+Doxygen。 diff --git a/docs/usr/MODULES.md b/docs/usr/MODULES.md index ab66b50f..e1305415 100644 --- a/docs/usr/MODULES.md +++ b/docs/usr/MODULES.md @@ -53,6 +53,7 @@ - [建筑放置](modules/building.md):策略 / 经营类建筑定义、格子占用、鬼影预览与可插拔校验/吸附。 - [建筑可视化](modules/buildingfx.md):把放置世界和鬼影同步为 2D/3D 视觉并绘制放置网格。 - [程序化房屋](modules/housegen.md):数据驱动的房屋生成(组件库 + 请求 → 布局 JSON)。 +- [建筑空间 ArchSpace](modules/archspace.md):Pascal 风格的楼层/墙/房间/开口文档编辑(与 building 放置域分离)。 - [程序化生成](modules/procgen.md):按算法名和 Params 生成网格、地图层、图像、法线图或 GPU 纹理。 - [武器系统](modules/weapon.md):数据驱动的武器、弹药池、挂点、瞄准、开火、装填与事件查询。 - [载具系统](modules/vehicle.md):载具定义、移动命令、座位和炮塔、伤害以及 2D/3D 物理接入。 diff --git a/docs/usr/modules/archspace.md b/docs/usr/modules/archspace.md new file mode 100644 index 00000000..13aeecbc --- /dev/null +++ b/docs/usr/modules/archspace.md @@ -0,0 +1,105 @@ +# ArchSpace 建筑空间编辑器 + +**脚本入口:** `eve.ArchSpace()` / `eve.ArchSpaceDocument()` / 模块槽 `archspace`、 +`archspaceEditor` + +ArchSpace 是对标 [Pascal Editor](https://github.com/pascalorg/editor) 的**建筑空间文档域**,与 RTS +`building` 放置、以及 `housegen` 程序化拼装完全分开。 + +引擎提供 UI 无关的: + +- 文档模型:`site → building → level → wall/slab/zone/item`,`opening` 挂在墙上; +- 可逆 DomainOperation(`archspace.document.replace.v1`); +- 高层编辑命令:`archspace.bootstrap.v1`、`archspace.room.create.v1`、 + `archspace.wall.create.v1`、`archspace.opening.create.v1`、`archspace.item.place.v1`、 + `archspace.property.set.v1`、`archspace.node.delete.v1`; +- Gizmo 叠加与 CPU mesh bake(墙盒含参数化洞口 + reveal 贴面 + 楼板扇三角 + 家具目录几何),供任意视口消费; +- C++ 交互工具(`ArchSpaceWallDrawTool` / `ArchSpaceOpeningPlaceTool` / `ArchSpaceItemPlaceTool`):通过 + `IEditAuthority` 提交 DomainOperation,预览不落盘。 + +**诚实边界:** 洞口是参数化切腔 + reveal,不是 CSG/IFC BREP;家具是内置目录多盒几何,不是外部网格资源; +还没有完整 CAD 产品级 UI。 + +项目自己组合工具栏、视口与 MCP host;不要把 ArchSpace 写进 `building`。 + +## 快速用法(脚本) + +```squirrel +local doc = eve.ArchSpaceDocument(); +local boot = doc.bootstrap("site", "building", "level0", 3.0); +if (!boot.ok) throw boot.status.summary; + +doc.createRectRoom("level0", "living", "Living", 0.0, 0.0, 6.0, 4.5, 3.0, 0.2, 0.2); +doc.createWall("level0", "partition", "Partition", 3.0, 0.0, 3.0, 4.5, 3.0, 0.15); +doc.createOpening("living.wall.0", "living.door", "Entry", "door", 0.45, 1.0, 2.1, 0.0); +doc.placeItem("level0", "desk", "Desk", "furniture.desk", 2.0, 0.0, 2.0, 90.0); +doc.placeItem("level0", "sofa", "Sofa", "furniture.sofa", 3.0, 0.0, 2.5, 0.0); + +if (!doc.hasNode("living.zone")) throw "missing zone"; +print(doc.summary() + " nodes=" + doc.nodeCount() + " root=" + doc.rootId() + "\n"); + +local arrays = doc.bakeMeshArrays(); // positions / normals / uvs / indices +local mesh = gfx.newMeshFromArrays(arrays.positions, arrays.normals, arrays.uvs, + arrays.vertexCount, arrays.indices, arrays.indexCount); + +doc.deleteNode("desk"); // cascading delete when needed +``` + +完整示例见 `examples/archspace-editor/`。 + +内置家具目录 id:`furniture.desk` / `sofa` / `bed` / `chair` / `table` / `wardrobe`(未知 id 回退为 marker 盒)。 + +## 快速用法(C++) + +```cpp +eve::archspace_editing::ArchSpaceDocumentTarget target("apartment"); +auto boot = target.makeBootstrap("site", "building", "level0"); +target.applyDomainOperation(boot.value()); +auto room = target.makeCreateRectRoom("level0", "living", "Living", 0, 0, 6, 4); +target.applyDomainOperation(room.value()); +auto mesh = target.bakeMesh(); // positions + indices + primitiveIds(含 *.reveal.*) + +// Interactive tools (host supplies IArchSpaceViewportAdapter + IEditAuthority): +// ArchSpaceWallDrawTool / ArchSpaceOpeningPlaceTool / ArchSpaceItemPlaceTool +``` + +## API 快查 + +### `ArchSpaceDocument`(脚本类) + +- `nodeCount()` — 当前节点数。 +- `rootId()` — 站点根 id;空文档返回空串。 +- `hasNode(id)` — 节点是否存在。 +- `bootstrap(siteId, buildingId, levelId, levelHeight)` → Result;空文档上创建 + site→building→level。 +- `createRectRoom(levelId, roomId, name, originX, originZ, sizeX, sizeZ, wallHeight, + wallThickness, slabThickness)` → Result;矩形房间(四墙 + 楼板 + zone)。 +- `createWall(levelId, wallId, name, x0, z0, x1, z1, height, thickness)` → Result。 +- `createOpening(wallId, openingId, name, kind, t, width, height, sill)` → Result; + `kind` 为 `"door"` / `"window"`。洞口 bake 为参数化切腔 + `*.reveal.*` 贴面。 +- `placeItem(levelId, itemId, name, catalogId, x, y, z, yawDegrees)` → Result。 + `catalogId` 解析内置家具目录;未知 id 使用 marker。 +- `deleteNode(id)` → Result;级联删除子树。 +- `bakeMeshArrays()` — 返回表:`positions`/`normals`/`uvs`/`indices` 以及 + `vertexCount`/`indexCount`/`triangleCount`(洞口切腔后的上传数组)。 +- `summary()` — 人类可读摘要字符串(nodes/root/triangles/vertices)。 + +### 编辑器工具(C++,`archspace_editor`) + +- `ArchSpaceWallDrawTool` — 拖拽画墙;Shift 正交;contiguous 首尾相接;Up 时 + `makeCreateWall`。 +- `ArchSpaceOpeningPlaceTool` — 就近吸附墙体参数 t;Up 时 `makeCreateOpening`。 +- `ArchSpaceItemPlaceTool` — 点击放置目录家具;Up 时 `makePlaceItem`。 +- 均需宿主实现 `IArchSpaceViewportAdapter`(`pointerRay` / `projectWorld`),并通过 + `IEditAuthority` 提交;Move/预览不改文档。 + +所有可能失败的脚本调用返回统一 Result 投影:检查 `ok`,再读 `status.summary` / +`diagnostics`;不要依赖旁路错误字符串。数值参数在脚本侧为 `float`。 + +## Agent / MCP + +1. `eve_editor_target_create`,`type=archspace`,可选 `siteId` 自动 bootstrap; +2. `eve_editor_execute` 调用上表命令; +3. `eve_editor_inspect` / observe / `eve_screenshot` 验收。 + +设计说明见 [`docs/dev/2026-09-14-archspace-editor.md`](../dev/2026-09-14-archspace-editor.md)。 diff --git a/examples/archspace-editor/README.md b/examples/archspace-editor/README.md new file mode 100644 index 00000000..dc8939a2 --- /dev/null +++ b/examples/archspace-editor/README.md @@ -0,0 +1,18 @@ +# ArchSpace Editor example + +Builds a small apartment document (rooms, corridor wall, door, window, desk), +bakes a renderer-neutral mesh with opening cutouts, uploads it through +`gfx.newMeshFromArrays`, and saves `archspace-editor.png`. + +```sh +cd examples/archspace-editor +../../build/linux-debug/src/engine/eve run +``` + +Success markers: + +- stdout contains `ARCHSPACE_PASS` +- `archspace-editor.png` is written after a few frames + +C++/automation coverage: `test/editor_archspace_target.cpp`. +Design notes: `docs/dev/2026-09-14-archspace-editor.md`. diff --git a/examples/archspace-editor/config.nut b/examples/archspace-editor/config.nut new file mode 100644 index 00000000..461c6d46 --- /dev/null +++ b/examples/archspace-editor/config.nut @@ -0,0 +1,8 @@ +config = { + title = "EVEngine ArchSpace Editor", + width = 1280, + height = 720, + debug = true, + hotReload = true, + modules = ["archspace", "archspaceEditor", "editor", "gfx"], +}; diff --git a/examples/archspace-editor/main.nut b/examples/archspace-editor/main.nut new file mode 100644 index 00000000..498cf7f1 --- /dev/null +++ b/examples/archspace-editor/main.nut @@ -0,0 +1,83 @@ +// ArchSpace authoring demo: build a real floor plan, bake mesh with door/window +// cutouts, upload to the GPU and prove the viewport path with a screenshot. + +persist arch = { + camera = null, + object = null, + frame = 0, + screenshotSaved = false, + summary = "", +} + +function archRequire(result, context) { + if (!result.ok) throw context + ": " + result.status.summary; + return result; +} + +function archBuildApartment() { + local doc = eve.ArchSpaceDocument(); + archRequire(doc.bootstrap("site", "building", "level0", 3.0), "bootstrap"); + archRequire(doc.createRectRoom("level0", "living", "Living", 0.0, 0.0, 6.0, 4.5, 3.0, 0.2, 0.2), "living"); + archRequire(doc.createRectRoom("level0", "office", "Office", 6.0, 0.0, 4.0, 4.5, 3.0, 0.2, 0.2), "office"); + archRequire(doc.createWall("level0", "corridor.wall", "Corridor", 0.0, 4.5, 10.0, 4.5, 3.0, 0.18), "corridor"); + archRequire(doc.createOpening("living.wall.0", "living.door", "Entry", "door", 0.45, 1.0, 2.1, 0.0), "door"); + archRequire(doc.createOpening("office.wall.1", "office.window", "Window", "window", 0.5, 1.4, 1.4, 0.9), + "window"); + archRequire(doc.placeItem("level0", "desk", "Desk", "furniture.desk", 7.2, 0.0, 2.0, 90.0), "desk"); + archRequire(doc.placeItem("level0", "sofa", "Sofa", "furniture.sofa", 2.0, 0.0, 2.2, 0.0), "sofa"); + archRequire(doc.placeItem("level0", "chair", "Chair", "furniture.chair", 7.2, 0.0, 1.1, 0.0), "chair"); + if (!doc.hasNode("living.zone") || !doc.hasNode("living.door") || !doc.hasNode("desk") || + !doc.hasNode("sofa") || !doc.hasNode("chair")) + throw "ArchSpace document missing authored nodes"; + return doc; +} + +function archUpload(doc) { + local arrays = doc.bakeMeshArrays(); + if (arrays.triangleCount < 12) throw "ArchSpace bake produced too few triangles: " + arrays.triangleCount; + local mesh = gfx.newMeshFromArrays(arrays.positions, arrays.normals, arrays.uvs, arrays.vertexCount, + arrays.indices, arrays.indexCount); + if (mesh == null) throw "gfx.newMeshFromArrays failed for ArchSpace bake"; + local object = eve.Renderable3D(); + object.setMesh(mesh); + object.setPosition(0.0, 0.0, 0.0); + object.setTint(0.82, 0.78, 0.72, 1.0); + object.setRoughness(0.55); + object.setMetallic(0.05); + object.setCastShadow(true); + object.setReceiveShadow(true); + return { object = object, summary = doc.summary() }; +} + +if (arch.camera == null) { + arch.camera = eve.Camera3D(); + arch.camera.setEye(8.0, 11.0, -14.0); + arch.camera.setTarget(5.0, 0.5, 2.5); + arch.camera.setUp(0.0, 1.0, 0.0); + arch.camera.setFov(42.0); + arch.camera.setAmbient(0.28, 0.30, 0.34); + arch.camera.setActive(true); + gfx.setDirectionalLight(-0.45, -1.0, -0.35, 1.4, 1.3, 1.15); + gfx.setBackgroundColor(0.04, 0.055, 0.08, 1.0); +} + +if (arch.object == null) { + local built = archUpload(archBuildApartment()); + arch.object = built.object; + arch.summary = built.summary; + print("ARCHSPACE_PASS " + arch.summary + "\n"); +} + +function eve_update(dt) { + arch.frame += 1; + if (arch.object != null) arch.object.setYaw(arch.frame * 0.15); + if (!arch.screenshotSaved && arch.frame > 24 && gfx.saveFramePng("archspace-editor.png")) { + arch.screenshotSaved = true; + print("archspace-editor: screenshot saved\n"); + } +} + +function eve_render() { + gfx.clear(); + gfx.render3D(); +} diff --git a/src/modules/archspace/ArchSpace.cpp b/src/modules/archspace/ArchSpace.cpp new file mode 100644 index 00000000..b584e882 --- /dev/null +++ b/src/modules/archspace/ArchSpace.cpp @@ -0,0 +1,121 @@ +#include "archspace/ArchSpace.h" + +#include "archspace/ArchSpaceDocument.h" +#include "common/SquirrelBinding.h" + +#include + +#include +#include +#include +#include + +namespace eve::archspace { +namespace { + +template +ssq::Table failNull(HSQUIRRELVM vm, const char* message) { + return eve::script::projectResult( + vm, eve::Result::failure(eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, message, "receiver", + {}, "archspace.squirrel"))); +} + +ssq::Table bakeArraysTable(HSQUIRRELVM vm, const MeshArrays& arrays) { + ssq::Array positions(vm); + for (float v : arrays.positions) positions.push(v); + ssq::Array normals(vm); + for (float v : arrays.normals) normals.push(v); + ssq::Array uvs(vm); + for (float v : arrays.uvs) uvs.push(v); + ssq::Array indices(vm); + for (std::uint32_t v : arrays.indices) indices.push(static_cast(v)); + ssq::Table table(vm); + table.set("positions", positions); + table.set("normals", normals); + table.set("uvs", uvs); + table.set("indices", indices); + table.set("vertexCount", static_cast(arrays.vertexCount())); + table.set("indexCount", static_cast(arrays.indexCount())); + table.set("triangleCount", static_cast(arrays.triangleCount())); + return table; +} + +} // namespace + +Module_IMPL(ArchSpace, new ArchSpace()); + +void ArchSpace::expose(ssq::Table& table) { + const HSQUIRRELVM vm = table.getHandle(); + table.addClass(name, ArchSpace::create, false); + + auto document = + table.addClass("ArchSpaceDocument", + std::function([] { return new Document(); }), true); + document.addFunc("nodeCount", [](Document* self) { + return self ? static_cast(self->nodeCount()) : 0; + }); + document.addFunc("rootId", [](Document* self) { return self ? self->rootId() : std::string{}; }); + document.addFunc("hasNode", [](Document* self, const std::string& id) { + return self && self->find(id) != nullptr; + }); + document.addFunc("bootstrap", [vm](Document* self, const std::string& siteId, const std::string& buildingId, + const std::string& levelId, float levelHeight) { + if (!self) return failNull(vm, "ArchSpaceDocument receiver must not be null"); + return eve::script::projectResult(vm, self->bootstrap(siteId, buildingId, levelId, levelHeight)); + }); + document.addFunc("createRectRoom", [vm](Document* self, const std::string& levelId, const std::string& roomId, + const std::string& roomName, float originX, float originZ, float sizeX, + float sizeZ, float wallHeight, float wallThickness, + float slabThickness) { + if (!self) return failNull(vm, "ArchSpaceDocument receiver must not be null"); + std::vector polygon = {{originX, originZ}, + {originX + sizeX, originZ}, + {originX + sizeX, originZ + sizeZ}, + {originX, originZ + sizeZ}}; + return eve::script::projectResult( + vm, self->createRoom(levelId, roomId, roomName, std::move(polygon), wallHeight, wallThickness, + slabThickness)); + }); + document.addFunc("createWall", [vm](Document* self, const std::string& levelId, const std::string& wallId, + const std::string& wallName, float x0, float z0, float x1, float z1, + float height, float thickness) { + if (!self) return failNull(vm, "ArchSpaceDocument receiver must not be null"); + return eve::script::projectResult( + vm, self->createWall(levelId, wallId, wallName, Vec2{x0, z0}, Vec2{x1, z1}, height, thickness)); + }); + document.addFunc("createOpening", [vm](Document* self, const std::string& wallId, const std::string& openingId, + const std::string& openingName, const std::string& kindText, float t, + float width, float height, float sill) { + if (!self) return failNull(vm, "ArchSpaceDocument receiver must not be null"); + auto kind = parseOpeningKind(kindText); + if (!kind.ok()) return eve::script::projectResult(vm, eve::Result::failure(kind.status())); + return eve::script::projectResult( + vm, self->createOpening(wallId, openingId, openingName, kind.value(), t, width, height, sill)); + }); + document.addFunc("placeItem", [vm](Document* self, const std::string& levelId, const std::string& itemId, + const std::string& itemName, const std::string& catalogId, float x, float y, + float z, float yawDegrees) { + if (!self) return failNull(vm, "ArchSpaceDocument receiver must not be null"); + return eve::script::projectResult( + vm, self->placeItem(levelId, itemId, itemName, catalogId, Vec3{x, y, z}, yawDegrees)); + }); + document.addFunc("deleteNode", [vm](Document* self, const std::string& id) { + if (!self) return failNull(vm, "ArchSpaceDocument receiver must not be null"); + return eve::script::projectResult(vm, self->eraseCascade(id)); + }); + document.addFunc("bakeMeshArrays", [vm](Document* self) { + if (!self) return ssq::Table(vm); + return bakeArraysTable(vm, self->bakeMeshArrays()); + }); + document.addFunc("summary", [](Document* self) { + if (!self) return std::string{"null"}; + const auto bake = self->bakeMeshArrays(); + return "nodes=" + std::to_string(self->nodeCount()) + " root=" + self->rootId() + + " triangles=" + std::to_string(bake.triangleCount()) + + " vertices=" + std::to_string(bake.vertexCount()); + }); +} + +void ArchSpace::expose(ssq::Class&) {} + +} // namespace eve::archspace diff --git a/src/modules/archspace/ArchSpace.h b/src/modules/archspace/ArchSpace.h new file mode 100644 index 00000000..210ed1f6 --- /dev/null +++ b/src/modules/archspace/ArchSpace.h @@ -0,0 +1,13 @@ +#pragma once + +#include "common/Module.h" + +namespace eve::archspace { + +/** @brief Runtime ArchSpace module entry used for profile wiring and script discovery. */ +class ArchSpace final : public Module { +public: + Module_REG(ArchSpace); +}; + +} // namespace eve::archspace diff --git a/src/modules/archspace/ArchSpaceCatalog.cpp b/src/modules/archspace/ArchSpaceCatalog.cpp new file mode 100644 index 00000000..d3a6c184 --- /dev/null +++ b/src/modules/archspace/ArchSpaceCatalog.cpp @@ -0,0 +1,221 @@ +#include "archspace/ArchSpaceCatalog.h" + +#include +#include +#include + +namespace eve::archspace { +namespace { + +constexpr double kEps = 1e-8; +constexpr double kDeg2Rad = 0.017453292519943295; + +void pushBox(MeshBake& bake, const Vec3 corners[8], const std::string& id) { + const std::uint32_t base = static_cast(bake.positions.size()); + for (int i = 0; i < 8; ++i) bake.positions.push_back(corners[i]); + static constexpr std::uint32_t faces[12][3] = {{0, 1, 2}, {0, 2, 3}, {4, 6, 5}, {4, 7, 6}, {0, 3, 7}, {0, 7, 4}, + {1, 5, 6}, {1, 6, 2}, {0, 4, 5}, {0, 5, 1}, {3, 2, 6}, {3, 6, 7}}; + for (const auto& f : faces) { + bake.indices.push_back(base + f[0]); + bake.indices.push_back(base + f[1]); + bake.indices.push_back(base + f[2]); + } + bake.primitiveIds.push_back(id); +} + +void pushOrientedBox(MeshBake& bake, Vec3 center, Vec3 size, double yawDegrees, const std::string& id) { + if (size.x <= kEps || size.y <= kEps || size.z <= kEps) return; + const double c = std::cos(yawDegrees * kDeg2Rad); + const double s = std::sin(yawDegrees * kDeg2Rad); + const double hx = size.x * 0.5; + const double hy = size.y * 0.5; + const double hz = size.z * 0.5; + const Vec3 local[8] = {{-hx, -hy, -hz}, {hx, -hy, -hz}, {hx, -hy, hz}, {-hx, -hy, hz}, + {-hx, hy, -hz}, {hx, hy, -hz}, {hx, hy, hz}, {-hx, hy, hz}}; + Vec3 corners[8]; + for (int i = 0; i < 8; ++i) { + corners[i] = {center.x + local[i].x * c - local[i].z * s, center.y + local[i].y, + center.z + local[i].x * s + local[i].z * c}; + } + pushBox(bake, corners, id); +} + +Vec2 mix(const Vec2& a, const Vec2& b, double t) { return {a.x + (b.x - a.x) * t, a.z + (b.z - a.z) * t}; } + +void pushWallBox(MeshBake& bake, Vec2 start, Vec2 end, double elevation, double thickness, double bottom, double top, + const std::string& id) { + if (top <= bottom + kEps) return; + Vec2 dir{end.x - start.x, end.z - start.z}; + const double len = std::sqrt(dir.x * dir.x + dir.z * dir.z); + if (len <= kEps || thickness <= kEps) return; + dir.x /= len; + dir.z /= len; + const Vec2 n{-dir.z * thickness * 0.5, dir.x * thickness * 0.5}; + const double h = top - bottom; + const Vec3 a{start.x, elevation + bottom, start.z}; + const Vec3 b{end.x, elevation + bottom, end.z}; + const Vec3 corners[8] = {{a.x + n.x, a.y, a.z + n.z}, {a.x - n.x, a.y, a.z - n.z}, + {b.x - n.x, a.y, b.z - n.z}, {b.x + n.x, a.y, b.z + n.z}, + {a.x + n.x, a.y + h, a.z + n.z}, {a.x - n.x, a.y + h, a.z - n.z}, + {b.x - n.x, a.y + h, b.z - n.z}, {b.x + n.x, a.y + h, b.z + n.z}}; + pushBox(bake, corners, id); +} + +const std::unordered_map& table() { + static const std::unordered_map kTable = { + {"furniture.desk", + {"furniture.desk", + "Desk", + {{{0.0, 0.725, 0.0}, {1.40, 0.05, 0.70}, ".top"}, + {{-0.60, 0.35, -0.28}, {0.08, 0.70, 0.08}, ".leg.fl"}, + {{0.60, 0.35, -0.28}, {0.08, 0.70, 0.08}, ".leg.fr"}, + {{-0.60, 0.35, 0.28}, {0.08, 0.70, 0.08}, ".leg.bl"}, + {{0.60, 0.35, 0.28}, {0.08, 0.70, 0.08}, ".leg.br"}}}}, + {"furniture.sofa", + {"furniture.sofa", + "Sofa", + {{{0.0, 0.22, 0.0}, {2.00, 0.44, 0.85}, ".seat"}, + {{0.0, 0.55, -0.32}, {2.00, 0.50, 0.20}, ".back"}, + {{-0.90, 0.40, 0.05}, {0.18, 0.40, 0.75}, ".arm.l"}, + {{0.90, 0.40, 0.05}, {0.18, 0.40, 0.75}, ".arm.r"}}}}, + {"furniture.bed", + {"furniture.bed", + "Bed", + {{{0.0, 0.28, 0.0}, {2.00, 0.36, 1.60}, ".mattress"}, + {{0.0, 0.55, -0.72}, {2.00, 0.55, 0.12}, ".headboard"}}}}, + {"furniture.chair", + {"furniture.chair", + "Chair", + {{{0.0, 0.45, 0.0}, {0.45, 0.08, 0.45}, ".seat"}, + {{0.0, 0.70, -0.18}, {0.45, 0.45, 0.08}, ".back"}, + {{-0.16, 0.22, -0.16}, {0.06, 0.44, 0.06}, ".leg.fl"}, + {{0.16, 0.22, -0.16}, {0.06, 0.44, 0.06}, ".leg.fr"}, + {{-0.16, 0.22, 0.16}, {0.06, 0.44, 0.06}, ".leg.bl"}, + {{0.16, 0.22, 0.16}, {0.06, 0.44, 0.06}, ".leg.br"}}}}, + {"furniture.table", + {"furniture.table", + "Table", + {{{0.0, 0.72, 0.0}, {1.20, 0.06, 1.20}, ".top"}, {{0.0, 0.36, 0.0}, {0.12, 0.72, 0.12}, ".pedestal"}}}}, + {"furniture.wardrobe", {"furniture.wardrobe", "Wardrobe", {{{0.0, 1.05, 0.0}, {1.20, 2.10, 0.60}, ".body"}}}}, + }; + return kTable; +} + +} // namespace + +std::optional lookupCatalog(std::string_view catalogId) { + const auto it = table().find(std::string(catalogId)); + if (it == table().end()) return std::nullopt; + return it->second; +} + +std::vector listCatalogIds() { + std::vector ids; + ids.reserve(table().size()); + for (const auto& [id, _] : table()) ids.push_back(id); + std::sort(ids.begin(), ids.end()); + return ids; +} + +void appendCatalogItem(MeshBake& bake, const std::string& itemId, const std::string& catalogId, const Vec3& position, + double yawDegrees) { + if (const auto entry = lookupCatalog(catalogId)) { + const double c = std::cos(yawDegrees * kDeg2Rad); + const double s = std::sin(yawDegrees * kDeg2Rad); + for (const CatalogPart& part : entry->parts) { + const Vec3 center{position.x + part.center.x * c - part.center.z * s, position.y + part.center.y, + position.z + part.center.x * s + part.center.z * c}; + pushOrientedBox(bake, center, part.size, yawDegrees, itemId + part.suffix); + } + return; + } + pushOrientedBox(bake, {position.x, position.y + 0.45, position.z}, {0.50, 0.90, 0.50}, yawDegrees, itemId); +} + +void appendWallWithOpenings(MeshBake& bake, const Node& wall, double elevation, + const std::vector& openings) { + const double len = std::sqrt((wall.end.x - wall.start.x) * (wall.end.x - wall.start.x) + + (wall.end.z - wall.start.z) * (wall.end.z - wall.start.z)); + if (len <= kEps) return; + + struct Span { + double t0 = 0; + double t1 = 0; + OpeningKind kind = OpeningKind::Door; + double sill = 0; + double oh = 0; + std::string id; + }; + std::vector spans; + for (const Node* child : openings) { + if (!child || child->kind != NodeKind::Opening) continue; + const double halfT = (child->width * 0.5) / len; + Span span; + span.t0 = std::clamp(child->t - halfT, 0.0, 1.0); + span.t1 = std::clamp(child->t + halfT, 0.0, 1.0); + span.kind = child->openingKind; + span.sill = std::clamp(child->sill, 0.0, wall.height); + span.oh = std::clamp(child->openingHeight, 0.0, std::max(0.0, wall.height - span.sill)); + span.id = child->id; + if (span.t1 > span.t0 + 1e-6 && span.oh > kEps) spans.push_back(span); + } + std::sort(spans.begin(), spans.end(), [](const Span& a, const Span& b) { return a.t0 < b.t0; }); + + std::vector merged; + for (const Span& span : spans) { + if (!merged.empty() && span.t0 <= merged.back().t1 + 1e-6) { + Span& last = merged.back(); + last.t1 = std::max(last.t1, span.t1); + const double lastTop = last.sill + last.oh; + const double spanTop = span.sill + span.oh; + last.sill = std::min(last.sill, span.sill); + last.oh = std::max(lastTop, spanTop) - last.sill; + if (span.kind == OpeningKind::Door) last.kind = OpeningKind::Door; + last.id += "+" + span.id; + } else { + merged.push_back(span); + } + } + + double cursor = 0.0; + auto emit = [&](double t0, double t1, double bottom, double top, const std::string& id) { + pushWallBox(bake, mix(wall.start, wall.end, t0), mix(wall.start, wall.end, t1), elevation, wall.thickness, + bottom, top, id); + }; + + for (const Span& opening : merged) { + emit(cursor, opening.t0, 0.0, wall.height, wall.id); + const double head = opening.sill + opening.oh; + if (opening.kind == OpeningKind::Door) { + emit(opening.t0, opening.t1, head, wall.height, wall.id + ".lintel"); + } else { + emit(opening.t0, opening.t1, 0.0, opening.sill, wall.id + ".sill"); + emit(opening.t0, opening.t1, head, wall.height, wall.id + ".head"); + } + + const Vec2 p0 = mix(wall.start, wall.end, opening.t0); + const Vec2 p1 = mix(wall.start, wall.end, opening.t1); + Vec2 dir{wall.end.x - wall.start.x, wall.end.z - wall.start.z}; + dir.x /= len; + dir.z /= len; + const double reveal = std::min(0.04, wall.thickness * 0.5); + pushWallBox(bake, {p0.x - dir.x * reveal, p0.z - dir.z * reveal}, + {p0.x + dir.x * reveal, p0.z + dir.z * reveal}, elevation, wall.thickness, opening.sill, head, + opening.id + ".reveal.jamb.l"); + pushWallBox(bake, {p1.x - dir.x * reveal, p1.z - dir.z * reveal}, + {p1.x + dir.x * reveal, p1.z + dir.z * reveal}, elevation, wall.thickness, opening.sill, head, + opening.id + ".reveal.jamb.r"); + if (opening.kind == OpeningKind::Window && opening.sill > kEps) { + pushWallBox(bake, p0, p1, elevation, wall.thickness, opening.sill - std::min(0.03, opening.sill), + opening.sill, opening.id + ".reveal.sill"); + } + if (head + kEps < wall.height) { + pushWallBox(bake, p0, p1, elevation, wall.thickness, head, head + std::min(0.03, wall.height - head), + opening.id + ".reveal.head"); + } + cursor = std::max(cursor, opening.t1); + } + emit(cursor, 1.0, 0.0, wall.height, wall.id); +} + +} // namespace eve::archspace diff --git a/src/modules/archspace/ArchSpaceCatalog.h b/src/modules/archspace/ArchSpaceCatalog.h new file mode 100644 index 00000000..7db71a72 --- /dev/null +++ b/src/modules/archspace/ArchSpaceCatalog.h @@ -0,0 +1,56 @@ +#pragma once + +#include "archspace/ArchSpaceTypes.h" + +#include +#include +#include +#include + +namespace eve::archspace { + +/** @brief One parametric furniture part in catalog-local metres (Y-up). */ +struct CatalogPart { + Vec3 center{}; ///< Local center relative to the item origin. + Vec3 size{0.5, 0.5, 0.5}; ///< Full extents along local X / Y / Z. + std::string suffix; ///< Appended to the item id for bake primitive ids. +}; + +/** @brief Built-in catalog entry resolved from an item `catalogId`. */ +struct CatalogEntry { + std::string id; + std::string displayName; + std::vector parts; +}; + +/** + * @brief Resolve a built-in furniture catalog entry. + * @return Entry when known; empty optional for unknown ids (bake falls back to a marker). + */ +[[nodiscard]] std::optional lookupCatalog(std::string_view catalogId); + +/** @brief Stable list of built-in catalog ids for tools and inspector UIs. */ +[[nodiscard]] std::vector listCatalogIds(); + +/** + * @brief Append yaw-oriented catalog geometry into a mesh bake. + * @param bake Destination mesh. + * @param itemId Stable item node id used as the primitive id prefix. + * @param catalogId Catalog key; unknown ids emit a default marker box. + * @param position World/level position of the item origin. + * @param yawDegrees Rotation about +Y in degrees. + */ +void appendCatalogItem(MeshBake& bake, const std::string& itemId, const std::string& catalogId, const Vec3& position, + double yawDegrees); + +/** + * @brief Bake one wall with merged opening cavities and explicit reveal liners. + * + * Overlapping openings are merged along the wall parameter before carving. Reveal + * primitives use `*.reveal.*` ids so hosts can distinguish cavity liners from structure. + * This is a parametric boolean (segment solids + reveal faces), not IFC/CSG BREP. + */ +void appendWallWithOpenings(MeshBake& bake, const Node& wall, double elevation, + const std::vector& openings); + +} // namespace eve::archspace diff --git a/src/modules/archspace/ArchSpaceDocument.cpp b/src/modules/archspace/ArchSpaceDocument.cpp new file mode 100644 index 00000000..29ccd11b --- /dev/null +++ b/src/modules/archspace/ArchSpaceDocument.cpp @@ -0,0 +1,403 @@ +#include "archspace/ArchSpaceDocument.h" + +#include "archspace/ArchSpaceCatalog.h" + +#include +#include +#include +#include + +namespace eve::archspace { +namespace { + +constexpr double kEps = 1e-8; + +template +eve::Result fail(eve::DiagnosticCode code, std::string message, std::string path = {}) { + return eve::Result::failure( + eve::Diagnostic::error(code, std::move(message), std::move(path), {}, "archspace.document")); +} + +eve::Result ok() { return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); } + +bool isFinite(double v) { return std::isfinite(v); } + +bool isFinite(const Vec2& v) { return isFinite(v.x) && isFinite(v.z); } + +bool isFinite(const Vec3& v) { return isFinite(v.x) && isFinite(v.y) && isFinite(v.z); } + +double distance(const Vec2& a, const Vec2& b) { + const double dx = b.x - a.x; + const double dz = b.z - a.z; + return std::sqrt(dx * dx + dz * dz); +} + +double polygonArea(const std::vector& poly) { + if (poly.size() < 3) return 0.0; + double acc = 0.0; + for (std::size_t i = 0; i < poly.size(); ++i) { + const Vec2& a = poly[i]; + const Vec2& b = poly[(i + 1) % poly.size()]; + acc += a.x * b.z - b.x * a.z; + } + return 0.5 * acc; +} + +bool polygonValid(const std::vector& poly) { + if (poly.size() < 3 || poly.size() > 256) return false; + for (const auto& p : poly) + if (!isFinite(p)) return false; + return std::abs(polygonArea(poly)) > kEps; +} + +void appendBox(MeshBake& bake, const Vec3& a, const Vec3& b, double thickness, double height, const std::string& id) { + Vec2 dir{b.x - a.x, b.z - a.z}; + const double len = std::sqrt(dir.x * dir.x + dir.z * dir.z); + if (len <= kEps || thickness <= kEps || height <= kEps) return; + dir.x /= len; + dir.z /= len; + const Vec2 n{-dir.z * thickness * 0.5, dir.x * thickness * 0.5}; + const std::uint32_t base = static_cast(bake.positions.size()); + const Vec3 corners[8] = { + {a.x + n.x, a.y, a.z + n.z}, {a.x - n.x, a.y, a.z - n.z}, + {b.x - n.x, a.y, b.z - n.z}, {b.x + n.x, a.y, b.z + n.z}, + {a.x + n.x, a.y + height, a.z + n.z}, {a.x - n.x, a.y + height, a.z - n.z}, + {b.x - n.x, a.y + height, b.z - n.z}, {b.x + n.x, a.y + height, b.z + n.z}, + }; + for (const auto& c : corners) bake.positions.push_back(c); + static constexpr std::uint32_t faces[12][3] = {{0, 1, 2}, {0, 2, 3}, {4, 6, 5}, {4, 7, 6}, {0, 3, 7}, {0, 7, 4}, + {1, 5, 6}, {1, 6, 2}, {0, 4, 5}, {0, 5, 1}, {3, 2, 6}, {3, 6, 7}}; + for (const auto& f : faces) { + bake.indices.push_back(base + f[0]); + bake.indices.push_back(base + f[1]); + bake.indices.push_back(base + f[2]); + } + bake.primitiveIds.push_back(id); +} + +void appendSlab(MeshBake& bake, const std::vector& poly, double y, double thickness, const std::string& id) { + if (poly.size() < 3 || thickness <= kEps) return; + const std::uint32_t base = static_cast(bake.positions.size()); + for (const auto& p : poly) bake.positions.push_back(Vec3{p.x, y, p.z}); + for (const auto& p : poly) bake.positions.push_back(Vec3{p.x, y + thickness, p.z}); + const std::uint32_t n = static_cast(poly.size()); + for (std::uint32_t i = 1; i + 1 < n; ++i) { + bake.indices.push_back(base); + bake.indices.push_back(base + i); + bake.indices.push_back(base + i + 1); + bake.indices.push_back(base + n); + bake.indices.push_back(base + n + i + 1); + bake.indices.push_back(base + n + i); + } + bake.primitiveIds.push_back(id); +} + +} // namespace + +const Node* Document::find(const std::string& id) const { + const auto it = nodes_.find(id); + return it == nodes_.end() ? nullptr : &it->second; +} + +Node* Document::findMutable(const std::string& id) { + const auto it = nodes_.find(id); + return it == nodes_.end() ? nullptr : &it->second; +} + +bool Document::canParent(NodeKind child, const std::string& parentId) const { + if (child == NodeKind::Site) return parentId.empty(); + const Node* parent = find(parentId); + if (!parent) return false; + switch (child) { + case NodeKind::Building: return parent->kind == NodeKind::Site; + case NodeKind::Level: return parent->kind == NodeKind::Building; + case NodeKind::Wall: + case NodeKind::Slab: + case NodeKind::Zone: + case NodeKind::Item: return parent->kind == NodeKind::Level; + case NodeKind::Opening: return parent->kind == NodeKind::Wall; + case NodeKind::Site: return false; + } + return false; +} + +bool Document::validateNode(const Node& node) const { + if (node.id.empty() || node.id.size() > 128 || node.name.size() > 256) return false; + if (node.kind == NodeKind::Site) { + if (!node.parentId.empty()) return false; + } else if (!canParent(node.kind, node.parentId)) { + return false; + } + switch (node.kind) { + case NodeKind::Site: + case NodeKind::Building: return true; + case NodeKind::Level: return isFinite(node.elevation) && isFinite(node.height) && node.height > kEps; + case NodeKind::Wall: + return isFinite(node.start) && isFinite(node.end) && isFinite(node.thickness) && isFinite(node.height) && + node.thickness > kEps && node.height > kEps && distance(node.start, node.end) > kEps; + case NodeKind::Slab: + return polygonValid(node.polygon) && isFinite(node.slabThickness) && node.slabThickness > kEps; + case NodeKind::Zone: return polygonValid(node.polygon); + case NodeKind::Item: + return isFinite(node.position) && isFinite(node.yawDegrees) && node.catalogId.size() <= 256; + case NodeKind::Opening: + return isFinite(node.t) && node.t >= 0.0 && node.t <= 1.0 && isFinite(node.width) && node.width > kEps && + isFinite(node.openingHeight) && node.openingHeight > kEps && isFinite(node.sill) && node.sill >= 0.0; + } + return false; +} + +void Document::unlinkFromParent(const std::string& id) { + Node* node = findMutable(id); + if (!node || node->parentId.empty()) return; + if (Node* parent = findMutable(node->parentId)) { + parent->children.erase(std::remove(parent->children.begin(), parent->children.end(), id), + parent->children.end()); + } +} + +void Document::collectDescendants(const std::string& id, std::vector& out) const { + const Node* node = find(id); + if (!node) return; + for (const auto& child : node->children) { + out.push_back(child); + collectDescendants(child, out); + } +} + +eve::Result Document::insert(Node node) { + if (nodes_.find(node.id) != nodes_.end()) + return fail(eve::DiagnosticCode::Conflict, "ArchSpace node id already exists", "id"); + if (!validateNode(node)) + return fail(eve::DiagnosticCode::InvalidArgument, "ArchSpace node failed validation", "node"); + if (node.kind == NodeKind::Site) { + if (!rootId_.empty()) + return fail(eve::DiagnosticCode::Conflict, "ArchSpace document already has a site root", "root"); + rootId_ = node.id; + } else { + Node* parent = findMutable(node.parentId); + if (!parent) return fail(eve::DiagnosticCode::NotFound, "ArchSpace parent node is missing", "parentId"); + parent->children.push_back(node.id); + } + nodes_.emplace(node.id, std::move(node)); + return ok(); +} + +eve::Result Document::replace(Node node) { + Node* existing = findMutable(node.id); + if (!existing) return fail(eve::DiagnosticCode::NotFound, "ArchSpace node is missing", "id"); + node.parentId = existing->parentId; + node.kind = existing->kind; + node.children = existing->children; + if (!validateNode(node)) + return fail(eve::DiagnosticCode::InvalidArgument, "ArchSpace node failed validation", "node"); + *existing = std::move(node); + return ok(); +} + +eve::Result Document::eraseCascade(const std::string& id) { + if (!find(id)) return fail(eve::DiagnosticCode::NotFound, "ArchSpace node is missing", "id"); + std::vector order; + collectDescendants(id, order); + std::reverse(order.begin(), order.end()); + order.push_back(id); + for (const auto& victim : order) { + unlinkFromParent(victim); + nodes_.erase(victim); + } + if (rootId_ == id) rootId_.clear(); + return ok(); +} + +void Document::clear() { + nodes_.clear(); + rootId_.clear(); +} + +eve::Result Document::bootstrap(const std::string& siteId, const std::string& buildingId, + const std::string& levelId, double levelHeight) { + if (!rootId_.empty() || siteId.empty() || buildingId.empty() || levelId.empty()) + return fail(eve::DiagnosticCode::PreconditionViolation, + "ArchSpace bootstrap requires an empty document and non-empty ids"); + if (siteId == buildingId || siteId == levelId || buildingId == levelId) + return fail(eve::DiagnosticCode::InvalidArgument, "ArchSpace bootstrap ids must be unique"); + Node site; + site.id = siteId; + site.kind = NodeKind::Site; + site.name = "Site"; + Node building; + building.id = buildingId; + building.kind = NodeKind::Building; + building.parentId = siteId; + building.name = "Building"; + Node level; + level.id = levelId; + level.kind = NodeKind::Level; + level.parentId = buildingId; + level.name = "Level 0"; + level.height = levelHeight; + Document candidate; + auto inserted = candidate.insert(std::move(site)); + if (!inserted.ok()) return inserted; + inserted = candidate.insert(std::move(building)); + if (!inserted.ok()) return inserted; + inserted = candidate.insert(std::move(level)); + if (!inserted.ok()) return inserted; + *this = std::move(candidate); + return ok(); +} + +eve::Result Document::createRoom(const std::string& levelId, const std::string& roomId, std::string roomName, + std::vector polygon, double wallHeight, double wallThickness, + double slabThickness) { + const Node* level = find(levelId); + if (!level || level->kind != NodeKind::Level) + return fail(eve::DiagnosticCode::NotFound, "ArchSpace room requires an existing level", "levelId"); + if (roomId.empty() || !polygonValid(polygon) || wallHeight <= kEps || wallThickness <= kEps || + slabThickness <= kEps) + return fail(eve::DiagnosticCode::InvalidArgument, "ArchSpace room geometry is invalid", "polygon"); + if (roomName.empty()) roomName = roomId; + + Document candidate = *this; + Node zone; + zone.id = roomId + ".zone"; + zone.kind = NodeKind::Zone; + zone.parentId = levelId; + zone.name = roomName; + zone.polygon = polygon; + Node slab; + slab.id = roomId + ".slab"; + slab.kind = NodeKind::Slab; + slab.parentId = levelId; + slab.name = roomName + " floor"; + slab.polygon = polygon; + slab.slabThickness = slabThickness; + auto inserted = candidate.insert(std::move(zone)); + if (!inserted.ok()) return inserted; + inserted = candidate.insert(std::move(slab)); + if (!inserted.ok()) return inserted; + for (std::size_t i = 0; i < polygon.size(); ++i) { + Node wall; + wall.id = roomId + ".wall." + std::to_string(i); + wall.kind = NodeKind::Wall; + wall.parentId = levelId; + wall.name = roomName + " wall " + std::to_string(i); + wall.start = polygon[i]; + wall.end = polygon[(i + 1) % polygon.size()]; + wall.height = wallHeight; + wall.thickness = wallThickness; + inserted = candidate.insert(std::move(wall)); + if (!inserted.ok()) return inserted; + } + *this = std::move(candidate); + return ok(); +} + +eve::Result Document::createWall(const std::string& levelId, const std::string& wallId, std::string wallName, + Vec2 start, Vec2 end, double height, double thickness) { + const Node* level = find(levelId); + if (!level || level->kind != NodeKind::Level) + return fail(eve::DiagnosticCode::NotFound, "ArchSpace wall requires an existing level", "levelId"); + Node wall; + wall.id = wallId; + wall.kind = NodeKind::Wall; + wall.parentId = levelId; + wall.name = wallName.empty() ? wallId : std::move(wallName); + wall.start = start; + wall.end = end; + wall.height = height; + wall.thickness = thickness; + return insert(std::move(wall)); +} + +eve::Result Document::createOpening(const std::string& wallId, const std::string& openingId, + std::string openingName, OpeningKind kind, double t, double width, + double height, double sill) { + const Node* wall = find(wallId); + if (!wall || wall->kind != NodeKind::Wall) + return fail(eve::DiagnosticCode::NotFound, "ArchSpace opening requires an existing wall", "wallId"); + Node opening; + opening.id = openingId; + opening.kind = NodeKind::Opening; + opening.parentId = wallId; + opening.name = openingName.empty() ? openingId : std::move(openingName); + opening.openingKind = kind; + opening.t = t; + opening.width = width; + opening.openingHeight = height; + opening.sill = sill; + return insert(std::move(opening)); +} + +eve::Result Document::placeItem(const std::string& levelId, const std::string& itemId, std::string itemName, + std::string catalogId, Vec3 position, double yawDegrees) { + const Node* level = find(levelId); + if (!level || level->kind != NodeKind::Level) + return fail(eve::DiagnosticCode::NotFound, "ArchSpace item requires an existing level", "levelId"); + Node item; + item.id = itemId; + item.kind = NodeKind::Item; + item.parentId = levelId; + item.name = itemName.empty() ? itemId : std::move(itemName); + item.catalogId = std::move(catalogId); + item.position = position; + item.yawDegrees = yawDegrees; + return insert(std::move(item)); +} + +std::vector Document::diagnostics() const { + std::vector out; + if (rootId_.empty() && !nodes_.empty()) out.push_back("document has nodes but no root site"); + if (!rootId_.empty() && !find(rootId_)) out.push_back("root id is missing from node map"); + std::unordered_map referenced; + for (const auto& [id, node] : nodes_) { + if (!validateNode(node)) out.push_back("node failed validation: " + id); + for (const auto& child : node.children) { + ++referenced[child]; + const Node* childNode = find(child); + if (!childNode) + out.push_back("missing child " + child + " under " + id); + else if (childNode->parentId != id) + out.push_back("child parent mismatch: " + child); + } + if (node.kind != NodeKind::Site) { + const Node* parent = find(node.parentId); + if (!parent) + out.push_back("missing parent for " + id); + else if (!canParent(node.kind, node.parentId)) + out.push_back("illegal parent kind for " + id); + } + } + for (const auto& [id, node] : nodes_) { + if (node.kind == NodeKind::Site) continue; + if (referenced.find(id) == referenced.end()) out.push_back("orphaned node: " + id); + } + return out; +} + +MeshBake Document::bakeMesh() const { + MeshBake bake; + + for (const auto& [id, node] : nodes_) { + if (node.kind == NodeKind::Wall) { + const Node* level = find(node.parentId); + const double y = level ? level->elevation : 0.0; + std::vector openings; + openings.reserve(node.children.size()); + for (const auto& childId : node.children) { + const Node* child = find(childId); + if (child && child->kind == NodeKind::Opening) openings.push_back(child); + } + appendWallWithOpenings(bake, node, y, openings); + } else if (node.kind == NodeKind::Slab) { + const Node* level = find(node.parentId); + const double y = level ? level->elevation : 0.0; + appendSlab(bake, node.polygon, y, node.slabThickness, id); + } else if (node.kind == NodeKind::Item) { + appendCatalogItem(bake, id, node.catalogId, node.position, node.yawDegrees); + } + } + return bake; +} + +} // namespace eve::archspace diff --git a/src/modules/archspace/ArchSpaceDocument.h b/src/modules/archspace/ArchSpaceDocument.h new file mode 100644 index 00000000..13c8cfd6 --- /dev/null +++ b/src/modules/archspace/ArchSpaceDocument.h @@ -0,0 +1,120 @@ +#pragma once + +#include "archspace/ArchSpaceTypes.h" +#include "common/Result.h" + +#include +#include +#include + +namespace eve::archspace { + +/** + * @brief Authoritative architectural space document (Pascal-like node graph). + * + * @ownership The document exclusively owns its nodes. Borrowed pointers returned by + * find() are invalidated by any mutating call. + * @thread Owner thread only. + * @failure Mutators return a checked Result and leave the previous document state unchanged. + */ +class Document { +public: + Document() = default; + + [[nodiscard]] const std::string& rootId() const noexcept { return rootId_; } + [[nodiscard]] const std::unordered_map& nodes() const noexcept { return nodes_; } + [[nodiscard]] std::size_t nodeCount() const noexcept { return nodes_.size(); } + + /** + * @brief Borrow a node by id. + * @ownership Non-owning pointer into this document's node map; do not free. + * @lifetime Until the next mutating call. + */ + [[nodiscard]] const Node* find(const std::string& id) const; + /** + * @brief Borrow a mutable node by id. + * @ownership Non-owning pointer into this document's node map; do not free. + * @lifetime Until the next structural mutation. + */ + [[nodiscard]] Node* findMutable(const std::string& id); + + /** + * @brief Insert a validated node and link it into the parent child list. + * @return Applied on success; Rejected when id/kind/parent/geometry rules fail. + */ + [[nodiscard]] eve::Result insert(Node node); + /** + * @brief Replace authored fields while preserving id, kind, parent and children. + * @return Applied on success; NotFound/Rejected when missing or invalid. + */ + [[nodiscard]] eve::Result replace(Node node); + /** + * @brief Delete a node and every descendant. Root deletion clears the document. + * @return Applied on success; NotFound when the id is unknown. + */ + [[nodiscard]] eve::Result eraseCascade(const std::string& id); + /** @brief Remove every node. */ + void clear(); + + /** + * @brief Create site → building → level skeleton when the document is empty. + * @return Applied on success; Rejected when the document already has a root or ids collide. + */ + [[nodiscard]] eve::Result bootstrap(const std::string& siteId, const std::string& buildingId, + const std::string& levelId, double levelHeight = 3.0); + + /** + * @brief Create a closed room: perimeter walls + slab + zone under an existing level. + * @param polygon Level-local XZ loop; the first vertex should not be repeated. + * @return Applied on success; Rejected when the level is missing or geometry is invalid. + */ + [[nodiscard]] eve::Result createRoom(const std::string& levelId, const std::string& roomId, + std::string roomName, std::vector polygon, double wallHeight, + double wallThickness, double slabThickness); + + /** + * @brief Insert a single wall under an existing level. + * @return Applied on success; Rejected when the level is missing or geometry is invalid. + */ + [[nodiscard]] eve::Result createWall(const std::string& levelId, const std::string& wallId, + std::string wallName, Vec2 start, Vec2 end, double height, + double thickness); + + /** + * @brief Insert a door/window opening on an existing wall. + * @return Applied on success; Rejected when the wall is missing or parameters are invalid. + */ + [[nodiscard]] eve::Result createOpening(const std::string& wallId, const std::string& openingId, + std::string openingName, OpeningKind kind, double t, double width, + double height, double sill = 0.0); + + /** + * @brief Place a catalog item on an existing level. + * @return Applied on success; Rejected when the level is missing or placement is invalid. + */ + [[nodiscard]] eve::Result placeItem(const std::string& levelId, const std::string& itemId, + std::string itemName, std::string catalogId, Vec3 position, + double yawDegrees = 0.0); + + /** @brief Collect structural and geometric diagnostics as human-readable lines. */ + [[nodiscard]] std::vector diagnostics() const; + + /** + * @brief Build wall boxes (with opening cutouts), slabs and item markers for viewport upload. + */ + [[nodiscard]] MeshBake bakeMesh() const; + + /** @brief Bake and pack into GPU-upload float arrays with per-triangle normals. */ + [[nodiscard]] MeshArrays bakeMeshArrays() const { return toMeshArrays(bakeMesh()); } + +private: + [[nodiscard]] bool canParent(NodeKind child, const std::string& parentId) const; + [[nodiscard]] bool validateNode(const Node& node) const; + void unlinkFromParent(const std::string& id); + void collectDescendants(const std::string& id, std::vector& out) const; + + std::string rootId_; + std::unordered_map nodes_; +}; + +} // namespace eve::archspace diff --git a/src/modules/archspace/ArchSpaceTypes.h b/src/modules/archspace/ArchSpaceTypes.h new file mode 100644 index 00000000..a0870b8f --- /dev/null +++ b/src/modules/archspace/ArchSpaceTypes.h @@ -0,0 +1,190 @@ +#pragma once + +#include "common/Result.h" + +#include +#include +#include +#include + +namespace eve::archspace { + +/** @brief Stable node kind discriminator for the architectural document. */ +enum class NodeKind : std::uint8_t { Site = 0, Building, Level, Wall, Slab, Zone, Item, Opening }; + +/** @brief Opening semantic used by doors and windows hosted on walls. */ +enum class OpeningKind : std::uint8_t { Door = 0, Window }; + +/** @brief Metres on the level XZ plane. */ +struct Vec2 { + double x = 0.0; + double z = 0.0; +}; + +/** @brief Metres in world/level space. */ +struct Vec3 { + double x = 0.0; + double y = 0.0; + double z = 0.0; +}; + +/** @brief One authored architectural node. Only fields for the active kind are meaningful. */ +struct Node { + std::string id; + NodeKind kind = NodeKind::Site; + std::string parentId; + std::string name; + std::vector children; + + // level + double elevation = 0.0; + double height = 3.0; + + // wall + Vec2 start{}; + Vec2 end{}; + double thickness = 0.2; + + // slab / zone + std::vector polygon; + double slabThickness = 0.2; + + // item + Vec3 position{}; + double yawDegrees = 0.0; + std::string catalogId; + + // opening + OpeningKind openingKind = OpeningKind::Door; + double t = 0.5; + double width = 0.9; + double openingHeight = 2.1; + double sill = 0.0; +}; + +/** @brief Renderer-neutral triangle mesh rebuilt from the document. */ +struct MeshBake { + std::vector positions; + std::vector indices; + std::vector primitiveIds; +}; + +/** + * @brief Interleaved GPU-upload arrays derived from MeshBake. + * + * positions/normals/uvs are xyz / xyz / uv packed floats. indices are triangles. + */ +struct MeshArrays { + std::vector positions; + std::vector normals; + std::vector uvs; + std::vector indices; + + [[nodiscard]] int vertexCount() const noexcept { + return static_cast(positions.size() / 3); + } + [[nodiscard]] int indexCount() const noexcept { return static_cast(indices.size()); } + [[nodiscard]] int triangleCount() const noexcept { return indexCount() / 3; } +}; + +/** @brief Convert a MeshBake into flat arrays with per-triangle normals. */ +[[nodiscard]] inline MeshArrays toMeshArrays(const MeshBake& bake) { + MeshArrays out; + out.positions.reserve(bake.positions.size() * 3); + out.normals.reserve(bake.positions.size() * 3); + out.uvs.reserve(bake.positions.size() * 2); + out.indices = bake.indices; + for (const Vec3& p : bake.positions) { + out.positions.push_back(static_cast(p.x)); + out.positions.push_back(static_cast(p.y)); + out.positions.push_back(static_cast(p.z)); + out.normals.push_back(0.f); + out.normals.push_back(1.f); + out.normals.push_back(0.f); + out.uvs.push_back(static_cast(p.x)); + out.uvs.push_back(static_cast(p.z)); + } + for (std::size_t i = 0; i + 2 < bake.indices.size(); i += 3) { + const std::uint32_t i0 = bake.indices[i], i1 = bake.indices[i + 1], i2 = bake.indices[i + 2]; + if (i0 >= bake.positions.size() || i1 >= bake.positions.size() || i2 >= bake.positions.size()) continue; + const Vec3& a = bake.positions[i0]; + const Vec3& b = bake.positions[i1]; + const Vec3& c = bake.positions[i2]; + Vec3 n{ (b.y - a.y) * (c.z - a.z) - (b.z - a.z) * (c.y - a.y), + (b.z - a.z) * (c.x - a.x) - (b.x - a.x) * (c.z - a.z), + (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) }; + const double len = std::sqrt(n.x * n.x + n.y * n.y + n.z * n.z); + if (len > 1e-8) { + n.x /= len; + n.y /= len; + n.z /= len; + } else { + n = {0, 1, 0}; + } + for (std::uint32_t idx : {i0, i1, i2}) { + out.normals[idx * 3 + 0] = static_cast(n.x); + out.normals[idx * 3 + 1] = static_cast(n.y); + out.normals[idx * 3 + 2] = static_cast(n.z); + } + } + return out; +} + +/** + * @brief Stable wire name for a node kind. + * @ownership Non-owning pointer to a static string literal; do not free. + * @lifetime Valid for the process lifetime. + */ +[[nodiscard]] inline const char* nodeKindName(NodeKind kind) noexcept { + switch (kind) { + case NodeKind::Site: return "site"; + case NodeKind::Building: return "building"; + case NodeKind::Level: return "level"; + case NodeKind::Wall: return "wall"; + case NodeKind::Slab: return "slab"; + case NodeKind::Zone: return "zone"; + case NodeKind::Item: return "item"; + case NodeKind::Opening: return "opening"; + } + return "site"; +} + +/** + * @brief Parse a node kind wire name. + * @return Parsed kind, or InvalidArgument when the text is unknown. + */ +[[nodiscard]] inline eve::Result parseNodeKind(const std::string& text) noexcept { + if (text == "site") return eve::Result::success(NodeKind::Site); + if (text == "building") return eve::Result::success(NodeKind::Building); + if (text == "level") return eve::Result::success(NodeKind::Level); + if (text == "wall") return eve::Result::success(NodeKind::Wall); + if (text == "slab") return eve::Result::success(NodeKind::Slab); + if (text == "zone") return eve::Result::success(NodeKind::Zone); + if (text == "item") return eve::Result::success(NodeKind::Item); + if (text == "opening") return eve::Result::success(NodeKind::Opening); + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "unknown ArchSpace node kind", "kind", {}, "archspace.kind")); +} + +/** + * @brief Stable wire name for an opening kind. + * @ownership Non-owning pointer to a static string literal; do not free. + * @lifetime Valid for the process lifetime. + */ +[[nodiscard]] inline const char* openingKindName(OpeningKind kind) noexcept { + return kind == OpeningKind::Window ? "window" : "door"; +} + +/** + * @brief Parse an opening kind wire name. + * @return Parsed kind, or InvalidArgument when the text is unknown. + */ +[[nodiscard]] inline eve::Result parseOpeningKind(const std::string& text) noexcept { + if (text == "door") return eve::Result::success(OpeningKind::Door); + if (text == "window") return eve::Result::success(OpeningKind::Window); + return eve::Result::failure(eve::Diagnostic::error(eve::DiagnosticCode::InvalidArgument, + "unknown ArchSpace opening kind", "openingKind", {}, + "archspace.opening")); +} + +} // namespace eve::archspace diff --git a/src/modules/archspace/editing/ArchSpaceEditingCommands.cpp b/src/modules/archspace/editing/ArchSpaceEditingCommands.cpp new file mode 100644 index 00000000..8fb6c890 --- /dev/null +++ b/src/modules/archspace/editing/ArchSpaceEditingCommands.cpp @@ -0,0 +1,210 @@ +#include "archspace/editing/ArchSpaceEditingCommands.h" + +#include "archspace/editing/ArchSpaceTarget.h" +#include "editing/EditingProperty.h" + +#include +#include +#include + +namespace eve::archspace_editing { +namespace { + +const editing::Value* field(const editing::Value& value, const char* key) { + const auto* object = value.getIf(); + if (!object) return nullptr; + const auto found = object->find(key); + return found == object->end() ? nullptr : &found->second; +} + +const std::string* stringField(const editing::Value& value, const char* key) { + const editing::Value* entry = field(value, key); + return entry ? entry->getIf() : nullptr; +} + +bool readNumber(const editing::Value& value, const char* key, double& output, bool required) { + const editing::Value* entry = field(value, key); + if (!entry) return !required; + if (const auto* real = entry->getIf()) { + output = *real; + return std::isfinite(output); + } + if (const auto* integer = entry->getIf()) { + output = static_cast(*integer); + return true; + } + return false; +} + +template +editing::Result error(editing::Status status, const char* rule, std::string message) { + return eve::editing::failed(status, editing::RuleId(rule), std::move(message)); +} + +editing::Result addCommand(editing::IEditingCommandRegistry& registry, const char* id, const char* displayName, + editing::EditingCommandPlanner planner) { + editing::EditingCommandDescriptor descriptor; + descriptor.id = editing::CommandId(id); + descriptor.ownerModule = "archspace_editing"; + descriptor.displayName = displayName; + descriptor.category = "ArchSpace"; + descriptor.automationAllowed = true; + return registry.registerPlannedCommand(std::move(descriptor), std::move(planner)); +} + +ArchSpaceDocumentTarget* asArchSpace(editing::IEditableTarget& target) { + return dynamic_cast(&target); +} + +editing::Result planFrom(editing::Result operation, + editing::Value summary) { + if (!operation.ok()) + return error(operation.code(), "archspace.editing.operation", + "ArchSpace target rejected the planned edit"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation).value()); + plan.summary = std::move(summary); + return eve::editing::applied(std::move(plan)); +} + +} // namespace + +editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry) { + auto registered = addCommand(registry, "archspace.bootstrap.v1", "Bootstrap ArchSpace site", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* doc = asArchSpace(target); + const std::string* site = stringField(request.payload, "siteId"); + const std::string* building = stringField(request.payload, "buildingId"); + const std::string* level = stringField(request.payload, "levelId"); + double height = 3.0; + if (!doc || !site || site->empty() || !building || building->empty() || !level || + level->empty() || !readNumber(request.payload, "levelHeight", height, false)) + return error( + editing::Status::Rejected, "archspace.editing.bootstrap", + "ArchSpace bootstrap requires siteId, buildingId and levelId"); + return planFrom(doc->makeBootstrap(*site, *building, *level, height), + editing::Value::Object{{"levelId", *level}}); + }); + if (!registered.ok()) return registered; + + registered = addCommand( + registry, "archspace.room.create.v1", "Create ArchSpace rectangular room", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* doc = asArchSpace(target); + const std::string* level = stringField(request.payload, "levelId"); + const std::string* room = stringField(request.payload, "roomId"); + const std::string* name = stringField(request.payload, "name"); + double originX = 0, originZ = 0, sizeX = 0, sizeZ = 0, wallHeight = 3, wallThickness = 0.2, + slabThickness = 0.2; + if (!doc || !level || level->empty() || !room || room->empty() || + !readNumber(request.payload, "originX", originX, true) || + !readNumber(request.payload, "originZ", originZ, true) || + !readNumber(request.payload, "sizeX", sizeX, true) || + !readNumber(request.payload, "sizeZ", sizeZ, true) || + !readNumber(request.payload, "wallHeight", wallHeight, false) || + !readNumber(request.payload, "wallThickness", wallThickness, false) || + !readNumber(request.payload, "slabThickness", slabThickness, false)) + return error(editing::Status::Rejected, "archspace.editing.room", + "ArchSpace room create requires levelId, roomId and size"); + return planFrom(doc->makeCreateRectRoom(*level, *room, name ? *name : *room, originX, originZ, sizeX, sizeZ, + wallHeight, wallThickness, slabThickness), + editing::Value::Object{{"roomId", *room}}); + }); + if (!registered.ok()) return registered; + + registered = addCommand( + registry, "archspace.wall.create.v1", "Create ArchSpace wall", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* doc = asArchSpace(target); + const std::string* level = stringField(request.payload, "levelId"); + const std::string* wall = stringField(request.payload, "wallId"); + const std::string* name = stringField(request.payload, "name"); + double x0 = 0, z0 = 0, x1 = 0, z1 = 0, height = 3.0, thickness = 0.2; + if (!doc || !level || level->empty() || !wall || wall->empty() || + !readNumber(request.payload, "startX", x0, true) || + !readNumber(request.payload, "startZ", z0, true) || + !readNumber(request.payload, "endX", x1, true) || !readNumber(request.payload, "endZ", z1, true) || + !readNumber(request.payload, "height", height, false) || + !readNumber(request.payload, "thickness", thickness, false)) + return error(editing::Status::Rejected, "archspace.editing.wall", + "ArchSpace wall create requires levelId, wallId and endpoints"); + return planFrom(doc->makeCreateWall(*level, *wall, name ? *name : *wall, archspace::Vec2{x0, z0}, + archspace::Vec2{x1, z1}, height, thickness), + editing::Value::Object{{"wallId", *wall}}); + }); + if (!registered.ok()) return registered; + + registered = addCommand( + registry, "archspace.opening.create.v1", "Create ArchSpace opening", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* doc = asArchSpace(target); + const std::string* wall = stringField(request.payload, "wallId"); + const std::string* opening = stringField(request.payload, "openingId"); + const std::string* kindText = stringField(request.payload, "kind"); + double t = 0.5, width = 0.9, height = 2.1, sill = 0.0; + if (!doc || !wall || wall->empty() || !opening || opening->empty() || !kindText || + !readNumber(request.payload, "t", t, false) || !readNumber(request.payload, "width", width, false) || + !readNumber(request.payload, "height", height, false) || + !readNumber(request.payload, "sill", sill, false)) + return error(editing::Status::Rejected, "archspace.editing.opening", + "ArchSpace opening requires wallId, openingId and kind"); + auto kind = archspace::parseOpeningKind(*kindText); + if (!kind.ok()) + return error(editing::Status::Rejected, "archspace.editing.opening", + "ArchSpace opening requires wallId, openingId and kind"); + return planFrom(doc->makeCreateOpening(*wall, *opening, kind.value(), t, width, height, sill), + editing::Value::Object{{"openingId", *opening}}); + }); + if (!registered.ok()) return registered; + + registered = addCommand( + registry, "archspace.item.place.v1", "Place ArchSpace item", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* doc = asArchSpace(target); + const std::string* level = stringField(request.payload, "levelId"); + const std::string* item = stringField(request.payload, "itemId"); + const std::string* catalog = stringField(request.payload, "catalogId"); + double x = 0, y = 0, z = 0, yaw = 0; + if (!doc || !level || level->empty() || !item || item->empty() || !catalog || catalog->empty() || + !readNumber(request.payload, "x", x, true) || !readNumber(request.payload, "y", y, false) || + !readNumber(request.payload, "z", z, true) || !readNumber(request.payload, "yawDegrees", yaw, false)) + return error(editing::Status::Rejected, "archspace.editing.item", + "ArchSpace item place requires levelId, itemId, catalogId, x, z"); + return planFrom(doc->makePlaceItem(*level, *item, *catalog, archspace::Vec3{x, y, z}, yaw), + editing::Value::Object{{"itemId", *item}}); + }); + if (!registered.ok()) return registered; + + registered = addCommand( + registry, "archspace.property.set.v1", "Set ArchSpace property", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* doc = asArchSpace(target); + const std::string* object = stringField(request.payload, "objectId"); + const std::string* path = stringField(request.payload, "path"); + const editing::Value* value = field(request.payload, "value"); + if (!doc || !object || object->empty() || !path || path->empty() || !value) + return error(editing::Status::Rejected, "archspace.editing.property", + "ArchSpace property set requires objectId, path and value"); + editing::SelectionSnapshot selection; + selection.channel = "archspace"; + selection.items.push_back({editing::SelectionDomain::Asset, doc->targetId(), editing::StableId(*object), + std::string("archspace.node")}); + return planFrom(doc->makeSet(selection, editing::PropertyPath(*path), *value, + editing::PropertySetMode::Absolute), + editing::Value::Object{{"objectId", *object}, {"path", *path}}); + }); + if (!registered.ok()) return registered; + + return addCommand(registry, "archspace.node.delete.v1", "Delete ArchSpace node", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* doc = asArchSpace(target); + const std::string* id = stringField(request.payload, "id"); + if (!doc || !id || id->empty()) + return error(editing::Status::Rejected, "archspace.editing.delete", + "ArchSpace delete requires id"); + return planFrom(doc->makeDeleteNode(editing::ObjectId(*id)), + editing::Value::Object{{"id", *id}}); + }); +} + +} // namespace eve::archspace_editing diff --git a/src/modules/archspace/editing/ArchSpaceEditingCommands.h b/src/modules/archspace/editing/ArchSpaceEditingCommands.h new file mode 100644 index 00000000..d17d30d9 --- /dev/null +++ b/src/modules/archspace/editing/ArchSpaceEditingCommands.h @@ -0,0 +1,10 @@ +#pragma once + +#include "editing/EditingCommandRegistry.h" + +namespace eve::archspace_editing { + +/** @brief Register ArchSpace-owned planned commands with a generic editing host. */ +[[nodiscard]] editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry); + +} // namespace eve::archspace_editing diff --git a/src/modules/archspace/editing/ArchSpaceTarget.cpp b/src/modules/archspace/editing/ArchSpaceTarget.cpp new file mode 100644 index 00000000..e898833d --- /dev/null +++ b/src/modules/archspace/editing/ArchSpaceTarget.cpp @@ -0,0 +1,627 @@ +#include "archspace/editing/ArchSpaceTarget.h" + +#include +#include +#include + +namespace eve::archspace_editing { +namespace { + +template +EditorResult fail(EditorStatus status, const char* rule, std::string message) { + return eve::editing::failed(status, RuleId(rule), std::move(message)); +} + +const EditorValue* field(const EditorValue& value, const char* key) { + const auto* object = value.getIf(); + if (!object) return nullptr; + const auto it = object->find(key); + return it == object->end() ? nullptr : &it->second; +} + +bool readNumber(const EditorValue* value, double& out) { + if (!value) return false; + if (const auto* real = value->getIf()) { + out = *real; + return std::isfinite(out); + } + if (const auto* integer = value->getIf()) { + out = static_cast(*integer); + return true; + } + return false; +} + +EditorValue vec2Value(const archspace::Vec2& v) { return EditorValue::Array{v.x, v.z}; } + +EditorValue vec3Value(const archspace::Vec3& v) { return EditorValue::Array{v.x, v.y, v.z}; } + +bool parseVec2(const EditorValue& value, archspace::Vec2& out) { + const auto* array = value.getIf(); + if (!array || array->size() != 2) return false; + return readNumber(&(*array)[0], out.x) && readNumber(&(*array)[1], out.z); +} + +bool parseVec3(const EditorValue& value, archspace::Vec3& out) { + const auto* array = value.getIf(); + if (!array || array->size() != 3) return false; + return readNumber(&(*array)[0], out.x) && readNumber(&(*array)[1], out.y) && readNumber(&(*array)[2], out.z); +} + +EditorValue nodeValue(const archspace::Node& node) { + EditorValue::Array children; + for (const auto& child : node.children) children.push_back(child); + EditorValue::Array polygon; + for (const auto& p : node.polygon) polygon.push_back(vec2Value(p)); + return EditorValue::Object{ + {"id", node.id}, + {"kind", std::string(archspace::nodeKindName(node.kind))}, + {"parentId", node.parentId}, + {"name", node.name}, + {"children", std::move(children)}, + {"elevation", node.elevation}, + {"height", node.height}, + {"start", vec2Value(node.start)}, + {"end", vec2Value(node.end)}, + {"thickness", node.thickness}, + {"polygon", std::move(polygon)}, + {"slabThickness", node.slabThickness}, + {"position", vec3Value(node.position)}, + {"yawDegrees", node.yawDegrees}, + {"catalogId", node.catalogId}, + {"openingKind", std::string(archspace::openingKindName(node.openingKind))}, + {"t", node.t}, + {"width", node.width}, + {"openingHeight", node.openingHeight}, + {"sill", node.sill}, + }; +} + +EditorResult parseNode(const EditorValue& value) { + archspace::Node node; + const auto* id = field(value, "id"); + const auto* kind = field(value, "kind"); + const auto* parentId = field(value, "parentId"); + const auto* name = field(value, "name"); + const auto* ids = id ? id->getIf() : nullptr; + const auto* kinds = kind ? kind->getIf() : nullptr; + const auto* parents = parentId ? parentId->getIf() : nullptr; + const auto* names = name ? name->getIf() : nullptr; + if (!ids || ids->empty() || !kinds || !parents || !names) + return fail(EditorStatus::Rejected, "editor.archspace.node", + "ArchSpace node identity is invalid"); + auto parsedKind = archspace::parseNodeKind(*kinds); + if (!parsedKind.ok()) + return fail(EditorStatus::Rejected, "editor.archspace.kind", "ArchSpace node kind is unknown"); + node.kind = parsedKind.value(); + node.id = *ids; + node.parentId = *parents; + node.name = *names; + if (const auto* children = field(value, "children")) { + const auto* array = children->getIf(); + if (!array) + return fail(EditorStatus::Rejected, "editor.archspace.children", + "ArchSpace children must be an array"); + for (const auto& child : *array) { + const auto* text = child.getIf(); + if (!text) + return fail(EditorStatus::Rejected, "editor.archspace.children", + "ArchSpace child id must be a string"); + node.children.push_back(*text); + } + } + readNumber(field(value, "elevation"), node.elevation); + readNumber(field(value, "height"), node.height); + if (const auto* start = field(value, "start"); start && !parseVec2(*start, node.start)) + return fail(EditorStatus::Rejected, "editor.archspace.wall", + "ArchSpace wall start is invalid"); + if (const auto* end = field(value, "end"); end && !parseVec2(*end, node.end)) + return fail(EditorStatus::Rejected, "editor.archspace.wall", "ArchSpace wall end is invalid"); + readNumber(field(value, "thickness"), node.thickness); + if (const auto* polygon = field(value, "polygon")) { + const auto* array = polygon->getIf(); + if (!array) + return fail(EditorStatus::Rejected, "editor.archspace.polygon", + "ArchSpace polygon must be an array"); + for (const auto& point : *array) { + archspace::Vec2 v; + if (!parseVec2(point, v)) + return fail(EditorStatus::Rejected, "editor.archspace.polygon", + "ArchSpace polygon point is invalid"); + node.polygon.push_back(v); + } + } + readNumber(field(value, "slabThickness"), node.slabThickness); + if (const auto* position = field(value, "position"); position && !parseVec3(*position, node.position)) + return fail(EditorStatus::Rejected, "editor.archspace.item", + "ArchSpace item position is invalid"); + readNumber(field(value, "yawDegrees"), node.yawDegrees); + if (const auto* catalog = field(value, "catalogId")) { + const auto* text = catalog->getIf(); + if (!text) + return fail(EditorStatus::Rejected, "editor.archspace.item", + "ArchSpace catalog id must be a string"); + node.catalogId = *text; + } + if (const auto* opening = field(value, "openingKind")) { + const auto* text = opening->getIf(); + if (!text) + return fail(EditorStatus::Rejected, "editor.archspace.opening", + "ArchSpace opening kind must be a string"); + auto kind = archspace::parseOpeningKind(*text); + if (!kind.ok()) + return fail(EditorStatus::Rejected, "editor.archspace.opening", + "ArchSpace opening kind is invalid"); + node.openingKind = kind.value(); + } + readNumber(field(value, "t"), node.t); + readNumber(field(value, "width"), node.width); + readNumber(field(value, "openingHeight"), node.openingHeight); + readNumber(field(value, "sill"), node.sill); + return eve::editing::applied(std::move(node)); +} + +PropertyDescriptor prop(std::string path, PropertyType type, EditorValue defaultValue, const char* category, + double minimum = -1e9, double maximum = 1e9) { + PropertyDescriptor d; + d.path = PropertyPath(std::move(path)); + d.displayNameKey = "editor.archspace." + d.path.value(); + d.category = category; + d.type = type; + d.defaultValue = std::move(defaultValue); + d.flags = PropertyFlag::EditorOnly; + d.numeric.minimum = minimum; + d.numeric.maximum = maximum; + return d; +} + +bool hasErrors(const std::vector& values) { + return std::any_of(values.begin(), values.end(), + [](const auto& d) { return d.severity() == DiagnosticSeverity::Error; }); +} + +} // namespace + +ArchSpaceDocumentTarget::ArchSpaceDocumentTarget(std::string id) : id_(std::move(id)) {} + +TargetDescriptor ArchSpaceDocumentTarget::describe() const { + return {TargetId(id_), + "archspace-document", + revision_, + false, + {propertyCapabilityId(), IEditingSnapshotProvider::editingCapabilityId()}}; +} + +void* ArchSpaceDocumentTarget::queryCapability(const CapabilityId& capability) { + if (capability == propertyCapabilityId()) return static_cast(this); + if (capability == IEditingSnapshotProvider::editingCapabilityId()) + return static_cast(this); + return nullptr; +} + +EditorValue ArchSpaceDocumentTarget::snapshotValue() const { + return EditorValue::Object{{"schemaId", std::string("eve.archspace.document")}, + {"schemaVersion", int64_t{1}}, + {"content", contentValue()}}; +} + +bool ArchSpaceDocumentTarget::matches(const SelectionSnapshot& selection) const { + if (selection.items.empty()) return false; + for (const auto& item : selection.items) { + if (item.target != TargetId(id_) || !document_.find(item.item.value())) return false; + } + return true; +} + +const archspace::Node* ArchSpaceDocumentTarget::selectedNode(const SelectionSnapshot& selection) const { + if (selection.items.size() != 1 || selection.items.front().target != TargetId(id_)) return nullptr; + return document_.find(selection.items.front().item.value()); +} + +eve::Result ArchSpaceDocumentTarget::currentRevision(const SelectionSnapshot& selection) const { + if (!matches(selection)) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "ArchSpace selection mismatch", "editor.archspace.selection")); + return eve::Result::success(eve::Revision(revision_)); +} + +PropertySchema ArchSpaceDocumentTarget::schema(const SelectionSnapshot& selection) const { + PropertySchema schema; + const archspace::Node* node = selectedNode(selection); + if (!node) return schema; + schema.typeId = std::string("archspace.") + archspace::nodeKindName(node->kind); + schema.properties.push_back(prop("name", PropertyType::String, node->name, "common")); + switch (node->kind) { + case archspace::NodeKind::Level: + schema.properties.push_back(prop("elevation", PropertyType::Float, node->elevation, "level")); + schema.properties.push_back(prop("height", PropertyType::Float, node->height, "level", 0.1, 1000)); + break; + case archspace::NodeKind::Wall: + schema.properties.push_back(prop("thickness", PropertyType::Float, node->thickness, "wall", 0.01, 10)); + schema.properties.push_back(prop("height", PropertyType::Float, node->height, "wall", 0.1, 1000)); + break; + case archspace::NodeKind::Item: + schema.properties.push_back(prop("catalogId", PropertyType::String, node->catalogId, "item")); + schema.properties.push_back(prop("yawDegrees", PropertyType::Float, node->yawDegrees, "item")); + break; + case archspace::NodeKind::Opening: + schema.properties.push_back(prop("t", PropertyType::Float, node->t, "opening", 0, 1)); + schema.properties.push_back(prop("width", PropertyType::Float, node->width, "opening", 0.01, 20)); + schema.properties.push_back( + prop("openingHeight", PropertyType::Float, node->openingHeight, "opening", 0.01, 20)); + schema.properties.push_back(prop("sill", PropertyType::Float, node->sill, "opening", 0, 20)); + break; + default: break; + } + return schema; +} + +PropertyReadResult ArchSpaceDocumentTarget::read(const SelectionSnapshot& selection, const PropertyPath& path) const { + const archspace::Node* node = selectedNode(selection); + if (!node || !schema(selection).find(path)) return {}; + if (path == PropertyPath("name")) return {PropertyReadState::Value, node->name, {}}; + if (path == PropertyPath("elevation")) return {PropertyReadState::Value, node->elevation, {}}; + if (path == PropertyPath("height")) return {PropertyReadState::Value, node->height, {}}; + if (path == PropertyPath("thickness")) return {PropertyReadState::Value, node->thickness, {}}; + if (path == PropertyPath("catalogId")) return {PropertyReadState::Value, node->catalogId, {}}; + if (path == PropertyPath("yawDegrees")) return {PropertyReadState::Value, node->yawDegrees, {}}; + if (path == PropertyPath("t")) return {PropertyReadState::Value, node->t, {}}; + if (path == PropertyPath("width")) return {PropertyReadState::Value, node->width, {}}; + if (path == PropertyPath("openingHeight")) return {PropertyReadState::Value, node->openingHeight, {}}; + if (path == PropertyPath("sill")) return {PropertyReadState::Value, node->sill, {}}; + return {}; +} + +EditorValue ArchSpaceDocumentTarget::contentValue() const { + EditorValue::Array nodes; + for (const auto& [id, node] : document_.nodes()) { + (void)id; + nodes.push_back(nodeValue(node)); + } + return EditorValue::Object{{"rootId", document_.rootId()}, {"nodes", std::move(nodes)}}; +} + +EditorResult ArchSpaceDocumentTarget::replacement(EditorValue content, std::string property) const { + DomainOperation op; + op.type = "archspace.document.replace.v1"; + op.inverseType = op.type; + op.target = TargetId(id_); + op.payload = std::move(content); + op.inverse = contentValue(); + op.hasInverse = true; + if (!property.empty()) op.affectedProperties.push_back(property); + op.mergeKey = "archspace:" + id_ + ":" + (property.empty() ? "structure" : property); + return eve::editing::applied(std::move(op)); +} + +EditorResult ArchSpaceDocumentTarget::installContent(const EditorValue& content) { + const auto* rootIdValue = field(content, "rootId"); + const auto* nodesValue = field(content, "nodes"); + const auto* rootId = rootIdValue ? rootIdValue->getIf() : nullptr; + const auto* nodes = nodesValue ? nodesValue->getIf() : nullptr; + if (!rootId || !nodes || !content.isWithinLimits(12, 250000, 32 * 1024 * 1024)) + return fail(EditorStatus::Rejected, "editor.archspace.payload", + "ArchSpace document payload is invalid or exceeds limits"); + if (nodes->size() > 100000) + return fail(EditorStatus::Rejected, "editor.archspace.budget", "ArchSpace document exceeds 100000 nodes"); + + archspace::Document candidate; + if (rootId->empty() && nodes->empty()) { + document_ = std::move(candidate); + return eve::editing::applied(); + } + // Insert sites first, then remaining nodes in dependency-friendly passes. + std::vector parsed; + parsed.reserve(nodes->size()); + for (const auto& value : *nodes) { + auto node = parseNode(value); + if (!node.ok()) + return fail(EditorStatus::Rejected, "editor.archspace.node", + "ArchSpace document contains an invalid node"); + parsed.push_back(std::move(node.value())); + } + std::sort(parsed.begin(), parsed.end(), [](const archspace::Node& a, const archspace::Node& b) { + return static_cast(a.kind) < static_cast(b.kind); + }); + for (auto& node : parsed) { + node.children.clear(); // re-linked by insert() + auto inserted = candidate.insert(std::move(node)); + if (!inserted.ok()) + return fail(EditorStatus::Rejected, "editor.archspace.insert", + "ArchSpace document failed hierarchical insert"); + } + if (candidate.rootId() != *rootId) + return fail(EditorStatus::Rejected, "editor.archspace.root", + "ArchSpace root id does not match document content"); + if (!candidate.diagnostics().empty()) + return fail(EditorStatus::Rejected, "editor.archspace.invalid", "ArchSpace document validation failed"); + document_ = std::move(candidate); + return eve::editing::applied(); +} + +std::vector ArchSpaceDocumentTarget::validate() const { + std::vector out; + for (const auto& line : document_.diagnostics()) { + out.push_back(eve::editing::ruleDiagnostic(eve::DiagnosticCode::PreconditionViolation, + RuleId("editor.archspace.validate"), DiagnosticSeverity::Error, + line)); + } + if (document_.nodeCount() > 100000) { + out.push_back(eve::editing::ruleDiagnostic(eve::DiagnosticCode::PreconditionViolation, + RuleId("editor.archspace.budget"), DiagnosticSeverity::Error, + "ArchSpace document exceeds 100000 nodes")); + } + return out; +} + +EditorResult ArchSpaceDocumentTarget::makeSet(const SelectionSnapshot& selection, + const PropertyPath& path, const EditorValue& value, + PropertySetMode mode) const { + if (mode == PropertySetMode::Reset) return makeReset(selection, path); + const auto descriptor = schema(selection).find(path); + const archspace::Node* node = selectedNode(selection); + if (!descriptor || !node || mode != PropertySetMode::Absolute) + return fail(EditorStatus::Rejected, "editor.archspace.set", + "ArchSpace property requires a matching absolute edit"); + auto checked = validatePropertyValue(*descriptor, value); + if (!checked.ok()) + return fail(EditorStatus::Rejected, "editor.archspace.value", + "ArchSpace property value is invalid"); + archspace::Document candidate = document_; + archspace::Node* editable = candidate.findMutable(node->id); + if (!editable) + return fail(EditorStatus::NotFound, "editor.archspace.node", "ArchSpace node is missing"); + if (path == PropertyPath("name")) { + const auto* text = value.getIf(); + if (!text) return fail(EditorStatus::Rejected, "editor.archspace.name", "Name must be string"); + editable->name = *text; + } else if (path == PropertyPath("catalogId")) { + const auto* text = value.getIf(); + if (!text) + return fail(EditorStatus::Rejected, "editor.archspace.catalog", + "Catalog id must be string"); + editable->catalogId = *text; + } else { + double number = 0.0; + if (!readNumber(&value, number)) + return fail(EditorStatus::Rejected, "editor.archspace.number", + "ArchSpace property requires a number"); + if (path == PropertyPath("elevation")) + editable->elevation = number; + else if (path == PropertyPath("height")) + editable->height = number; + else if (path == PropertyPath("thickness")) + editable->thickness = number; + else if (path == PropertyPath("yawDegrees")) + editable->yawDegrees = number; + else if (path == PropertyPath("t")) + editable->t = number; + else if (path == PropertyPath("width")) + editable->width = number; + else if (path == PropertyPath("openingHeight")) + editable->openingHeight = number; + else if (path == PropertyPath("sill")) + editable->sill = number; + else + return fail(EditorStatus::Unsupported, "editor.archspace.property", + "Unsupported ArchSpace property"); + } + auto replaced = candidate.replace(*editable); + if (!replaced.ok()) + return fail(EditorStatus::Rejected, "editor.archspace.invalid", + "ArchSpace property update failed validation"); + ArchSpaceDocumentTarget staged(id_); + staged.document_ = std::move(candidate); + if (hasErrors(staged.validate())) + return fail(EditorStatus::Rejected, "editor.archspace.invalid", + "ArchSpace property update failed validation"); + return replacement(staged.contentValue(), path.value()); +} + +EditorResult ArchSpaceDocumentTarget::makeReset(const SelectionSnapshot& selection, + const PropertyPath& path) const { + const auto descriptor = schema(selection).find(path); + if (!descriptor) + return fail(EditorStatus::Unsupported, "editor.archspace.property", + "Unknown ArchSpace property"); + return makeSet(selection, path, descriptor->defaultValue, PropertySetMode::Absolute); +} + +EditorResult ArchSpaceDocumentTarget::makeBootstrap(const std::string& siteId, + const std::string& buildingId, + const std::string& levelId, + double levelHeight) const { + archspace::Document candidate = document_; + auto boot = candidate.bootstrap(siteId, buildingId, levelId, levelHeight); + if (!boot.ok()) + return fail(EditorStatus::Rejected, "editor.archspace.bootstrap", + "ArchSpace bootstrap requires an empty document and unique ids"); + ArchSpaceDocumentTarget staged(id_); + staged.document_ = std::move(candidate); + return replacement(staged.contentValue()); +} + +EditorResult ArchSpaceDocumentTarget::makeCreateRectRoom( + const std::string& levelId, const std::string& roomId, std::string roomName, double originX, double originZ, + double sizeX, double sizeZ, double wallHeight, double wallThickness, double slabThickness) const { + if (!(std::isfinite(originX) && std::isfinite(originZ) && std::isfinite(sizeX) && std::isfinite(sizeZ)) || + sizeX <= 1e-6 || sizeZ <= 1e-6) + return fail(EditorStatus::Rejected, "editor.archspace.room", + "ArchSpace room size must be finite and positive"); + std::vector polygon = { + {originX, originZ}, + {originX + sizeX, originZ}, + {originX + sizeX, originZ + sizeZ}, + {originX, originZ + sizeZ}, + }; + archspace::Document candidate = document_; + auto created = candidate.createRoom(levelId, roomId, std::move(roomName), std::move(polygon), wallHeight, + wallThickness, slabThickness); + if (!created.ok()) + return fail(EditorStatus::Rejected, "editor.archspace.room", + "ArchSpace room could not be created under the given level"); + ArchSpaceDocumentTarget staged(id_); + staged.document_ = std::move(candidate); + return replacement(staged.contentValue()); +} + +EditorResult ArchSpaceDocumentTarget::makeCreateWall(const std::string& levelId, + const std::string& wallId, std::string wallName, + archspace::Vec2 start, archspace::Vec2 end, + double height, double thickness) const { + archspace::Document candidate = document_; + auto created = candidate.createWall(levelId, wallId, std::move(wallName), start, end, height, thickness); + if (!created.ok()) + return fail(EditorStatus::Rejected, "editor.archspace.wall", + "ArchSpace wall could not be created under the given level"); + ArchSpaceDocumentTarget staged(id_); + staged.document_ = std::move(candidate); + return replacement(staged.contentValue()); +} + +EditorResult ArchSpaceDocumentTarget::makeCreateOpening(const std::string& wallId, + const std::string& openingId, + archspace::OpeningKind kind, double t, + double width, double height, + double sill) const { + archspace::Document candidate = document_; + auto created = candidate.createOpening(wallId, openingId, openingId, kind, t, width, height, sill); + if (!created.ok()) + return fail(EditorStatus::Rejected, "editor.archspace.opening", + "ArchSpace opening insert failed validation"); + ArchSpaceDocumentTarget staged(id_); + staged.document_ = std::move(candidate); + return replacement(staged.contentValue()); +} + +EditorResult ArchSpaceDocumentTarget::makePlaceItem(const std::string& levelId, + const std::string& itemId, std::string catalogId, + archspace::Vec3 position, + double yawDegrees) const { + archspace::Document candidate = document_; + auto placed = + candidate.placeItem(levelId, itemId, itemId, std::move(catalogId), position, yawDegrees); + if (!placed.ok()) + return fail(EditorStatus::Rejected, "editor.archspace.item", + "ArchSpace item insert failed validation"); + ArchSpaceDocumentTarget staged(id_); + staged.document_ = std::move(candidate); + return replacement(staged.contentValue()); +} + +EditorResult ArchSpaceDocumentTarget::makeDeleteNode(const ObjectId& id) const { + archspace::Document candidate = document_; + auto erased = candidate.eraseCascade(id.value()); + if (!erased.ok()) + return fail(EditorStatus::NotFound, "editor.archspace.delete", "ArchSpace node was not found"); + ArchSpaceDocumentTarget staged(id_); + staged.document_ = std::move(candidate); + return replacement(staged.contentValue()); +} + +EditorResult ArchSpaceDocumentTarget::applyDomainOperation(const DomainOperation& operation) { + if (operation.target != TargetId(id_) || operation.type != "archspace.document.replace.v1") + return fail(EditorStatus::Rejected, "editor.archspace.operation", "ArchSpace operation mismatch"); + ArchSpaceDocumentTarget candidate(id_); + auto installed = candidate.installContent(operation.payload); + if (!installed.ok()) return installed; + if (hasErrors(candidate.validate())) + return fail(EditorStatus::Rejected, "editor.archspace.invalid", "ArchSpace document validation failed"); + document_ = std::move(candidate.document_); + ++revision_; + dirty_.include(0, 0); + return eve::editing::applied(); +} + +std::unique_ptr ArchSpaceDocumentTarget::cloneDomainState() const { + return std::make_unique(*this); +} + +EditorResult ArchSpaceDocumentTarget::commitDomainState(std::unique_ptr candidate) { + auto* typed = dynamic_cast(candidate.get()); + if (!typed || typed->id_ != id_) + return fail(EditorStatus::Conflict, "editor.archspace.candidate", "ArchSpace candidate mismatch"); + *this = *typed; + return eve::editing::applied(); +} + +EditorResult ArchSpaceDocumentTarget::loadSnapshot(const EditorValue& snapshot) { + const auto* schemaIdValue = field(snapshot, "schemaId"); + const auto* versionValue = field(snapshot, "schemaVersion"); + const auto* content = field(snapshot, "content"); + const auto* schemaId = schemaIdValue ? schemaIdValue->getIf() : nullptr; + const auto* version = versionValue ? versionValue->getIf() : nullptr; + if (!schemaId || *schemaId != "eve.archspace.document" || !version || *version != 1 || !content) + return fail(EditorStatus::Unsupported, "editor.archspace.snapshot", "Unsupported ArchSpace snapshot"); + DomainOperation op; + op.target = TargetId(id_); + op.type = "archspace.document.replace.v1"; + op.payload = *content; + auto result = applyDomainOperation(op); + if (result.ok()) dirty_.clear(); + return result; +} + +EditorResult ArchSpaceDocumentTarget::gizmo() const { + EditorGizmoSnapshot snapshot; + snapshot.status = EditorStatus::Applied; + snapshot.target = id_; + snapshot.targetRevision = revision_; + for (const auto& [id, node] : document_.nodes()) { + (void)id; + if (node.kind == archspace::NodeKind::Wall) { + EditorGizmoPrimitive primitive; + primitive.id = node.id; + primitive.kind = "segment"; + primitive.position = {node.start.x, + document_.find(node.parentId) ? document_.find(node.parentId)->elevation : 0.0, + node.start.z}; + primitive.direction = {node.end.x - node.start.x, 0.0, node.end.z - node.start.z}; + primitive.length = std::sqrt(primitive.direction[0] * primitive.direction[0] + + primitive.direction[2] * primitive.direction[2]); + primitive.color = {0.2, 0.7, 1.0, 0.9}; + snapshot.primitives.push_back(std::move(primitive)); + } else if (node.kind == archspace::NodeKind::Zone && !node.polygon.empty()) { + double minX = node.polygon.front().x, maxX = minX, minZ = node.polygon.front().z, maxZ = minZ; + for (const auto& p : node.polygon) { + minX = std::min(minX, p.x); + maxX = std::max(maxX, p.x); + minZ = std::min(minZ, p.z); + maxZ = std::max(maxZ, p.z); + } + EditorGizmoPrimitive primitive; + primitive.id = node.id; + primitive.kind = "area"; + primitive.dashed = true; + primitive.position = {(minX + maxX) * 0.5, 0.02, (minZ + maxZ) * 0.5}; + primitive.size = {maxX - minX, 0.01, maxZ - minZ}; + primitive.color = {0.8, 0.2, 1.0, 0.25}; + snapshot.primitives.push_back(std::move(primitive)); + } else if (node.kind == archspace::NodeKind::Item) { + EditorGizmoPrimitive primitive; + primitive.id = node.id; + primitive.kind = "point"; + primitive.position = {node.position.x, node.position.y, node.position.z}; + primitive.radius = 0.15; + primitive.color = {1.0, 0.8, 0.2, 0.9}; + primitive.text = node.catalogId; + snapshot.primitives.push_back(std::move(primitive)); + } else if (node.kind == archspace::NodeKind::Opening) { + const archspace::Node* wall = document_.find(node.parentId); + if (!wall) continue; + EditorGizmoPrimitive primitive; + primitive.id = node.id; + primitive.kind = "point"; + primitive.position = {wall->start.x + (wall->end.x - wall->start.x) * node.t, + node.sill + node.openingHeight * 0.5, + wall->start.z + (wall->end.z - wall->start.z) * node.t}; + primitive.radius = 0.12; + primitive.color = {0.2, 1.0, 0.4, 0.9}; + primitive.text = archspace::openingKindName(node.openingKind); + snapshot.primitives.push_back(std::move(primitive)); + } + } + return eve::editing::applied(std::move(snapshot)); +} + +} // namespace eve::archspace_editing diff --git a/src/modules/archspace/editing/ArchSpaceTarget.h b/src/modules/archspace/editing/ArchSpaceTarget.h new file mode 100644 index 00000000..1285577a --- /dev/null +++ b/src/modules/archspace/editing/ArchSpaceTarget.h @@ -0,0 +1,137 @@ +#pragma once + +#include "archspace/ArchSpaceDocument.h" +#include "editing/EditableTarget.h" +#include "editing/EditingAuthority.h" +#include "editing/EditingGizmo.h" +#include "editing/EditingProperty.h" + +#include +#include +#include + +namespace eve::archspace_editing { + +using CapabilityId = editing::CapabilityId; +using DiagnosticSeverity = editing::DiagnosticSeverity; +using DomainOperation = editing::DomainOperation; +using EditRegion = editing::EditRegion; +using EditorDiagnostic = editing::Diagnostic; +template +using EditorResult = editing::Result; +using EditorStatus = editing::Status; +using EditorValue = editing::Value; +using IDomainOperationTarget = editing::IDomainOperationTarget; +using IDomainOperationTargetStaging = editing::IDomainOperationTargetStaging; +using IEditableTarget = editing::IEditableTarget; +using IEditingSnapshotProvider = editing::IEditingSnapshotProvider; +using IPropertyProvider = editing::IPropertyProvider; +using ObjectId = editing::ObjectId; +using PropertyDescriptor = editing::PropertyDescriptor; +using PropertyFlag = editing::PropertyFlag; +using PropertyPath = editing::PropertyPath; +using PropertyReadResult = editing::PropertyReadResult; +using PropertyReadState = editing::PropertyReadState; +using PropertySchema = editing::PropertySchema; +using PropertySetMode = editing::PropertySetMode; +using PropertyType = editing::PropertyType; +using Revision = editing::Revision; +using RuleId = editing::RuleId; +using SelectionSnapshot = editing::SelectionSnapshot; +using TargetDescriptor = editing::TargetDescriptor; +using TargetId = editing::TargetId; +using EditorGizmoSnapshot = editing::GizmoSnapshot; +using EditorGizmoPrimitive = editing::GizmoPrimitive; +using editing::validatePropertyValue; + +/** + * @brief Revisioned ArchSpace document target for Inspector, tools and automation. + * + * Mutations are expressed as reversible `archspace.document.replace.v1` domain + * operations. High-level helpers (bootstrap/room/opening/item) plan those + * replacements without bypassing validation. + */ +class ArchSpaceDocumentTarget final : public virtual IEditableTarget, + public IDomainOperationTarget, + public IDomainOperationTargetStaging, + public IPropertyProvider, + public IEditingSnapshotProvider { +public: + explicit ArchSpaceDocumentTarget(std::string id); + + /** @brief Capability identity published by describe() for Inspector property editing. */ + static CapabilityId propertyCapabilityId() { return CapabilityId("eve.editor.target.archspace-properties"); } + + TargetId targetId() const override { return TargetId(id_); } + std::uint64_t revision() const override { return revision_; } + EditRegion dirtyRegion() const override { return dirty_; } + void clearDirtyRegion() override { dirty_.clear(); } + TargetDescriptor describe() const override; + /** + * @brief Query Inspector property capability. + * @ownership Borrowed from this target; callers must not delete it. + * @lifetime Valid until this target is destroyed. + */ + void* queryCapability(const CapabilityId& capability) override; + EditorResult applyDomainOperation(const DomainOperation& operation) override; + std::unique_ptr cloneDomainState() const override; + EditorResult commitDomainState(std::unique_ptr candidate) override; + eve::Result currentRevision(const SelectionSnapshot& selection) const override; + PropertySchema schema(const SelectionSnapshot& selection) const override; + PropertyReadResult read(const SelectionSnapshot& selection, const PropertyPath& path) const override; + EditorResult makeSet(const SelectionSnapshot& selection, const PropertyPath& path, + const EditorValue& value, PropertySetMode mode) const override; + EditorResult makeReset(const SelectionSnapshot& selection, + const PropertyPath& path) const override; + + /** @brief Plan site→building→level creation on an empty document. */ + EditorResult makeBootstrap(const std::string& siteId, const std::string& buildingId, + const std::string& levelId, double levelHeight = 3.0) const; + /** @brief Plan a rectangular room (four walls + slab + zone) under a level. */ + EditorResult makeCreateRectRoom(const std::string& levelId, const std::string& roomId, + std::string roomName, double originX, double originZ, double sizeX, + double sizeZ, double wallHeight = 3.0, double wallThickness = 0.2, + double slabThickness = 0.2) const; + /** @brief Plan a single wall under a level. */ + EditorResult makeCreateWall(const std::string& levelId, const std::string& wallId, + std::string wallName, archspace::Vec2 start, archspace::Vec2 end, + double height = 3.0, double thickness = 0.2) const; + /** @brief Plan a door/window opening on an existing wall. */ + EditorResult makeCreateOpening(const std::string& wallId, const std::string& openingId, + archspace::OpeningKind kind, double t, double width, double height, + double sill = 0.0) const; + /** @brief Plan a catalog item placement on a level. */ + EditorResult makePlaceItem(const std::string& levelId, const std::string& itemId, + std::string catalogId, archspace::Vec3 position, + double yawDegrees = 0.0) const; + /** @brief Plan cascading deletion of one node. */ + EditorResult makeDeleteNode(const ObjectId& id) const; + + [[nodiscard]] const archspace::Document& document() const { return document_; } + [[nodiscard]] std::vector validate() const; + [[nodiscard]] EditorValue snapshotValue() const override; + EditorResult loadSnapshot(const EditorValue& snapshot); + /** @brief Build a revision-bound overlay for walls, zones, items and openings. */ + [[nodiscard]] EditorResult gizmo() const; + /** @brief Bake renderer-neutral triangle mesh for the current revision. */ + [[nodiscard]] archspace::MeshBake bakeMesh() const { return document_.bakeMesh(); } + +private: + bool matches(const SelectionSnapshot& selection) const; + EditorValue contentValue() const; + EditorResult replacement(EditorValue content, std::string property = {}) const; + EditorResult installContent(const EditorValue& content); + /** + * @brief Resolve the single selected ArchSpace node for property edits. + * @ownership Non-owning pointer into document_; do not free. + * @lifetime Until the next mutating call on this target. + */ + const archspace::Node* selectedNode(const SelectionSnapshot& selection) const; + + std::string id_; + Revision revision_ = 1; + EditRegion dirty_; + archspace::Document document_; +}; + +} // namespace eve::archspace_editing diff --git a/src/modules/archspace/editor/ArchSpaceEditorModule.h b/src/modules/archspace/editor/ArchSpaceEditorModule.h new file mode 100644 index 00000000..1f663da2 --- /dev/null +++ b/src/modules/archspace/editor/ArchSpaceEditorModule.h @@ -0,0 +1,28 @@ +#pragma once + +#include "common/Module.h" + +#include + +namespace eve::archspace_editor { + +/** + * @brief Composition adapter that contributes ArchSpace editing commands and automation targets. + * + * @ownership The automation factory is owned by this module. Registered commands are owned by the + * borrowed editing host and unregistered on destruction. + * @threadaffinity Owner/composition thread only. + * @reentrancy Do not construct or destroy while a command planner is running. + */ +class ArchSpaceEditorModule final : public Module { +public: + Module_REG(ArchSpaceEditorModule); + ArchSpaceEditorModule(); + ~ArchSpaceEditorModule() override; + +private: + class TargetFactory; + std::unique_ptr factory_; +}; + +} // namespace eve::archspace_editor diff --git a/src/modules/archspace/editor/ArchSpaceTools.cpp b/src/modules/archspace/editor/ArchSpaceTools.cpp new file mode 100644 index 00000000..42ab5b50 --- /dev/null +++ b/src/modules/archspace/editor/ArchSpaceTools.cpp @@ -0,0 +1,338 @@ +#include "archspace/editor/EditorArchSpaceTools.h" + +#include +#include +#include +#include +#include + +namespace eve::editor { +namespace { + +constexpr double kEps = 1e-8; + +bool intersectFloor(const ArchSpaceViewportRay& ray, double elevation, archspace::Vec2& out) { + if (std::abs(ray.direction[1]) <= kEps) return false; + const double t = (elevation - ray.origin[1]) / ray.direction[1]; + if (t < 0.0) return false; + out.x = ray.origin[0] + ray.direction[0] * t; + out.z = ray.origin[2] + ray.direction[2] * t; + return std::isfinite(out.x) && std::isfinite(out.z); +} + +archspace::Vec2 snapOrtho(const archspace::Vec2& start, const archspace::Vec2& end) { + if (std::abs(end.x - start.x) >= std::abs(end.z - start.z)) return {end.x, start.z}; + return {start.x, end.z}; +} + +bool projectOntoWall(const archspace::Node& wall, const archspace::Vec2& point, double& t, double& dist) { + const double dx = wall.end.x - wall.start.x; + const double dz = wall.end.z - wall.start.z; + const double len2 = dx * dx + dz * dz; + if (len2 <= kEps) return false; + t = std::clamp(((point.x - wall.start.x) * dx + (point.z - wall.start.z) * dz) / len2, 0.0, 1.0); + const double px = wall.start.x + dx * t; + const double pz = wall.start.z + dz * t; + dist = std::sqrt((point.x - px) * (point.x - px) + (point.z - pz) * (point.z - pz)); + return true; +} + +bool commitDomain(editing::IEditAuthority* authority, eve::archspace_editing::ArchSpaceDocumentTarget& target, + editing::DomainOperation operation, const ToolDescriptor& descriptor, std::uint64_t sequence, + std::string mergeKey, std::optional& outReceipt) { + if (!authority) return false; + editing::TransactionSpec transaction; + transaction.id = editing::TransactionId(descriptor.id + "." + std::to_string(sequence)); + transaction.label = descriptor.label; + transaction.origin = editing::ActionOrigin::User; + transaction.target = target.targetId(); + transaction.baseRevision = target.revision(); + transaction.mergeKey = std::move(mergeKey); + const std::array operations{std::move(operation)}; + auto plan = authority->preflight(transaction, std::span(operations)); + if (!plan.ok()) return false; + auto receipt = authority->commit(plan.value()); + if (!receipt.ok()) return false; + outReceipt = std::move(receipt.value()); + return true; +} + +} // namespace + +ArchSpaceWallDrawTool::ArchSpaceWallDrawTool(IArchSpaceViewportAdapter* viewport, editing::IEditAuthority* authority) + : viewport_(viewport), authority_(authority) {} + +void ArchSpaceWallDrawTool::setViewportAdapter(IArchSpaceViewportAdapter* viewport) { + viewport_ = viewport; + drawing_ = false; +} + +void ArchSpaceWallDrawTool::setAuthority(editing::IEditAuthority* authority) { + authority_ = authority; + drawing_ = false; +} + +void ArchSpaceWallDrawTool::setLevelId(std::string levelId) { levelId_ = std::move(levelId); } + +bool ArchSpaceWallDrawTool::hitFloor(const EditorPointerEvent& event, archspace::Vec2& out) const { + if (!viewport_) return false; + auto ray = viewport_->pointerRay(event); + if (!ray.ok()) return false; + return intersectFloor(ray.value(), 0.0, out); +} + +ToolResponse ArchSpaceWallDrawTool::begin(EditorContext&, const EditorPointerEvent& event) { + if (levelId_.empty() || !viewport_) return ToolResponse::ignored(); + archspace::Vec2 point; + if (!hitFloor(event, point)) return ToolResponse::ignored(); + if (!(contiguous_ && haveAnchor_ && !lastWallId_.empty())) { + anchor_ = point; + haveAnchor_ = true; + } + cursor_ = point; + drawing_ = true; + return ToolResponse::capture(); +} + +ToolResponse ArchSpaceWallDrawTool::move(const EditorPointerEvent& event) { + if (!drawing_) return ToolResponse::ignored(); + archspace::Vec2 point; + if (!hitFloor(event, point)) return ToolResponse::consumed(); + cursor_ = event.shift ? snapOrtho(anchor_, point) : point; + return ToolResponse::consumed(); +} + +bool ArchSpaceWallDrawTool::commitWall(eve::archspace_editing::ArchSpaceDocumentTarget& target, archspace::Vec2 start, + archspace::Vec2 end) { + const double dx = end.x - start.x; + const double dz = end.z - start.z; + if (std::sqrt(dx * dx + dz * dz) <= 0.05) return false; + const std::string wallId = idPrefix_ + "." + std::to_string(++sequence_); + auto operation = target.makeCreateWall(levelId_, wallId, wallId, start, end, wallHeight_, wallThickness_); + if (!operation.ok()) return false; + if (!commitDomain(authority_, target, std::move(operation.value()), descriptor_, sequence_, "archspace.wall.draw", + lastReceipt_)) + return false; + lastWallId_ = wallId; + if (contiguous_) { + anchor_ = end; + haveAnchor_ = true; + } + return true; +} + +ToolResponse ArchSpaceWallDrawTool::finish(EditorContext& context, const EditorPointerEvent& event) { + if (!drawing_) return ToolResponse::ignored(); + (void)move(event); + drawing_ = false; + auto* target = dynamic_cast(context.target()); + if (!target || !authority_) return ToolResponse::release(); + (void)commitWall(*target, anchor_, cursor_); + return ToolResponse::release(); +} + +ToolResponse ArchSpaceWallDrawTool::pointerEvent(EditorContext& context, const EditorPointerEvent& event) { + if (event.phase == EditorPointerEvent::Phase::Down) + return event.button == 0 ? begin(context, event) : ToolResponse::ignored(); + if (event.phase == EditorPointerEvent::Phase::Move) return move(event); + if (event.phase == EditorPointerEvent::Phase::Up) return finish(context, event); + if (event.phase == EditorPointerEvent::Phase::Cancel) { + cancel(context); + return ToolResponse::release(); + } + return ToolResponse::ignored(); +} + +void ArchSpaceWallDrawTool::deactivate(EditorContext& context) { cancel(context); } +void ArchSpaceWallDrawTool::cancel(EditorContext&) { drawing_ = false; } + +void ArchSpaceWallDrawTool::drawOverlay(EditorContext&, IEditorOverlay& overlay) { + if (!drawing_ || !viewport_) return; + auto from = viewport_->projectWorld({anchor_.x, 0.05, anchor_.z}); + auto to = viewport_->projectWorld({cursor_.x, 0.05, cursor_.z}); + if (!from.ok() || !to.ok()) return; + OverlayStyle style; + style.color = 0xff4fc3f7U; + style.thickness = 2.f; + overlay.line(from.value(), to.value(), style); + overlay.circle(from.value(), 5.f, style); + overlay.circle(to.value(), 5.f, style); +} + +void ArchSpaceWallDrawTool::inspect(EditorContext&, IEditorInspector& inspector) { + inspector.beginGroup("archspace.wall", "Wall Draw"); + float height = static_cast(wallHeight_); + float thickness = static_cast(wallThickness_); + if (inspector.scalar("height", "Height", height, 0.5f, 10.f)) wallHeight_ = height; + if (inspector.scalar("thickness", "Thickness", thickness, 0.05f, 1.0f)) wallThickness_ = thickness; + inspector.boolean("contiguous", "Contiguous", contiguous_); + inspector.string("levelId", "Level Id", levelId_); + inspector.endGroup(); +} + +ArchSpaceOpeningPlaceTool::ArchSpaceOpeningPlaceTool(IArchSpaceViewportAdapter* viewport, + editing::IEditAuthority* authority) + : viewport_(viewport), authority_(authority) {} + +void ArchSpaceOpeningPlaceTool::setViewportAdapter(IArchSpaceViewportAdapter* viewport) { + viewport_ = viewport; + preview_.reset(); +} +void ArchSpaceOpeningPlaceTool::setAuthority(editing::IEditAuthority* authority) { + authority_ = authority; + preview_.reset(); +} + +bool ArchSpaceOpeningPlaceTool::hitFloor(const EditorPointerEvent& event, archspace::Vec2& out) const { + if (!viewport_) return false; + auto ray = viewport_->pointerRay(event); + if (!ray.ok()) return false; + return intersectFloor(ray.value(), 0.0, out); +} + +std::optional ArchSpaceOpeningPlaceTool::pickWall( + const eve::archspace_editing::ArchSpaceDocumentTarget& target, const archspace::Vec2& point) const { + std::optional best; + double bestDist = 1e9; + for (const auto& [id, node] : target.document().nodes()) { + if (node.kind != archspace::NodeKind::Wall) continue; + double t = 0.5; + double dist = 0.0; + if (!projectOntoWall(node, point, t, dist)) continue; + if (dist > std::max(0.75, node.thickness * 2.0)) continue; + if (dist >= bestDist) continue; + bestDist = dist; + best = Pick{ + id, t, {node.start.x + (node.end.x - node.start.x) * t, node.start.z + (node.end.z - node.start.z) * t}}; + } + return best; +} + +ToolResponse ArchSpaceOpeningPlaceTool::pointerEvent(EditorContext& context, const EditorPointerEvent& event) { + auto* target = dynamic_cast(context.target()); + if (!target || !viewport_) return ToolResponse::ignored(); + archspace::Vec2 point; + if (!hitFloor(event, point)) { + preview_.reset(); + return event.phase == EditorPointerEvent::Phase::Move ? ToolResponse::consumed() : ToolResponse::ignored(); + } + preview_ = pickWall(*target, point); + if (event.phase == EditorPointerEvent::Phase::Move) return ToolResponse::consumed(); + if (event.phase == EditorPointerEvent::Phase::Down && event.button == 0 && preview_) return ToolResponse::capture(); + if (event.phase == EditorPointerEvent::Phase::Cancel) { + preview_.reset(); + return ToolResponse::release(); + } + if (event.phase != EditorPointerEvent::Phase::Up || event.button != 0) return ToolResponse::ignored(); + if (!preview_ || !authority_) return ToolResponse::release(); + const std::string openingId = idPrefix_ + "." + std::to_string(++sequence_); + auto operation = target->makeCreateOpening(preview_->wallId, openingId, kind_, preview_->t, width_, height_, sill_); + if (!operation.ok()) return ToolResponse::release(); + if (commitDomain(authority_, *target, std::move(operation.value()), descriptor_, sequence_, + "archspace.opening.place", lastReceipt_)) + lastOpeningId_ = openingId; + preview_.reset(); + return ToolResponse::release(); +} + +void ArchSpaceOpeningPlaceTool::deactivate(EditorContext& context) { cancel(context); } +void ArchSpaceOpeningPlaceTool::cancel(EditorContext&) { preview_.reset(); } + +void ArchSpaceOpeningPlaceTool::drawOverlay(EditorContext&, IEditorOverlay& overlay) { + if (!preview_ || !viewport_) return; + auto center = viewport_->projectWorld({preview_->point.x, height_ * 0.5, preview_->point.z}); + if (!center.ok()) return; + OverlayStyle style; + style.color = kind_ == archspace::OpeningKind::Door ? 0xffffb74dU : 0xff81d4faU; + style.thickness = 2.f; + overlay.circle(center.value(), 8.f, style); + overlay.text(center.value(), kind_ == archspace::OpeningKind::Door ? "door" : "window", style); +} + +void ArchSpaceOpeningPlaceTool::inspect(EditorContext&, IEditorInspector& inspector) { + inspector.beginGroup("archspace.opening", "Opening Place"); + bool isDoor = kind_ == archspace::OpeningKind::Door; + if (inspector.boolean("door", "Door (else window)", isDoor)) + kind_ = isDoor ? archspace::OpeningKind::Door : archspace::OpeningKind::Window; + float width = static_cast(width_); + float height = static_cast(height_); + float sill = static_cast(sill_); + if (inspector.scalar("width", "Width", width, 0.3f, 3.0f)) width_ = width; + if (inspector.scalar("height", "Height", height, 0.3f, 4.0f)) height_ = height; + if (inspector.scalar("sill", "Sill", sill, 0.0f, 2.5f)) sill_ = sill; + inspector.endGroup(); +} + +ArchSpaceItemPlaceTool::ArchSpaceItemPlaceTool(IArchSpaceViewportAdapter* viewport, editing::IEditAuthority* authority) + : viewport_(viewport), authority_(authority) {} + +void ArchSpaceItemPlaceTool::setViewportAdapter(IArchSpaceViewportAdapter* viewport) { + viewport_ = viewport; + preview_.reset(); +} +void ArchSpaceItemPlaceTool::setAuthority(editing::IEditAuthority* authority) { + authority_ = authority; + preview_.reset(); +} +void ArchSpaceItemPlaceTool::setLevelId(std::string levelId) { levelId_ = std::move(levelId); } +void ArchSpaceItemPlaceTool::setCatalogId(std::string catalogId) { catalogId_ = std::move(catalogId); } + +bool ArchSpaceItemPlaceTool::hitFloor(const EditorPointerEvent& event, archspace::Vec2& out) const { + if (!viewport_) return false; + auto ray = viewport_->pointerRay(event); + if (!ray.ok()) return false; + return intersectFloor(ray.value(), 0.0, out); +} + +ToolResponse ArchSpaceItemPlaceTool::pointerEvent(EditorContext& context, const EditorPointerEvent& event) { + auto* target = dynamic_cast(context.target()); + if (!target || levelId_.empty() || !viewport_) return ToolResponse::ignored(); + archspace::Vec2 point; + if (!hitFloor(event, point)) { + preview_.reset(); + return event.phase == EditorPointerEvent::Phase::Move ? ToolResponse::consumed() : ToolResponse::ignored(); + } + preview_ = point; + if (event.phase == EditorPointerEvent::Phase::Move) return ToolResponse::consumed(); + if (event.phase == EditorPointerEvent::Phase::Down && event.button == 0) return ToolResponse::capture(); + if (event.phase == EditorPointerEvent::Phase::Cancel) { + preview_.reset(); + return ToolResponse::release(); + } + if (event.phase != EditorPointerEvent::Phase::Up || event.button != 0) return ToolResponse::ignored(); + if (!authority_) return ToolResponse::release(); + const std::string itemId = idPrefix_ + "." + std::to_string(++sequence_); + auto operation = + target->makePlaceItem(levelId_, itemId, catalogId_, archspace::Vec3{point.x, 0.0, point.z}, yawDegrees_); + if (!operation.ok()) return ToolResponse::release(); + if (commitDomain(authority_, *target, std::move(operation.value()), descriptor_, sequence_, "archspace.item.place", + lastReceipt_)) + lastItemId_ = itemId; + preview_.reset(); + return ToolResponse::release(); +} + +void ArchSpaceItemPlaceTool::deactivate(EditorContext& context) { cancel(context); } +void ArchSpaceItemPlaceTool::cancel(EditorContext&) { preview_.reset(); } + +void ArchSpaceItemPlaceTool::drawOverlay(EditorContext&, IEditorOverlay& overlay) { + if (!preview_ || !viewport_) return; + auto center = viewport_->projectWorld({preview_->x, 0.05, preview_->z}); + if (!center.ok()) return; + OverlayStyle style; + style.color = 0xffaed581U; + style.thickness = 2.f; + overlay.circle(center.value(), 7.f, style); + overlay.text(center.value(), catalogId_, style); +} + +void ArchSpaceItemPlaceTool::inspect(EditorContext&, IEditorInspector& inspector) { + inspector.beginGroup("archspace.item", "Item Place"); + inspector.string("levelId", "Level Id", levelId_); + inspector.string("catalogId", "Catalog Id", catalogId_); + float yaw = static_cast(yawDegrees_); + if (inspector.scalar("yaw", "Yaw Degrees", yaw, -180.f, 180.f)) yawDegrees_ = yaw; + inspector.endGroup(); +} + +} // namespace eve::editor diff --git a/src/modules/archspace/editor/EditorArchSpaceTarget.h b/src/modules/archspace/editor/EditorArchSpaceTarget.h new file mode 100644 index 00000000..da83d155 --- /dev/null +++ b/src/modules/archspace/editor/EditorArchSpaceTarget.h @@ -0,0 +1,10 @@ +#pragma once + +// Compatibility facade. Canonical implementation is owned by archspace_editing. +#include "archspace/editing/ArchSpaceTarget.h" +#include "editor/EditorProperty.h" +#include "editor/EditorProtocol.h" + +namespace eve::editor { +using archspace_editing::ArchSpaceDocumentTarget; +} // namespace eve::editor diff --git a/src/modules/archspace/editor/EditorArchSpaceTools.h b/src/modules/archspace/editor/EditorArchSpaceTools.h new file mode 100644 index 00000000..4636e750 --- /dev/null +++ b/src/modules/archspace/editor/EditorArchSpaceTools.h @@ -0,0 +1,176 @@ +#pragma once + +#include "archspace/ArchSpaceTypes.h" +#include "archspace/editing/ArchSpaceTarget.h" +#include "editing/EditingAuthority.h" +#include "editor/EditorPresentation.h" +#include "editor/EditorTool.h" + +#include +#include +#include +#include + +namespace eve::editor { + +/** @brief World-space ray produced by an ArchSpace editor viewport. */ +struct ArchSpaceViewportRay { + std::array origin{0.0, 0.0, 0.0}; + std::array direction{0.0, 0.0, -1.0}; +}; + +/** + * @brief Host-owned conversion boundary between pointer coordinates and the ArchSpace viewport. + * + * Tools invoke this interface synchronously on the editor thread and never retain returned values + * across callbacks. Implementations must not re-enter the tool. + */ +class IArchSpaceViewportAdapter { +public: + virtual ~IArchSpaceViewportAdapter() = default; + + /** @brief Build a world ray for one normalized pointer event. */ + virtual editing::Result pointerRay(const EditorPointerEvent& event) const = 0; + /** @brief Project a world position into the coordinate space accepted by IEditorOverlay. */ + virtual editing::Result projectWorld(const std::array& world) const = 0; +}; + +/** + * @brief Click-drag wall draw tool. Down starts, Move previews, Up commits `makeCreateWall`. + * + * Contiguous mode chains the next start to the previous end. Shift constrains to axis-aligned. + */ +class ArchSpaceWallDrawTool final : public IEditorTool { +public: + ArchSpaceWallDrawTool(IArchSpaceViewportAdapter* viewport, editing::IEditAuthority* authority); + + const ToolDescriptor& descriptor() const override { return descriptor_; } + void setViewportAdapter(IArchSpaceViewportAdapter* viewport); + void setAuthority(editing::IEditAuthority* authority); + void setLevelId(std::string levelId); + void setWallHeight(double height) { wallHeight_ = height; } + void setWallThickness(double thickness) { wallThickness_ = thickness; } + void setContiguous(bool enabled) { contiguous_ = enabled; } + void setIdPrefix(std::string prefix) { idPrefix_ = std::move(prefix); } + + ToolResponse pointerEvent(EditorContext& context, const EditorPointerEvent& event) override; + void deactivate(EditorContext& context) override; + void cancel(EditorContext& context) override; + void drawOverlay(EditorContext& context, IEditorOverlay& overlay) override; + void inspect(EditorContext& context, IEditorInspector& inspector) override; + + [[nodiscard]] bool isDrawing() const { return drawing_; } + [[nodiscard]] const std::optional& lastReceipt() const { return lastReceipt_; } + [[nodiscard]] const std::string& lastWallId() const { return lastWallId_; } + +private: + ToolResponse begin(EditorContext& context, const EditorPointerEvent& event); + ToolResponse move(const EditorPointerEvent& event); + ToolResponse finish(EditorContext& context, const EditorPointerEvent& event); + [[nodiscard]] bool hitFloor(const EditorPointerEvent& event, archspace::Vec2& out) const; + [[nodiscard]] bool commitWall(archspace_editing::ArchSpaceDocumentTarget& target, archspace::Vec2 start, + archspace::Vec2 end); + + ToolDescriptor descriptor_{"archspace.wall.draw", "Draw Wall", {}}; + IArchSpaceViewportAdapter* viewport_ = nullptr; + editing::IEditAuthority* authority_ = nullptr; + std::string levelId_; + std::string idPrefix_{"wall"}; + double wallHeight_ = 3.0; + double wallThickness_ = 0.2; + bool contiguous_ = true; + bool drawing_ = false; + bool haveAnchor_ = false; + archspace::Vec2 anchor_{}; + archspace::Vec2 cursor_{}; + std::string lastWallId_; + std::optional lastReceipt_; + std::uint64_t sequence_ = 0; +}; + +/** @brief Click-to-place door/window tool committing `makeCreateOpening` on the nearest wall. */ +class ArchSpaceOpeningPlaceTool final : public IEditorTool { +public: + ArchSpaceOpeningPlaceTool(IArchSpaceViewportAdapter* viewport, editing::IEditAuthority* authority); + + const ToolDescriptor& descriptor() const override { return descriptor_; } + void setViewportAdapter(IArchSpaceViewportAdapter* viewport); + void setAuthority(editing::IEditAuthority* authority); + void setOpeningKind(archspace::OpeningKind kind) { kind_ = kind; } + void setWidth(double width) { width_ = width; } + void setHeight(double height) { height_ = height; } + void setSill(double sill) { sill_ = sill; } + void setIdPrefix(std::string prefix) { idPrefix_ = std::move(prefix); } + + ToolResponse pointerEvent(EditorContext& context, const EditorPointerEvent& event) override; + void deactivate(EditorContext& context) override; + void cancel(EditorContext& context) override; + void drawOverlay(EditorContext& context, IEditorOverlay& overlay) override; + void inspect(EditorContext& context, IEditorInspector& inspector) override; + + [[nodiscard]] const std::optional& lastReceipt() const { return lastReceipt_; } + [[nodiscard]] const std::string& lastOpeningId() const { return lastOpeningId_; } + +private: + struct Pick { + std::string wallId; + double t = 0.5; + archspace::Vec2 point{}; + }; + [[nodiscard]] bool hitFloor(const EditorPointerEvent& event, archspace::Vec2& out) const; + [[nodiscard]] std::optional pickWall(const archspace_editing::ArchSpaceDocumentTarget& target, + const archspace::Vec2& point) const; + + ToolDescriptor descriptor_{"archspace.opening.place", "Place Opening", {}}; + IArchSpaceViewportAdapter* viewport_ = nullptr; + editing::IEditAuthority* authority_ = nullptr; + archspace::OpeningKind kind_ = archspace::OpeningKind::Door; + double width_ = 0.9; + double height_ = 2.1; + double sill_ = 0.0; + std::string idPrefix_{"opening"}; + std::optional preview_; + std::string lastOpeningId_; + std::optional lastReceipt_; + std::uint64_t sequence_ = 0; +}; + +/** @brief Click-to-place furniture tool committing `makePlaceItem` with catalog id + yaw. */ +class ArchSpaceItemPlaceTool final : public IEditorTool { +public: + ArchSpaceItemPlaceTool(IArchSpaceViewportAdapter* viewport, editing::IEditAuthority* authority); + + const ToolDescriptor& descriptor() const override { return descriptor_; } + void setViewportAdapter(IArchSpaceViewportAdapter* viewport); + void setAuthority(editing::IEditAuthority* authority); + void setLevelId(std::string levelId); + void setCatalogId(std::string catalogId); + void setYawDegrees(double yaw) { yawDegrees_ = yaw; } + void setIdPrefix(std::string prefix) { idPrefix_ = std::move(prefix); } + + ToolResponse pointerEvent(EditorContext& context, const EditorPointerEvent& event) override; + void deactivate(EditorContext& context) override; + void cancel(EditorContext& context) override; + void drawOverlay(EditorContext& context, IEditorOverlay& overlay) override; + void inspect(EditorContext& context, IEditorInspector& inspector) override; + + [[nodiscard]] const std::optional& lastReceipt() const { return lastReceipt_; } + [[nodiscard]] const std::string& lastItemId() const { return lastItemId_; } + +private: + [[nodiscard]] bool hitFloor(const EditorPointerEvent& event, archspace::Vec2& out) const; + + ToolDescriptor descriptor_{"archspace.item.place", "Place Item", {}}; + IArchSpaceViewportAdapter* viewport_ = nullptr; + editing::IEditAuthority* authority_ = nullptr; + std::string levelId_; + std::string catalogId_{"furniture.desk"}; + std::string idPrefix_{"item"}; + double yawDegrees_ = 0.0; + std::optional preview_; + std::string lastItemId_; + std::optional lastReceipt_; + std::uint64_t sequence_ = 0; +}; + +} // namespace eve::editor diff --git a/src/modules/archspace/editor/archspace_editor.cpp b/src/modules/archspace/editor/archspace_editor.cpp new file mode 100644 index 00000000..518c3a5e --- /dev/null +++ b/src/modules/archspace/editor/archspace_editor.cpp @@ -0,0 +1,70 @@ +#include "archspace/editor/ArchSpaceEditorModule.h" + +#include "archspace/editing/ArchSpaceEditingCommands.h" +#include "archspace/editing/ArchSpaceTarget.h" +#include "common/Capability.h" +#include "editing/EditingCommandRegistry.h" +#include "editor/EditorAutomationTargetFactory.h" + +#include + +#include +#include + +namespace eve::archspace_editor { +namespace { + +std::string stringField(const editor::EditorValue::Object& request, const char* key) { + const auto found = request.find(key); + if (found == request.end()) return {}; + const auto* value = found->second.getIf(); + return value ? *value : std::string{}; +} + +} // namespace + +class ArchSpaceEditorModule::TargetFactory final : public editor::IEditorAutomationTargetFactory { +public: + bool supports(std::string_view type) const override { return type == "archspace" || type == "archspace-document"; } + + editor::EditorResult create(const editor::TargetId& target, std::string_view type, + const editor::EditorValue::Object& request) override { + (void)type; + auto document = std::make_unique(target.value()); + const std::string site = stringField(request, "siteId"); + if (!site.empty()) { + const std::string building = stringField(request, "buildingId"); + const std::string level = stringField(request, "levelId"); + auto operation = document->makeBootstrap(site, building.empty() ? site + ".building" : building, + level.empty() ? site + ".level0" : level); + if (!operation.ok()) + return editor::EditorResult::failure(operation.status()); + auto applied = document->applyDomainOperation(operation.value()); + if (!applied.ok()) return editor::EditorResult::failure(applied.status()); + } + editor::AutomationOwnedTarget owned; + owned.target = std::move(document); + return eve::editing::applied(std::move(owned)); + } +}; + +Module_IMPL(ArchSpaceEditorModule, new ArchSpaceEditorModule()); + +ArchSpaceEditorModule::ArchSpaceEditorModule() : factory_(std::make_unique()) { + auto* registry = eve::cap::query(); + if (!registry || !archspace_editing::registerEditingCommands(*registry).ok()) + throw std::runtime_error("Failed to register ArchSpace editing commands"); + eve::cap::addListener(factory_.get()); +} + +ArchSpaceEditorModule::~ArchSpaceEditorModule() { + eve::cap::removeListener(factory_.get()); + if (auto* registry = eve::cap::query()) + registry->unregisterOwner("archspace_editing").ignore("archspace editor adapter shutdown"); +} + +void ArchSpaceEditorModule::expose(ssq::Table& table) { table.addClass(name, ArchSpaceEditorModule::create, false); } + +void ArchSpaceEditorModule::expose(ssq::Class&) {} + +} // namespace eve::archspace_editor diff --git a/test/editor_archspace_target.cpp b/test/editor_archspace_target.cpp new file mode 100644 index 00000000..00878e06 --- /dev/null +++ b/test/editor_archspace_target.cpp @@ -0,0 +1,184 @@ +#include "archspace/ArchSpaceCatalog.h" +#include "archspace/ArchSpaceDocument.h" +#include "archspace/editing/ArchSpaceTarget.h" +#include "archspace/editor/ArchSpaceEditorModule.h" + +#include "common/Capability.h" +#include "common/EditorAutomation.h" +#include "editor/Editor.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include +#include + +using namespace eve::archspace; +using namespace eve::archspace_editing; +using namespace eve::editing; + +namespace { + +void apply(ArchSpaceDocumentTarget& target, EditorResult operation) { + REQUIRE(operation.ok()); + REQUIRE(target.applyDomainOperation(operation.value()).ok()); +} + +SelectionSnapshot select(const ArchSpaceDocumentTarget& target, const char* id, const char* type) { + SelectionSnapshot selection; + selection.channel = "archspace"; + selection.items.push_back({SelectionDomain::Asset, TargetId(target.targetId()), StableId(id), type}); + return selection; +} + +} // namespace + +TEST_CASE("archspace.document.bootstrap_room_opening_and_mesh_bake") { + Document document; + REQUIRE(document.bootstrap("site", "building", "level0").ok()); + REQUIRE( + document.createRoom("level0", "living", "Living Room", {{0, 0}, {6, 0}, {6, 4}, {0, 4}}, 3.0, 0.2, 0.2).ok()); + REQUIRE(document.find("living.wall.0") != nullptr); + REQUIRE(document.find("living.zone") != nullptr); + Node door; + door.id = "living.door"; + door.kind = NodeKind::Opening; + door.parentId = "living.wall.0"; + door.name = "Entry"; + door.openingKind = OpeningKind::Door; + door.t = 0.5; + door.width = 1.0; + door.openingHeight = 2.1; + REQUIRE(document.insert(std::move(door)).ok()); + const MeshBake bake = document.bakeMesh(); + CHECK(bake.positions.size() >= 8); + CHECK(!bake.indices.empty()); + CHECK(document.diagnostics().empty()); +} + +TEST_CASE("archspace.editing.room_property_undo_and_snapshot_are_atomic") { + ArchSpaceDocumentTarget target("apartment"); + apply(target, target.makeBootstrap("site", "building", "level0")); + apply(target, target.makeCreateRectRoom("level0", "office", "Office", 0, 0, 5, 4)); + CHECK(target.document().find("office.zone") != nullptr); + + const auto selection = select(target, "office.wall.0", "archspace.wall"); + auto edit = target.makeSet(selection, PropertyPath("thickness"), 0.35, PropertySetMode::Absolute); + REQUIRE(edit.ok()); + REQUIRE(target.applyDomainOperation(edit.value()).ok()); + CHECK_EQ(target.document().find("office.wall.0")->thickness, 0.35); + + DomainOperation undo = edit.value(); + undo.payload = edit.value().inverse; + REQUIRE(target.applyDomainOperation(undo).ok()); + CHECK_EQ(target.document().find("office.wall.0")->thickness, 0.2); + + apply(target, target.makeCreateOpening("office.wall.1", "office.window", OpeningKind::Window, 0.4, 1.2, 1.4, 0.9)); + apply(target, target.makePlaceItem("level0", "desk", "furniture.desk", Vec3{1.5, 0, 1.5}, 90)); + CHECK(target.document().find("desk") != nullptr); + + const EditorValue snapshot = target.snapshotValue(); + ArchSpaceDocumentTarget restored("copy"); + REQUIRE(restored.loadSnapshot(snapshot).ok()); + CHECK_EQ(restored.document().nodeCount(), target.document().nodeCount()); + + EditorValue invalid = snapshot; + auto* root = invalid.getIf(); + (*root)["schemaVersion"] = int64_t{99}; + const auto before = target.snapshotValue(); + CHECK(!target.loadSnapshot(invalid).ok()); + CHECK_EQ(target.snapshotValue(), before); + + auto gizmo = target.gizmo(); + REQUIRE(gizmo.ok()); + CHECK(!gizmo.value().primitives.empty()); + CHECK(target.bakeMesh().indices.size() >= 3); +} + +TEST_CASE("archspace.document.opening_cutout_changes_mesh_and_helpers_work") { + Document document; + REQUIRE(document.bootstrap("site", "building", "level0").ok()); + REQUIRE(document.createRoom("level0", "hall", "Hall", {{0, 0}, {8, 0}, {8, 5}, {0, 5}}, 3.0, 0.2, 0.2).ok()); + const MeshBake before = document.bakeMesh(); + REQUIRE(document.createOpening("hall.wall.0", "hall.door", "Door", OpeningKind::Door, 0.4, 1.1, 2.1, 0.0).ok()); + REQUIRE(document.createOpening("hall.wall.1", "hall.window", "Window", OpeningKind::Window, 0.5, 1.5, 1.3, 0.9) + .ok()); + REQUIRE(document.createWall("level0", "partition", "Partition", Vec2{4, 0}, Vec2{4, 5}, 3.0, 0.15).ok()); + REQUIRE(document.placeItem("level0", "sofa", "Sofa", "furniture.sofa", Vec3{2, 0, 2.5}, 0).ok()); + const MeshBake after = document.bakeMesh(); + CHECK(after.indices.size() > before.indices.size()); + CHECK(document.find("hall.door") != nullptr); + CHECK(document.find("partition") != nullptr); + CHECK(document.find("sofa") != nullptr); + const MeshArrays arrays = document.bakeMeshArrays(); + CHECK_EQ(arrays.vertexCount() * 3, static_cast(arrays.positions.size())); + CHECK_EQ(arrays.indexCount() % 3, 0); + CHECK(arrays.triangleCount() >= 12); + + const auto hasPrimitive = [&](const char* needle) { + return std::any_of(after.primitiveIds.begin(), after.primitiveIds.end(), + [&](const std::string& id) { return id.find(needle) != std::string::npos; }); + }; + CHECK(hasPrimitive(".reveal.")); + CHECK(hasPrimitive("sofa")); + CHECK(lookupCatalog("furniture.sofa").has_value()); + CHECK(listCatalogIds().size() >= 6); + + // Machine-readable evidence for walkthrough artifacts. + std::ofstream evidence("/opt/cursor/artifacts/archspace_mesh_evidence.txt"); + evidence << "nodes=" << document.nodeCount() << "\n"; + evidence << "triangles=" << arrays.triangleCount() << "\n"; + evidence << "vertices=" << arrays.vertexCount() << "\n"; + evidence << "indices=" << arrays.indexCount() << "\n"; + evidence << "primitives=" << after.primitiveIds.size() << "\n"; + evidence << "has_door=1\n"; + evidence << "has_window=1\n"; + evidence << "has_partition=1\n"; + evidence << "has_item=1\n"; + evidence << "has_reveal=1\n"; + evidence << "has_catalog_furniture=1\n"; + evidence.close(); +} + +TEST_CASE("editor.archspace.adapter_registers_commands_and_owns_automation_targets") { + using eve::editor::Editor; + Editor editor; + eve::archspace_editor::ArchSpaceEditorModule adapter; + auto* automation = eve::cap::query(); + REQUIRE(automation != nullptr); + + const std::string commands = automation->invoke("commands", "{}"); + CHECK(commands.find("archspace.bootstrap.v1") != std::string::npos); + CHECK(commands.find("archspace.room.create.v1") != std::string::npos); + CHECK(commands.find("archspace.wall.create.v1") != std::string::npos); + CHECK(commands.find("archspace.opening.create.v1") != std::string::npos); + CHECK(commands.find("archspace.item.place.v1") != std::string::npos); + CHECK(commands.find("archspace.property.set.v1") != std::string::npos); + CHECK(commands.find("archspace.node.delete.v1") != std::string::npos); + + const std::string created = automation->invoke( + "target-create", R"({"target":"agent.archspace","type":"archspace","siteId":"site"})"); + REQUIRE(created.find("\"status\":\"applied\"") != std::string::npos); + + const std::string room = automation->invoke( + "execute", + R"({"target":"agent.archspace","command":"archspace.room.create.v1","payload":{"levelId":"site.level0","roomId":"studio","name":"Studio","originX":0,"originZ":0,"sizeX":6,"sizeZ":4}})"); + REQUIRE(room.find("\"status\":\"applied\"") != std::string::npos); + + const std::string opening = automation->invoke( + "execute", + R"({"target":"agent.archspace","command":"archspace.opening.create.v1","payload":{"wallId":"studio.wall.0","openingId":"studio.door","kind":"door","t":0.5,"width":1.0,"height":2.1,"sill":0.0}})"); + REQUIRE(opening.find("\"status\":\"applied\"") != std::string::npos); + + const std::string thickness = automation->invoke( + "execute", + R"({"target":"agent.archspace","command":"archspace.property.set.v1","payload":{"objectId":"studio.wall.0","path":"thickness","value":0.3}})"); + REQUIRE(thickness.find("\"status\":\"applied\"") != std::string::npos); + + const std::string inspected = automation->invoke("inspect", R"({"target":"agent.archspace"})"); + CHECK(inspected.find("studio") != std::string::npos); + CHECK(inspected.find("studio.door") != std::string::npos); + + const std::string closed = automation->invoke("target-close", R"({"target":"agent.archspace"})"); + CHECK(closed.find("\"status\":\"applied\"") != std::string::npos); +} diff --git a/test/editor_archspace_tools.cpp b/test/editor_archspace_tools.cpp new file mode 100644 index 00000000..b7e60a54 --- /dev/null +++ b/test/editor_archspace_tools.cpp @@ -0,0 +1,225 @@ +#include "archspace/editor/EditorArchSpaceTools.h" + +#include "archspace/ArchSpaceCatalog.h" +#include "editing/EditingAuthority.h" +#include "editor/EditorSession.h" + +#include "zeroerr/unittest.h" + +#include +#include +#include +#include + +namespace { + +using eve::archspace::NodeKind; +using eve::archspace::OpeningKind; +using eve::archspace::Vec2; +using eve::archspace::Vec3; +using eve::archspace_editing::ArchSpaceDocumentTarget; +using eve::archspace_editing::EditorResult; +using eve::editing::DomainOperation; +using eve::editor::ArchSpaceItemPlaceTool; +using eve::editor::ArchSpaceOpeningPlaceTool; +using eve::editor::ArchSpaceViewportRay; +using eve::editor::ArchSpaceWallDrawTool; +using eve::editor::EditorPointerEvent; +using eve::editor::IArchSpaceViewportAdapter; +using eve::editor::IEditorOverlay; +using eve::editor::OverlayPoint; +using eve::editor::OverlayStyle; + +void apply(ArchSpaceDocumentTarget& target, EditorResult operation) { + REQUIRE(operation.ok()); + REQUIRE(target.applyDomainOperation(operation.value()).ok()); +} + +class TestArchSpaceViewport final : public IArchSpaceViewportAdapter { +public: + eve::editing::Result pointerRay(const EditorPointerEvent& event) const override { + ArchSpaceViewportRay ray; + ray.origin = {event.x, 30.0, event.y}; + ray.direction = {0.0, -1.0, 0.0}; + return eve::editing::applied(ray); + } + + eve::editing::Result projectWorld(const std::array& world) const override { + return eve::editing::applied( + {static_cast(world[0]), static_cast(world[2]), static_cast(world[1])}); + } +}; + +class CountingAuthority final : public eve::editing::IEditAuthority { +public: + explicit CountingAuthority(eve::editing::IDomainOperationTarget* target) : local_(target) {} + + eve::editing::Result preflight( + const eve::editing::TransactionSpec& transaction, + std::span operations) override { + ++preflights; + return local_.preflight(transaction, operations); + } + + eve::editing::Result commit(const eve::editing::AuthorityPlan& plan) override { + ++commits; + return local_.commit(plan); + } + + eve::editing::Result compensate( + const eve::editing::TransactionReceipt& receipt) override { + return local_.compensate(receipt); + } + + int preflights = 0; + int commits = 0; + +private: + eve::editing::LocalWorldAuthority local_; +}; + +class CountingOverlay final : public IEditorOverlay { +public: + void line(const OverlayPoint&, const OverlayPoint&, const OverlayStyle&) override { ++lines; } + void circle(const OverlayPoint&, float, const OverlayStyle&) override { ++circles; } + void rectangle(const OverlayPoint&, const OverlayPoint&, const OverlayStyle&) override {} + void text(const OverlayPoint&, const std::string&, const OverlayStyle&) override { ++texts; } + int lines = 0; + int circles = 0; + int texts = 0; +}; + +EditorPointerEvent pointer(EditorPointerEvent::Phase phase, float x, float y, int pointerId = 7) { + EditorPointerEvent event; + event.phase = phase; + event.pointerId = pointerId; + event.button = 0; + event.x = x; + event.y = y; + return event; +} + +bool hasPrimitive(const eve::archspace::MeshBake& bake, const std::string& needle) { + return std::any_of(bake.primitiveIds.begin(), bake.primitiveIds.end(), + [&](const std::string& id) { return id.find(needle) != std::string::npos; }); +} + +} // namespace + +TEST_CASE("editor.archspace.wall_draw_tool_commits_one_authority_transaction") { + ArchSpaceDocumentTarget target("archspace-wall-tool"); + apply(target, target.makeBootstrap("site", "building", "level0")); + const auto revisionBefore = target.revision(); + + TestArchSpaceViewport viewport; + CountingAuthority authority(&target); + ArchSpaceWallDrawTool tool(&viewport, &authority); + tool.setLevelId("level0"); + tool.setIdPrefix("drawn.wall"); + + eve::editor::EditorSession session; + session.bindTarget(target); + REQUIRE(session.addTool(&tool)); + REQUIRE(session.activateTool(tool.descriptor().id)); + + CHECK(session.dispatchPointer(pointer(EditorPointerEvent::Phase::Down, 0.f, 0.f)).capturePointer); + CHECK(session.hasPointerCapture()); + CHECK(tool.isDrawing()); + CHECK(session.dispatchPointer(pointer(EditorPointerEvent::Phase::Move, 4.f, 0.f)).handled); + CHECK_EQ(authority.preflights, 0); + CHECK_EQ(authority.commits, 0); + CHECK_EQ(target.revision(), revisionBefore); + + CountingOverlay overlay; + session.drawOverlay(overlay); + CHECK_EQ(overlay.lines, 1); + CHECK_EQ(overlay.circles, 2); + + CHECK(session.dispatchPointer(pointer(EditorPointerEvent::Phase::Up, 4.f, 0.f)).releasePointer); + CHECK(!session.hasPointerCapture()); + CHECK(!tool.isDrawing()); + CHECK_EQ(authority.preflights, 1); + CHECK_EQ(authority.commits, 1); + CHECK_EQ(target.revision(), revisionBefore + 1); + REQUIRE(tool.lastReceipt().has_value()); + REQUIRE(target.document().find(tool.lastWallId()) != nullptr); + CHECK_EQ(target.document().find(tool.lastWallId())->kind, NodeKind::Wall); +} + +TEST_CASE("editor.archspace.opening_and_item_tools_commit_through_authority") { + ArchSpaceDocumentTarget target("archspace-place-tools"); + apply(target, target.makeBootstrap("site", "building", "level0")); + apply(target, target.makeCreateWall("level0", "wall.a", "Wall A", Vec2{0, 0}, Vec2{6, 0}, 3.0, 0.2)); + + TestArchSpaceViewport viewport; + CountingAuthority authority(&target); + + ArchSpaceOpeningPlaceTool openingTool(&viewport, &authority); + openingTool.setOpeningKind(OpeningKind::Door); + openingTool.setWidth(1.0); + openingTool.setHeight(2.1); + openingTool.setIdPrefix("drawn.door"); + + eve::editor::EditorSession session; + session.bindTarget(target); + REQUIRE(session.addTool(&openingTool)); + REQUIRE(session.activateTool(openingTool.descriptor().id)); + + const auto revisionBeforeOpening = target.revision(); + CHECK(session.dispatchPointer(pointer(EditorPointerEvent::Phase::Down, 3.f, 0.f)).capturePointer); + CHECK_EQ(authority.commits, 0); + CHECK(session.dispatchPointer(pointer(EditorPointerEvent::Phase::Up, 3.f, 0.f)).releasePointer); + CHECK_EQ(authority.preflights, 1); + CHECK_EQ(authority.commits, 1); + CHECK_EQ(target.revision(), revisionBeforeOpening + 1); + REQUIRE(target.document().find(openingTool.lastOpeningId()) != nullptr); + CHECK_EQ(target.document().find(openingTool.lastOpeningId())->kind, NodeKind::Opening); + + ArchSpaceItemPlaceTool itemTool(&viewport, &authority); + itemTool.setLevelId("level0"); + itemTool.setCatalogId("furniture.desk"); + itemTool.setYawDegrees(90.0); + itemTool.setIdPrefix("drawn.desk"); + REQUIRE(session.addTool(&itemTool)); + REQUIRE(session.activateTool(itemTool.descriptor().id)); + + const auto revisionBeforeItem = target.revision(); + CHECK(session.dispatchPointer(pointer(EditorPointerEvent::Phase::Down, 2.f, 2.f)).capturePointer); + CHECK(session.dispatchPointer(pointer(EditorPointerEvent::Phase::Up, 2.f, 2.f)).releasePointer); + CHECK_EQ(authority.commits, 2); + CHECK_EQ(target.revision(), revisionBeforeItem + 1); + REQUIRE(target.document().find(itemTool.lastItemId()) != nullptr); + CHECK_EQ(target.document().find(itemTool.lastItemId())->catalogId, "furniture.desk"); + + const eve::archspace::MeshBake bake = target.bakeMesh(); + CHECK(hasPrimitive(bake, ".reveal.")); + CHECK(hasPrimitive(bake, itemTool.lastItemId())); + CHECK(eve::archspace::lookupCatalog("furniture.desk").has_value()); + CHECK(eve::archspace::listCatalogIds().size() >= 6); +} + +TEST_CASE("editor.archspace.wall_draw_cancel_does_not_mutate") { + ArchSpaceDocumentTarget target("archspace-wall-cancel"); + apply(target, target.makeBootstrap("site", "building", "level0")); + const auto revisionBefore = target.revision(); + const auto nodesBefore = target.document().nodeCount(); + + TestArchSpaceViewport viewport; + CountingAuthority authority(&target); + ArchSpaceWallDrawTool tool(&viewport, &authority); + tool.setLevelId("level0"); + + eve::editor::EditorSession session; + session.bindTarget(target); + REQUIRE(session.addTool(&tool)); + REQUIRE(session.activateTool(tool.descriptor().id)); + + CHECK(session.dispatchPointer(pointer(EditorPointerEvent::Phase::Down, 1.f, 1.f)).capturePointer); + CHECK(session.dispatchPointer(pointer(EditorPointerEvent::Phase::Move, 3.f, 1.f)).handled); + CHECK(session.dispatchPointer(pointer(EditorPointerEvent::Phase::Cancel, 3.f, 1.f)).releasePointer); + CHECK_EQ(authority.preflights, 0); + CHECK_EQ(authority.commits, 0); + CHECK_EQ(target.revision(), revisionBefore); + CHECK_EQ(target.document().nodeCount(), nodesBefore); + CHECK(!tool.isDrawing()); +}