Files
EVEngine/test/editor_archspace_target.cpp
T
西风逍遥游andCursor Agent c61bf92246 feat(archspace): interactive tools, catalog furniture, parametric openings (#395)
* 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: 西风逍遥游 <sunxfancy@gmail.com>

* 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: 西风逍遥游 <sunxfancy@gmail.com>

* 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: 西风逍遥游 <sunxfancy@gmail.com>

* 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: 西风逍遥游 <sunxfancy@gmail.com>

* 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: 西风逍遥游 <sunxfancy@gmail.com>

* 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: 西风逍遥游 <sunxfancy@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-15 07:25:24 +08:00

185 lines
8.5 KiB
C++

#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 <algorithm>
#include <fstream>
using namespace eve::archspace;
using namespace eve::archspace_editing;
using namespace eve::editing;
namespace {
void apply(ArchSpaceDocumentTarget& target, EditorResult<DomainOperation> 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<EditorValue::Object>();
(*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<int>(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<eve::IEditorAutomation>();
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);
}