diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0bccd34 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.gitignore +.github +.hermes + +frontend/node_modules +frontend/dist +frontend/test-results +frontend/playwright-report + +backend/target +wasm/target +wasm/pkg + +dist +*.lpk +.env +.env.* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..17ccc81 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: HomeVox CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: homevox-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend tests + runs-on: ubuntu-24.04 + services: + postgres: + image: postgres:17-alpine + ports: + - 5432:5432 + env: + POSTGRES_DB: homevox_test + POSTGRES_USER: homevox + POSTGRES_PASSWORD: homevox + options: >- + --health-cmd "pg_isready -U homevox -d homevox_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + defaults: + run: + working-directory: backend + env: + HOMEVOX_TEST_DATABASE_URL: postgres://homevox:homevox@localhost:5432/homevox_test?sslmode=disable + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v6 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + # PostgreSQL regressions in internal/api and internal/db both exercise schema + # initialization against this single service. Keep packages serial so their + # destructive legacy-schema fixtures cannot race each other. + - run: go test -p 1 ./... + + frontend-and-wasm: + name: Frontend, WASM, and production build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm --prefix frontend ci + - run: ./scripts/bootstrap-wasm.sh + - run: cargo +1.96.1 test --manifest-path wasm/Cargo.toml + - run: npm --prefix frontend run lint + - run: npm --prefix frontend test + - run: npm --prefix frontend run build + + production-browser-e2e: + name: Production browser persistence E2E + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm --prefix frontend ci + - run: HOMEVOX_E2E_ARTIFACT_DIR="$RUNNER_TEMP/homevox-e2e-artifacts" HOMEVOX_KEEP_E2E_ARTIFACTS=1 npm --prefix frontend run test:e2e + - if: always() + uses: actions/upload-artifact@v4 + with: + name: production-browser-e2e-${{ github.sha }} + path: ${{ runner.temp }}/homevox-e2e-artifacts + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 409e5df..6e5ad45 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ Thumbs.db wasm/target/ wasm/pkg/ + +# LazyCat production release output +dist/lpk/ +*.lpk +.lzc-cli-cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cdd94c..8a16b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ ### Added +- 2D 校正工具栏可在生成 3D 前直接创建服务器端识别快照,后续墙体/门窗调整以 revision 更新同一个项目且不会重新调用候选分析或 Vision parse。项目 UUID 不作为访问凭证:创建时生成 256-bit capability,数据库只存 SHA-256;detail/source/update 必须携带专用 header,并全部使用 `Cache-Control: no-store`。继续编辑链接只在 URL fragment 中交接 capability,应用启动后立即清除 fragment;source-image URL 必须精确绑定当前项目同源 API;全局项目列表在没有 owner 身份边界时关闭。 +- capability schema 升级对旧项目采取显式 fail-closed:保留旧记录与对象,以随机不可兑换的 64-hex digest 替换缺失凭据,并把 `capability_hash` 固化为 `NOT NULL` 且受格式约束;不会让历史 UUID 退化为访问凭据。durable wall/opening ID 同时统一为前后端均可重载的 `[A-Za-z0-9_-]+`。 +- 选中墙体后可直接输入起点/终点四个 source-pixel 坐标;数值编辑保留共享端点、进入 Undo/Redo,并与拖拽共用 effective source 边界和 opening 校验。前端拒绝越界、非有限、退化或破坏开口的编辑,后端项目 create/update 同样拒绝超出 durable image grid 的墙体和房间边界。 +- 修复真实 Vision Provider 默认按内部预处理尺寸回报 `metadata.image_width/image_height`,导致有效裁切被错误拒绝的问题:解析请求显式提供后端已解码的原图精确尺寸与坐标网格,但 Provider 回报的 metadata 仅视为不可信的预处理信息;durable metadata 由上传字节的真实宽高覆盖,任何房间/墙体坐标超出 decoded image bounds 仍失败关闭。真实懒猫浏览器以 `4701 × 4501` 裁切验证 `/api/floorplans/parse` 返回 200,并进入同尺寸、可编辑墙体覆盖可见的 2D 校正页。 +- 裁切确认后的真实 Provider 等待阶段现在显示动态“正在判断当前裁切区域…”状态并禁用重复提交;若裁切图仍缺少完整、无遮挡的墙体边界,失败说明会明确要求继续调整或更换原始无遮挡图,不再重复泛化提示“裁切到单个户型”。 +- Issue #19 milestone C:导入链路改为自动候选判断;single 候选自动裁切后解析,composite/uncertain 和分析失败进入可继续的手动裁切。有效裁切图是解析、2D 底图和项目 source-image 的唯一来源,原图不持久化。生产 Playwright/fake-vision 合同验证受控提示词返回的真实矩形、裁切后的尺寸/内容及 parse 失败后的重试保留状态。 +- Issue #19 parity 的权威原型更新为 File `7622c4ac-2f6b-802b-8008-5f15321d47e8` / Page `7622c4ac-2f6b-802b-8008-5f15321d47e9`,版本 **HomeVox · 主流程 · 自动判断与裁切 · 草稿 04 · 视觉验收**,共 8 个 Board。 +- 图片在完整解码前限制单边 `16384` 像素和总计 `100,000,000` 像素,阻断压缩图片炸弹;项目创建与更新均以真实 effective source 的 MIME、字节数和像素宽高绑定 canonical document,更新时会回读不可变对象验证。 + +- Add repository-owned LazyCat production LPK configuration: local development remains a direct `0.0.0.0:18088` process reached through the development machine's port-prefix URL, while the production LPK runs HomeVox on container port `18088`, persists PostgreSQL and MinIO under `/lzcapp/var`, and keeps every `.lpk` artifact ignored. +- 修复真实高干扰营销户型图验收发现的比例尺合同矛盾:`scale.pixel_to_unit` 现在是**必填的有限 number 或显式 null**;比例未知时只能持久化为 `{ "unit": "px", "pixel_to_unit": null }`,绝不猜测物理尺寸。严格 JSON、项目保存/重载、前端 guard 与 fake vision 合同同步收紧,缺失字段、字符串 unknown、null confidence/图像尺寸、重复键和尾随 JSON 仍失败关闭。 +- AI 识别失败现在区分服务不可用、模型输出格式不完整和图片中无可靠户型拓扑;复杂营销复合图会明确提示裁切到单个户型或上传更清晰的平面图,不再误报为网络问题。 + +- 修复 Issue #19 独立视觉验收发现的产品问题:移除普通用户界面的内部标识和工程术语,统一以「待确认 / 尚未测量 / 尚未识别」表达未确认信息;真实底层状态、选择、历史、导出与失败关闭链路不变。 +- 恢复 Tailwind spacing utilities 的正常层叠,并将 01、03、04、05 的 1440 × 960 live Penpot 卡片、网格、画布和边距写入 production DOM geometry gate;2D SVG 现在自适应居中,消除右侧黑色裁切和联动页下方无意义空白。 +- 3D 完成页现在只在可见空间预览真正准备好后显示完成态;生产测试同时约束客户可见文案、选择、真实像素、开洞、异步更新和过期导出拒绝。 + +- 完成 Issue #19 的 Penpot 产品化闭环:直接通过已配置的 Streamable HTTP Penpot MCP 回读当前 live file/page 与 8 个 1440 × 960 Board,并将 232px 侧栏、72px 顶栏、Inter 字体层级、颜色、圆角、工作区与 unknown 语义固化为前端产品设计合同和 production layout gate。 +- 导入页选择真实图片后自动调用 `/api/floorplans/candidates`:清晰单户型自动裁切并进入 `/api/floorplans/parse`,复合图与不确定结果进入原像素裁切确认;任何分析失败都只降级为可操作的全图手动裁切,不伪造候选。 +- 校正、生成 3D、联动和保存页面改为对应 Penpot 的产品工作区;保留真实 canonical、R3F/Rust-WASM、双向选择、Undo/Redo、保存/reload 和 stale-export fail-closed 链路,同时不在普通界面暴露原始 ID、WASM/端口/时序或 JSON 诊断信息。 +- 为 Vite 8 开发模式补齐 React Refresh 浏览器 preamble;独立 5173 HMR 服务器可在不替换 18088 production preview 的情况下真实热更新 CSS 产品变更。 + +### Verification + +- 生产 Playwright 在隔离 PostgreSQL + MinIO 中验证:2D 数值编辑后创建 revision 1 快照,独立 Chromium context 通过 capability fragment 恢复同一坐标,fragment 在请求前从地址栏清除,GET/source/PUT 均携带专用 header,候选/parse 请求计数不增加;继续编辑后保存 revision 2,服务重启后仍恢复同一 canonical 几何。 +- 新增 `productDesign` Vitest 合同和 production Playwright shell geometry gate(232px/72px);固定 1440 × 960 screenshot、像素与交互门禁继续覆盖 01/03/04/05。 + - 完成 Issue #17 的受控 Vision 合同闭环:生产 Go multipart parse API 以 OpenAI-compatible `/chat/completions` 多模态请求处理浏览器图片,并对空/非 JSON envelope、schema-invalid opening geometry、timeout、429 与 5xx fail-closed;上游错误正文不会回显给浏览器。 - AI 解析不再把缺少 `wallId`、局部 `position` 或 `width` 的 opening 推断为已确认结构;这些候选直接拒绝,避免最近墙体或预览默认值污染 durable document。 - AI canonical 输出现在要求完整且唯一的 JSON object:拒绝未知字段、重复键、缺失或部分嵌套字段、`null`、错误 JSON 类型、legacy opening 字段及尾随第二个 JSON 值;parse 路径不再生成 ID、kind、source 或空集合。 diff --git a/README.md b/README.md index c882d5d..2c0236e 100644 --- a/README.md +++ b/README.md @@ -31,16 +31,21 @@ HomeVox 采用混合技术路线:**Go 管业务 API,Rust 管体素/几何核 - ✅ Go 单进程固定监听 `0.0.0.0:18088`,同源提供 `/api/*` 与 `frontend/dist`,并支持 SPA fallback - ✅ 户型图上传与 OpenAI-compatible AI 解析接口已落地;正向解析运行需要配置 `AI_API_KEY`、`AI_BASE_URL`、`AI_MODEL` -- ✅ 2D 户型校正编辑器支持墙体选择、共享端点拖拽、Undo/Redo 与底图显隐 +- ✅ 自动判断与裁切:新文件会先请求候选区域;单一候选自动生成裁切后的 effective source 并解析,复合/不确定候选进入可调整裁切。分析的传输、schema 或内容失败会安全回到全图手动裁切;裁切后解析失败会保留原图、候选和裁切框以便重试。 +- ✅ 持久化边界:只保存已确认并实际解析的 effective source;原图仅保留在当前浏览器会话中用于重新裁切,不会上传为项目 source image。 +- ✅ 2D 户型校正编辑器支持墙体选择、共享端点拖拽、起点/终点 source-pixel 数值输入、Undo/Redo 与底图显隐;越出 effective source、退化或破坏门窗开口的编辑不会提交。 - ✅ Issue #13:生产前端构建会从 `wasm/` 源码执行 `wasm-pack build --target web`,由受控 17³(4,913 voxel)标量场调用 Rust Marching Cubes,并在 R3F 中显示有限 position/normal 的真实 WASM 网格。 - ✅ Issue #17:浏览器上传链路的 AI 结果现在经受控 OpenAI-compatible Vision HTTP 合同和严格 canonical schema validation;未知字段、重复键、缺失/部分字段、null、错误类型和尾随 JSON 都会失败关闭,且不会补齐 AI 输出中的 ID、kind、source 或集合。 - ✅ 门窗 opening 由 wall-local `wallId`、`position`、`width` 与 `confirmed` 驱动真实 2D/3D 开洞;未确认的门高、窗高和窗台高仅为非持久化预览,绝不表述为建筑实测参数。 - ✅ WebGL 不可用时显示明确降级提示,不再留下空黑 3D 视口 - ✅ Issue #9:为当前可用 2D/3D 视图提供一次点击一次下载的 PNG 导出,支持空白/尺寸/序列化等失败闭环;3D 导出通过 R3F 渲染器即时渲染后抓取并规避对象 URL 过早回收 - ✅ Rust/WASM 几何核心已加入 Marching Cubes 功能验证 -- ✅ Issue #11:项目可持久化保存、列表加载与修订保存已接入单端口 API。服务端在上传前分配 UUID,并以同一 UUID 写入 PostgreSQL 与 `projects/{uuid}/source-image` S3/MinIO 对象键;数据库写入失败会删除该对象。 +- ✅ Issue #11:项目可持久化保存与修订保存已接入单端口 API。服务端在上传前分配 UUID,并以同一 UUID 写入 PostgreSQL 与 `projects/{uuid}/source-image` S3/MinIO 对象键;数据库写入失败会删除该对象。未建立用户身份体系前,全局项目列表关闭,避免枚举其他项目。 +- ✅ AI 识别完成后可在 2D 阶段立即创建服务器端识别快照,后续显式“保存当前修改”沿用 revision conflict 保护且不会再次调用 Vision Provider。创建项目会生成 256-bit 随机 capability,数据库只存 SHA-256;detail/source/update 必须通过专用 header 携带明文 capability,并统一返回 `Cache-Control: no-store`。跨浏览器继续编辑链接只把 capability 放在 URL fragment,前端在发起项目请求前立即从地址栏清除;source-image URL 必须精确绑定当前项目同源 API。获得该链接的人可访问和修改项目,应按敏感凭据保管。 +- ⚠️ 当前 capability 是单实例阶段的持有者读写授权,不等同于用户登录或租户隔离。正式多用户版本仍需 owner-bound identity,以及 capability 的轮换/吊销入口;在此之前不要把继续编辑链接发给不受信任的人。 +- ⚠️ capability 上线前创建的旧项目没有可恢复的明文凭据。schema 升级会保留其数据库记录与对象,但以随机不可兑换 digest 明确退役访问,不会把旧 UUID 继续当作凭据;如需继续编辑,须从原始户型图重新创建受保护快照。 - ✅ 项目 API 仅在 PostgreSQL schema/ping 与 S3 bucket 均验证就绪时可用;`/api/config` 分别报告 `not_configured`、`incomplete_config`、`unavailable` 或 `ready` 状态。 -- ✅ 已用隔离 PostgreSQL + MinIO 验证 create/get/list/source-image/update/stale-revision-409、重启后加载及未配置持久化时 project API 的 503;该验收使用 fixture 文档,不包含真实 AI 正向解析。 +- ✅ 已用隔离 PostgreSQL + MinIO 验证 create、capability-gated get/source-image/update、stale-revision-409、独立浏览器恢复、重启后加载及未配置持久化时 project API 的 503;无 capability 的 UUID-only 请求失败关闭,全局 list 不可枚举。 - 3D 视口公开引擎状态、grid、三角形/顶点、调用耗时与输入/输出字节;单次主线程 WASM 调用预算为 50ms。加载、输入、输出或预算失败时明确回退到既有 wall-shell,2D 编辑、项目加载和 PNG 导出仍可用。 ## 可复现 WASM 与浏览器验收 @@ -59,6 +64,31 @@ npm --prefix frontend run test:e2e 构建会生成忽略的 `wasm/pkg/` bindings 与 `frontend/dist/`;不要提交它们。生产浏览器验收由 Go 在 `0.0.0.0:18088` 提供 production assets,加载实际 `.wasm`(`application/wasm`),并使用受控 17³ fixture 验证 Rust 调用、有限几何、拖拽/Undo/Redo、3D PNG 下载及 reload 后重建。Playwright 首次使用前执行 `npm --prefix frontend exec playwright install chromium`。 +## 本地开发与 LazyCat 生产发布 + +本地开发与 LazyCat 生产发布是两条独立链路。HomeVox 在开发机和生产容器内都固定监听 `0.0.0.0:18088`。 + +本地开发直接启动 Go 单端口服务,并通过开发机的 18088 端口前缀域名访问;不运行 `lzc-cli project deploy`,也不安装开发 LPK。若要跨浏览器保存识别快照,必须在进程环境或 ignored `backend/.env` 中提供可用的 `DATABASE_URL`、`S3_ENDPOINT`、`S3_BUCKET`、`S3_ACCESS_KEY_ID`、`S3_SECRET_ACCESS_KEY`,并以 `/api/config` 的 `databaseStatus: "ready"`、`s3Status: "ready"` 为准: + +```bash +npm --prefix frontend ci +npm --prefix frontend run build +HOMEVOX_FRONTEND_DIR="$PWD/frontend/dist" go -C backend run ./cmd/server +# 浏览器访问开发机的 18088 端口前缀域名 +``` + +仓库中的 `lzc-manifest.yml`、`lzc-build.yml`、`lzc-deploy-params.yml`、`package.yml`、`lzc-icon.png` 与 `images/Dockerfile` 只定义生产 LPK。生产发布使用 `lzc-cli project release -o <仓库外绝对路径>`,LPK 将入口转发至正式的 `homevox:18088` 容器,并包含 PostgreSQL 与 MinIO。任何 `.lpk` 都不得提交到仓库;LPK 的构建、验包、安装和发布需要单独授权。 + +部署包含 PostgreSQL 与 MinIO 持久服务,数据分别保存在 `/lzcapp/var/postgres` 和 `/lzcapp/var/minio`。数据库和对象存储使用 LazyCat `stable_secret` 生成实例内稳定密码;AI Provider 凭据不写入仓库或安装包,未配置时识别链路保持 fail-closed。 + +### d53 历史项目恢复密钥 + +`legacy_recovery_key` 是可选的 LazyCat `secret` 部署参数。运行期将其仅映射为 +`HOMEVOX_LEGACY_RECOVERY_KEY`;不要把值写入仓库、镜像、普通环境文件或日志。 +未配置时 HomeVox 正常启动,但一次性 d53 恢复端点保持 `403` fail-closed;该值仅 +用于人工核验归属后调用恢复端点。需要恢复时可重新配置应用实例,不需要也不应将 +密钥重新打包进 LPK。 + ## 许可 本项目采用 **GNU Affero General Public License v3.0 (AGPL-3.0)**。 diff --git a/backend/README.md b/backend/README.md index df7042b..4348f8a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -23,3 +23,17 @@ The service uses one fixed public listener, `0.0.0.0:18088`: - Missing assets and unknown API routes return `404`. `HOMEVOX_FRONTEND_DIR` is required and must contain `index.html`; startup fails closed when the frontend build is missing. The backend deliberately ignores `HOMEVOX_LISTEN_ADDR` and never drifts from the fixed listener above. + +## d53 legacy-project recovery + +The historical `d53b949` schema replaced legacy `NULL` capabilities with random +64-hex digests. Those values cannot be distinguished automatically from a valid +bearer digest. Do **not** bulk-clear `capability_hash`. + +After out-of-band ownership verification, an operator must add exactly one +`legacy_project_recovery_authorizations` row containing the project ID, the +currently observed retired digest, operator identity, and case reference. The +existing operator-key-gated recovery endpoint can then issue a replacement +capability; it writes `legacy_project_recovery_audit` and cannot be repeated. +Rows without that authorization (including ordinary bearer projects) remain +untouched. diff --git a/backend/cmd/fake-vision/main.go b/backend/cmd/fake-vision/main.go index 07a61d6..f377cc0 100644 --- a/backend/cmd/fake-vision/main.go +++ b/backend/cmd/fake-vision/main.go @@ -1,17 +1,32 @@ package main import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "encoding/json" + "fmt" + "image" + "image/draw" + _ "image/png" "log" "net/http" + "strings" + "sync" + "time" ) const ( - modelName = "e2e-fake-vision" - fixtureDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAYAAAC56t6BAAAAF0lEQVR4nGL6////fwZkwARjAAIAAP//YgEEAT/f/TcAAAAASUVORK5CYII=" - visionSystem = "You extract residential floor-plan structure. Return only one strict JSON object matching this schema: {rooms:[{name,type,approximate_bounds:{x1,y1,x2,y2},area_ratio}], walls:[{id,x1,y1,x2,y2}], doors:[{id,kind,wallId,position,width,source,confirmed}], windows:[{id,kind,wallId,position,width,source,confirmed}], scale:{unit,pixel_to_unit}, metadata:{source,confidence,image_width,image_height}}. Every listed field is required, no additional fields are accepted, and arrays may be empty. Use pixel coordinates when exact scale is unknown. Never infer or fabricate wall associations, opening widths, architectural dimensions, scale, orientation, height, thickness, or load-bearing status." - visionUser = "Parse this floor-plan image into the required JSON structure. Do not include markdown fences. Omit an opening if its wall-local position or width cannot be established." - visionResponse = `{"choices":[{"message":{"content":"{\"rooms\":[],\"walls\":[{\"id\":\"wall-1\",\"x1\":80,\"y1\":80,\"x2\":520,\"y2\":80},{\"id\":\"wall-2\",\"x1\":520,\"y1\":80,\"x2\":520,\"y2\":360},{\"id\":\"wall-3\",\"x1\":520,\"y1\":360,\"x2\":80,\"y2\":360},{\"id\":\"wall-4\",\"x1\":80,\"y1\":360,\"x2\":80,\"y2\":80}],\"doors\":[{\"id\":\"door-1\",\"kind\":\"door\",\"wallId\":\"wall-1\",\"position\":0.5,\"width\":72,\"source\":\"controlled-e2e-fake\",\"confirmed\":true}],\"windows\":[{\"id\":\"window-1\",\"kind\":\"window\",\"wallId\":\"wall-2\",\"position\":0.5,\"width\":64,\"source\":\"controlled-e2e-fake\",\"confirmed\":true}],\"scale\":{\"unit\":\"px\",\"pixel_to_unit\":1},\"metadata\":{\"source\":\"controlled-e2e-fake\",\"confidence\":1,\"image_width\":600,\"image_height\":440}}"}}]}` + modelName = "e2e-fake-vision" + fixtureDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAlgAAAG4CAIAAAAWqA6UAAAJQklEQVR4nO3VsY1kVRRF0cmCCMgBEQfhkBoh4Y8QBm4ZR8OMxOdWaS9pmW20Xv1z95evf/0NAFlfzv8DADgkhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkPRvCn37+jX91/hG8p/PfBXgrz10bIbx3npz3dP67AG/luWsjhPdeX+z3X/98dV6jQ+e/C/BWnrs2Qnjv9cWE0McDTM9dGyG89/piQujjAabnro0Q3nt9MSH08QDTc9dGCO+9vpgQ+niA6blrI4T3Xl9MCH08wPTctRHCe68vJoQ+HmB67tqchfD8zr4nIYwwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYSKb0wDmJ7boxDCgfObAh/nuT0KIRw4vynwcZ7boxDCgfObAh/nuT0KIRw4vynwcZ7boxDCgfObAh/nuT0KIRw4vynwcZ7boxDCgfObAh/nuT0KIRw4vynwcZ7boxAC8C6EEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgB4P8mhACkCSEAaUIIQJoQAnDmlz++vvpP/vJHCSEAZ4QQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBIBjQghAmhACkCaEAKQJIQDv4qQaQgjAuxBCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANJaIQSA7yeEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkPapIQSANyeEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKT9A4N4s+U669UwAAAAAElFTkSuQmCC" + visionSystem = "You extract residential floor-plan structure. Return only one strict JSON object matching this schema: {rooms:[{name,type,approximate_bounds:{x1,y1,x2,y2},area_ratio}], walls:[{id,x1,y1,x2,y2}], doors:[{id,kind,wallId,position,width,source,confirmed}], windows:[{id,kind,wallId,position,width,source,confirmed}], scale:{unit,pixel_to_unit}, metadata:{source,confidence,image_width,image_height}}. Every listed field is required, no additional fields are accepted, and arrays may be empty. All coordinates and opening widths are image pixels. scale.pixel_to_unit must be either a finite number or null, never a string: when no physical scale is explicitly visible, return exactly scale:{unit:\"px\",pixel_to_unit:null}; do not infer a conversion, orientation, height, thickness, or load-bearing status. metadata.confidence must be a finite number and image_width/image_height must be integers." + visionUser = "Parse this floor-plan image into the required JSON structure. The original uploaded image is exactly %d pixels wide and %d pixels high. Use that exact original %d x %d pixel coordinate grid for every room bound, wall endpoint, and opening width, and return metadata.image_width exactly %d and metadata.image_height exactly %d. Do not include markdown fences. Omit an opening if its wall-local position or width cannot be established." + candidateSystem = "Find reliable single-floor-plan crops in this source image. Return only one strict JSON object matching this schema: {mode:string,candidates:[{x:number,y:number,width:number,height:number}]}. mode must be exactly one of \"single\", \"composite\", or \"uncertain\". Candidates must be absolute integer-pixel rectangles on the source image. Return mode \"uncertain\" with candidates:[] when no reliable crop can be established. Return \"single\" with exactly one candidate; return \"composite\" with two or more non-overlapping candidates. Do not add fields or markdown." + candidateUser = "Detect reliable crop rectangles that each contain one standalone floor plan. Do not guess a crop when the image is ambiguous." + singleCandidateResponse = `{"choices":[{"message":{"content":"{\"mode\":\"single\",\"candidates\":[{\"x\":20,\"y\":20,\"width\":560,\"height\":400}]}"}}]}` + compositeCandidateResponse = `{"choices":[{"message":{"content":"{\"mode\":\"composite\",\"candidates\":[{\"x\":20,\"y\":20,\"width\":260,\"height\":400},{\"x\":320,\"y\":20,\"width\":260,\"height\":400}]}"}}]}` + visionResponse = `{"choices":[{"message":{"content":"{\"rooms\":[],\"walls\":[{\"id\":\"wall-1\",\"x1\":60,\"y1\":60,\"x2\":500,\"y2\":60},{\"id\":\"wall-2\",\"x1\":500,\"y1\":60,\"x2\":500,\"y2\":340},{\"id\":\"wall-3\",\"x1\":500,\"y1\":340,\"x2\":60,\"y2\":340},{\"id\":\"wall-4\",\"x1\":60,\"y1\":340,\"x2\":60,\"y2\":60}],\"doors\":[{\"id\":\"door-1\",\"kind\":\"door\",\"wallId\":\"wall-1\",\"position\":0.5,\"width\":72,\"source\":\"controlled-e2e-fake\",\"confirmed\":true}],\"windows\":[{\"id\":\"window-1\",\"kind\":\"window\",\"wallId\":\"wall-2\",\"position\":0.5,\"width\":64,\"source\":\"controlled-e2e-fake\",\"confirmed\":true}],\"scale\":{\"unit\":\"px\",\"pixel_to_unit\":null},\"metadata\":{\"source\":\"controlled-e2e-fake\",\"confidence\":1,\"image_width\":560,\"image_height\":400}}"}}]}` ) type chatRequest struct { @@ -22,14 +37,21 @@ type chatRequest struct { } `json:"messages"` } -func validContract(request chatRequest) bool { - if request.Model != modelName || len(request.Messages) != 2 || request.Messages[0].Role != "system" || request.Messages[1].Role != "user" { - return false - } - var system string - if json.Unmarshal(request.Messages[0].Content, &system) != nil || system != visionSystem { - return false - } +type requestFact struct { + Prompt string `json:"prompt"` + Width int `json:"width"` + Height int `json:"height"` + ImageDiffers bool `json:"imageDiffers"` + CropMatches bool `json:"cropMatches"` +} + +var facts struct { + sync.Mutex + Requests []requestFact + CandidateMode string +} + +func requestImage(content json.RawMessage) (string, bool) { var user []struct { Type string `json:"type"` Text string `json:"text"` @@ -37,10 +59,103 @@ func validContract(request chatRequest) bool { URL string `json:"url"` } `json:"image_url"` } - if json.Unmarshal(request.Messages[1].Content, &user) != nil || len(user) != 2 { + if json.Unmarshal(content, &user) != nil || len(user) != 2 || user[0].Type != "text" || user[1].Type != "image_url" { + return "", false + } + return user[1].ImageURL.URL, true +} +func decodeImage(dataURL string) (image.Image, bool) { + comma := strings.IndexByte(dataURL, ',') + if comma < 0 { + return nil, false + } + data, err := base64.StdEncoding.DecodeString(dataURL[comma+1:]) + if err != nil { + return nil, false + } + decoded, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, false + } + return decoded, true +} +func imageSize(dataURL string) (int, int, bool) { + decoded, ok := decodeImage(dataURL) + if !ok { + return 0, 0, false + } + return decoded.Bounds().Dx(), decoded.Bounds().Dy(), true +} +func cropImage(source image.Image, rect image.Rectangle) image.Image { + target := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy())) + draw.Draw(target, target.Bounds(), source, rect.Min, draw.Src) + return target +} +func pixelDigest(source image.Image) string { + bounds := source.Bounds() + hash := sha256.New() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, a := source.At(x, y).RGBA() + _, _ = hash.Write([]byte{byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)}) + } + } + return hex.EncodeToString(hash.Sum(nil)) +} +func matchesExpectedCrop(dataURL string) bool { + actual, actualOK := decodeImage(dataURL) + source, sourceOK := decodeImage(fixtureDataURL) + if !actualOK || !sourceOK { + return false + } + if actual.Bounds().Dx() != 560 || actual.Bounds().Dy() != 400 { return false } - return user[0].Type == "text" && user[0].Text == visionUser && user[1].Type == "image_url" && user[1].ImageURL.URL == fixtureDataURL + actualDigest := pixelDigest(actual) + // The lifecycle parses the recommended crop, then deliberately nudges it one + // source pixel before retrying. Both positions are explicit product actions; + // any other same-size crop must fail the controlled pixel-content contract. + for _, left := range []int{20, 21} { + if actualDigest == pixelDigest(cropImage(source, image.Rect(left, 20, left+560, 420))) { + return true + } + } + return false +} +func validContract(request chatRequest) (string, bool) { + if request.Model != modelName || len(request.Messages) != 2 || request.Messages[0].Role != "system" || request.Messages[1].Role != "user" { + return "", false + } + var system string + if json.Unmarshal(request.Messages[0].Content, &system) != nil { + return "", false + } + imageURL, ok := requestImage(request.Messages[1].Content) + if !ok { + return "", false + } + if system == candidateSystem { + var user []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if json.Unmarshal(request.Messages[1].Content, &user) != nil || user[0].Text != candidateUser || imageURL != fixtureDataURL { + return "", false + } + return "candidate", true + } + if system != visionSystem { + return "", false + } + var user []struct { + Type string `json:"type"` + Text string `json:"text"` + } + expectedPrompt := fmt.Sprintf(visionUser, 560, 400, 560, 400, 560, 400) + if json.Unmarshal(request.Messages[1].Content, &user) != nil || user[0].Text != expectedPrompt || !strings.HasPrefix(imageURL, "data:image/png;base64,") { + return "", false + } + return "parse", true } func main() { @@ -50,12 +165,62 @@ func main() { return } var request chatRequest - if err := json.NewDecoder(r.Body).Decode(&request); err != nil || !validContract(request) { + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "invalid controlled vision request", http.StatusBadRequest) + return + } + kind, ok := validContract(request) + if !ok { http.Error(w, "invalid controlled vision contract", http.StatusBadRequest) return } + imageURL, _ := requestImage(request.Messages[1].Content) + width, height, decoded := imageSize(imageURL) + facts.Lock() + cropMatches := kind == "parse" && matchesExpectedCrop(imageURL) + facts.Requests = append(facts.Requests, requestFact{Prompt: kind, Width: width, Height: height, ImageDiffers: imageURL != fixtureDataURL, CropMatches: cropMatches}) + facts.Unlock() w.Header().Set("Content-Type", "application/json") + if kind == "candidate" { + time.Sleep(400 * time.Millisecond) + facts.Lock() + mode := facts.CandidateMode + facts.Unlock() + if mode == "composite" { + _, _ = w.Write([]byte(compositeCandidateResponse)) + } else { + _, _ = w.Write([]byte(singleCandidateResponse)) + } + return + } + if !decoded || width != 560 || height != 400 || imageURL == fixtureDataURL || !cropMatches { + http.Error(w, fmt.Sprintf("expected cropped 560x400 parse image, got %dx%d", width, height), http.StatusBadRequest) + return + } _, _ = w.Write([]byte(visionResponse)) }) + http.HandleFunc("/e2e/requests", func(w http.ResponseWriter, _ *http.Request) { + facts.Lock() + defer facts.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(facts.Requests) + }) + http.HandleFunc("/e2e/candidate-mode", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Mode string `json:"mode"` + } + if json.NewDecoder(r.Body).Decode(&body) != nil || (body.Mode != "single" && body.Mode != "composite") { + http.Error(w, "invalid candidate mode", http.StatusBadRequest) + return + } + facts.Lock() + facts.CandidateMode = body.Mode + facts.Unlock() + w.WriteHeader(http.StatusNoContent) + }) log.Fatal(http.ListenAndServe("0.0.0.0:18089", nil)) } diff --git a/backend/cmd/fake-vision/main_test.go b/backend/cmd/fake-vision/main_test.go new file mode 100644 index 0000000..01080da --- /dev/null +++ b/backend/cmd/fake-vision/main_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "image" + "image/color" + "testing" +) + +func TestPixelDigestProvesCropPositionNotOnlyDimensions(t *testing.T) { + source := image.NewRGBA(image.Rect(0, 0, 12, 8)) + for y := 0; y < 8; y++ { + for x := 0; x < 12; x++ { + source.Set(x, y, color.RGBA{R: uint8(x * 11), G: uint8(y * 17), B: uint8(x + y), A: 255}) + } + } + want := cropImage(source, image.Rect(2, 1, 8, 6)) + wrong := cropImage(source, image.Rect(3, 1, 9, 6)) + if pixelDigest(want) == pixelDigest(wrong) { + t.Fatal("same-size crops from different source positions must have different pixel digests") + } +} diff --git a/backend/internal/api/project_routes_test.go b/backend/internal/api/project_routes_test.go index df4f098..8632f8d 100644 --- a/backend/internal/api/project_routes_test.go +++ b/backend/internal/api/project_routes_test.go @@ -3,11 +3,13 @@ package api import ( "bytes" "context" + "crypto/sha256" "encoding/json" "fmt" "mime/multipart" "net/http" "net/http/httptest" + "os" "strings" "testing" "time" @@ -16,32 +18,36 @@ import ( "github.com/KingBoyAndGirl/HomeVox/backend/internal/project" "github.com/KingBoyAndGirl/HomeVox/backend/internal/storage" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" ) type fakeProjectRepo struct { - projects map[string]db.Project - nextID int - lastCreatedID string - lastCreatedKey string - createErr error - initializeErr error - getCalls int + projects map[string]db.Project + capabilityHashes map[string]string + nextID int + lastCreatedID string + lastCreatedKey string + createErr error + initializeErr error + getCalls int } -const validProjectDocument = `{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture","image_width":100,"image_height":80}}}` +const validProjectDocument = `{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}` func projectDocumentForSourceImage(t testing.TB) string { t.Helper() - return strings.Replace(validProjectDocument, `"size":12`, fmt.Sprintf(`"size":%d`, len(validPNG(t))), 1) + document := strings.Replace(validProjectDocument, `"size":12`, fmt.Sprintf(`"size":%d`, len(validPNG(t))), 1) + document = strings.Replace(document, `"image_width":100`, `"image_width":2`, 1) + return strings.Replace(document, `"image_height":80`, `"image_height":3`, 1) } func newFakeProjectRepo() *fakeProjectRepo { - return &fakeProjectRepo{projects: make(map[string]db.Project)} + return &fakeProjectRepo{projects: make(map[string]db.Project), capabilityHashes: make(map[string]string)} } func (f *fakeProjectRepo) InitializeSchema(_ context.Context) error { return f.initializeErr } -func (f *fakeProjectRepo) Create(_ context.Context, id, name, sourceImageKey, sourceImageContentType string, sourceImageSize int64, document json.RawMessage) (db.Project, error) { +func (f *fakeProjectRepo) Create(_ context.Context, id, capabilityHash, name, sourceImageKey, sourceImageContentType string, sourceImageSize int64, document json.RawMessage) (db.Project, error) { f.lastCreatedID = id f.lastCreatedKey = sourceImageKey if f.createErr != nil { @@ -63,21 +69,22 @@ func (f *fakeProjectRepo) Create(_ context.Context, id, name, sourceImageKey, so UpdatedAt: time.Now().UTC(), } f.projects[id] = project + f.capabilityHashes[id] = capabilityHash return project, nil } -func (f *fakeProjectRepo) Get(_ context.Context, id string) (db.Project, error) { +func (f *fakeProjectRepo) Get(_ context.Context, id, capabilityHash string) (db.Project, error) { f.getCalls++ project, ok := f.projects[id] - if !ok { + if !ok || f.capabilityHashes[id] != capabilityHash { return db.Project{}, db.ErrProjectNotFound } return project, nil } -func (f *fakeProjectRepo) Update(_ context.Context, id string, expectedRevision int, name string, document json.RawMessage) (db.Project, error) { +func (f *fakeProjectRepo) Update(_ context.Context, id, capabilityHash string, expectedRevision int, name string, document json.RawMessage) (db.Project, error) { project, ok := f.projects[id] - if !ok { + if !ok || f.capabilityHashes[id] != capabilityHash { return db.Project{}, db.ErrProjectNotFound } if project.Revision != expectedRevision { @@ -91,27 +98,23 @@ func (f *fakeProjectRepo) Update(_ context.Context, id string, expectedRevision return project, nil } -func (f *fakeProjectRepo) List(_ context.Context, limit int) ([]db.ProjectSummary, error) { - if limit <= 0 { - limit = 1 - } - if limit > 100 { - limit = 100 - } - items := make([]db.ProjectSummary, 0, len(f.projects)) - for _, project := range f.projects { - items = append(items, db.ProjectSummary{ - ID: project.ID, - Name: project.Name, - Revision: project.Revision, - CreatedAt: project.CreatedAt, - UpdatedAt: project.UpdatedAt, - }) +func (f *fakeProjectRepo) LegacyRecoveryCandidate(_ context.Context, id string) (db.Project, error) { + project, ok := f.projects[id] + if !ok { + return db.Project{}, db.ErrProjectNotFound } - if len(items) > limit { - return items[:limit], nil + return project, nil +} + +func (f *fakeProjectRepo) RecoverLegacy(_ context.Context, id, capabilityHash, _ string, document json.RawMessage) (db.Project, error) { + project, ok := f.projects[id] + if !ok || f.capabilityHashes[id] != "" { + return db.Project{}, db.ErrProjectNotFound } - return items, nil + f.capabilityHashes[id] = capabilityHash + project.Document = document + f.projects[id] = project + return project, nil } func (f *fakeProjectRepo) Close() {} @@ -180,6 +183,65 @@ func newProjectRouter(repo db.ProjectRepository, store storage.ObjectStore) *gin return router } +func newLegacyRecoveryRouter(repo db.ProjectRepository, store storage.ObjectStore, key string) *gin.Engine { + router := gin.New() + deps := readyDeps(repo, store) + deps.legacyRecoveryKey = key + registerProjectRoutes(router, deps) + return router +} + +const testProjectCapability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + +func testCapabilityHash() string { + sum := sha256.Sum256([]byte(testProjectCapability)) + return fmt.Sprintf("%x", sum[:]) +} + +func authorizeProjectRequest(request *http.Request, capability string) { + request.Header.Set(projectCapabilityHeader, capability) +} + +func seedAuthorizedProject(t testing.TB, repo *fakeProjectRepo, store *fakeObjectStore, projectID string) { + t.Helper() + repo.projects[projectID] = db.Project{ + ID: projectID, Name: "Private plan", SourceImageKey: sourceImageKey(projectID), + SourceImageContentType: "image/png", SourceImageSize: int64(len(validPNG(t))), + Document: json.RawMessage(projectDocumentForSourceImage(t)), Revision: 1, + CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + } + repo.capabilityHashes[projectID] = testCapabilityHash() + store.objects[sourceImageKey(projectID)] = fakeObject{data: validPNG(t), contentType: "image/png"} +} + +func TestProjectRoutesRequireCapabilityInsteadOfProjectID(t *testing.T) { + repo := newFakeProjectRepo() + store := newFakeObjectStore() + router := newProjectRouter(repo, store) + projectID := "00000000-0000-0000-0000-000000000001" + seedAuthorizedProject(t, repo, store, projectID) + + for _, target := range []struct { + method string + path string + }{ + {http.MethodGet, "/api/projects"}, + {http.MethodGet, "/api/projects/" + projectID}, + {http.MethodGet, "/api/projects/" + projectID + "/source-image"}, + {http.MethodPut, "/api/projects/" + projectID}, + } { + req := httptest.NewRequest(target.method, target.path, nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + if response.Code != http.StatusUnauthorized { + t.Fatalf("%s %s status = %d, want %d", target.method, target.path, response.Code, http.StatusUnauthorized) + } + if strings.Contains(response.Body.String(), projectID) { + t.Fatalf("%s %s leaked project identity: %s", target.method, target.path, response.Body.String()) + } + } +} + func TestProjectCreateRequiresValidatedInput(t *testing.T) { router := newProjectRouter(newFakeProjectRepo(), newFakeObjectStore()) @@ -206,11 +268,11 @@ func TestProjectCreateRequiresValidatedInput(t *testing.T) { } } -func TestProjectAPIReturnsUnavailableWhenPersistenceNotReady(t *testing.T) { +func TestProjectCreateReturnsUnavailableWhenPersistenceNotReady(t *testing.T) { router := gin.New() registerProjectRoutes(router, projectDependencies{databaseStatus: statusUnavailable, s3Status: statusUnavailable}) - req := httptest.NewRequest(http.MethodGet, "/api/projects", nil) + req := httptest.NewRequest(http.MethodPost, "/api/projects", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusServiceUnavailable { @@ -222,7 +284,7 @@ func TestProjectAPIReturnsUnavailableWhenPersistenceNotReady(t *testing.T) { } } -func TestProjectCreateGetsListAndSourceImage(t *testing.T) { +func TestProjectCreateReturnsCapabilityAndAuthorizesReads(t *testing.T) { repo := newFakeProjectRepo() store := newFakeObjectStore() router := newProjectRouter(repo, store) @@ -249,12 +311,19 @@ func TestProjectCreateGetsListAndSourceImage(t *testing.T) { if createW.Code != http.StatusCreated { t.Fatalf("create status = %d, body=%s", createW.Code, createW.Body.String()) } + if got := createW.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("create Cache-Control = %q, want no-store for one-time capability response", got) + } var created map[string]any if err := json.Unmarshal(createW.Body.Bytes(), &created); err != nil { t.Fatalf("unmarshal response: %v", err) } id := created["id"].(string) + capability, ok := created["capability"].(string) + if !ok || !projectCapabilityRegex.MatchString(capability) { + t.Fatalf("create capability is missing or malformed") + } if repo.lastCreatedID != id { t.Fatalf("repo ID = %s, response ID = %s", repo.lastCreatedID, id) } @@ -265,27 +334,28 @@ func TestProjectCreateGetsListAndSourceImage(t *testing.T) { if _, ok := store.objects[wantKey]; !ok { t.Fatalf("upload did not use project UUID key %s", wantKey) } + storedHash := repo.capabilityHashes[id] + if storedHash == "" || storedHash == capability || storedHash != hashProjectCapability(capability) { + t.Fatalf("repository must receive only the capability hash") + } listReq := httptest.NewRequest(http.MethodGet, "/api/projects?limit=1", nil) listW := httptest.NewRecorder() router.ServeHTTP(listW, listReq) - if listW.Code != http.StatusOK { - t.Fatalf("list status = %d", listW.Code) - } - var list []map[string]any - if err := json.Unmarshal(listW.Body.Bytes(), &list); err != nil { - t.Fatalf("unmarshal list: %v", err) - } - if got := list[0]["id"].(string); got != id { - t.Fatalf("list first id = %s, want %s", got, id) + if listW.Code != http.StatusUnauthorized || strings.Contains(listW.Body.String(), id) { + t.Fatalf("list must not enumerate projects; status=%d body=%s", listW.Code, listW.Body.String()) } sourceImageReq := httptest.NewRequest(http.MethodGet, created["sourceImageURL"].(string), nil) + authorizeProjectRequest(sourceImageReq, capability) sourceImageW := httptest.NewRecorder() router.ServeHTTP(sourceImageW, sourceImageReq) if sourceImageW.Code != http.StatusOK { t.Fatalf("source image status = %d", sourceImageW.Code) } + if got := sourceImageW.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("source image Cache-Control = %q, want no-store", got) + } if sourceImageW.Header().Get("Content-Type") != "image/png" { t.Fatalf("content type = %s", sourceImageW.Header().Get("Content-Type")) } @@ -294,11 +364,197 @@ func TestProjectCreateGetsListAndSourceImage(t *testing.T) { } getReq := httptest.NewRequest(http.MethodGet, "/api/projects/"+id, nil) + authorizeProjectRequest(getReq, capability) getW := httptest.NewRecorder() router.ServeHTTP(getW, getReq) if getW.Code != http.StatusOK { t.Fatalf("get status = %d", getW.Code) } + if strings.Contains(getW.Body.String(), capability) { + t.Fatal("project detail must not echo the capability") + } +} + +func TestProjectReadWithWrongCapabilityReturnsGenericNotFound(t *testing.T) { + repo := newFakeProjectRepo() + store := newFakeObjectStore() + projectID := "00000000-0000-0000-0000-000000000001" + seedAuthorizedProject(t, repo, store, projectID) + router := newProjectRouter(repo, store) + + req := httptest.NewRequest(http.MethodGet, "/api/projects/"+projectID, nil) + authorizeProjectRequest(req, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d; body=%s", w.Code, http.StatusNotFound, w.Body.String()) + } + if strings.Contains(w.Body.String(), projectID) || strings.Contains(w.Body.String(), testProjectCapability) { + t.Fatalf("unauthorized response leaked project identity or capability: %s", w.Body.String()) + } +} + +func TestLegacyProjectRecoveryRequiresOperatorKeyAndIssuesOneNewCapability(t *testing.T) { + repo := newFakeProjectRepo() + store := newFakeObjectStore() + id := "00000000-0000-0000-0000-000000000019" + repo.projects[id] = db.Project{ + ID: id, Name: "Legacy plan", SourceImageKey: sourceImageKey(id), + SourceImageContentType: "image/png", SourceImageSize: int64(len(validPNG(t))), + Document: json.RawMessage(projectDocumentForSourceImage(t)), Revision: 1, + CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + } + store.objects[sourceImageKey(id)] = fakeObject{data: validPNG(t), contentType: "image/png"} + router := newLegacyRecoveryRouter(repo, store, "operator-only-key") + + for _, key := range []string{"", "wrong-key"} { + req := httptest.NewRequest(http.MethodPost, "/api/projects/"+id+"/recover", nil) + req.Header.Set("X-HomeVox-Legacy-Recovery-Key", key) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("recovery with %q status=%d body=%s", key, w.Code, w.Body.String()) + } + } + + req := httptest.NewRequest(http.MethodPost, "/api/projects/"+id+"/recover", nil) + req.Header.Set("X-HomeVox-Legacy-Recovery-Key", "operator-only-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("recovery status=%d body=%s", w.Code, w.Body.String()) + } + var recovered struct { + ID string `json:"id"` + Capability string `json:"capability"` + } + if err := json.Unmarshal(w.Body.Bytes(), &recovered); err != nil { + t.Fatal(err) + } + if recovered.ID != id || !projectCapabilityRegex.MatchString(recovered.Capability) { + t.Fatalf("unexpected recovery response: %+v", recovered) + } + if repo.capabilityHashes[id] != hashProjectCapability(recovered.Capability) { + t.Fatal("recovery did not atomically assign the returned capability") + } + + again := httptest.NewRequest(http.MethodPost, "/api/projects/"+id+"/recover", nil) + again.Header.Set("X-HomeVox-Legacy-Recovery-Key", "operator-only-key") + againW := httptest.NewRecorder() + router.ServeHTTP(againW, again) + if againW.Code != http.StatusNotFound { + t.Fatalf("second recovery status=%d body=%s", againW.Code, againW.Body.String()) + } +} + +func TestLegacyProjectRecoveryFailsClosedWhenOperatorKeyIsUnconfigured(t *testing.T) { + router := newLegacyRecoveryRouter(newFakeProjectRepo(), newFakeObjectStore(), "") + request := httptest.NewRequest(http.MethodPost, "/api/projects/00000000-0000-0000-0000-000000000019/recover", nil) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("unconfigured recovery key status=%d, want %d; body=%s", response.Code, http.StatusForbidden, response.Body.String()) + } +} + +func TestLegacyRecoveryPostgresRouteUpgradesOldDocumentBeforeOneTimeClaim(t *testing.T) { + dsn := os.Getenv("HOMEVOX_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("HOMEVOX_TEST_DATABASE_URL not set; skipping PostgreSQL route regression") + } + ctx := context.Background() + repo, err := db.NewPostgresRepository(ctx, dsn) + if err != nil { + t.Fatalf("new postgres repository: %v", err) + } + defer repo.Close() + if err := repo.InitializeSchema(ctx); err != nil { + t.Fatalf("initialize schema: %v", err) + } + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("new postgres pool: %v", err) + } + defer pool.Close() + if _, err := pool.Exec(ctx, `TRUNCATE TABLE projects CASCADE`); err != nil { + t.Fatalf("truncate projects: %v", err) + } + + const id = "00000000-0000-4000-8000-000000000053" + const retiredHash = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + image := validPNG(t) + var legacyDocument map[string]any + if err := json.Unmarshal([]byte(projectDocumentForSourceImage(t)), &legacyDocument); err != nil { + t.Fatalf("decode fixture document: %v", err) + } + result := legacyDocument["result"].(map[string]any) + delete(result["scale"].(map[string]any), "pixel_to_unit") + metadata := result["metadata"].(map[string]any) + delete(metadata, "confidence") + delete(metadata, "image_width") + delete(metadata, "image_height") + legacyDocumentJSON, err := json.Marshal(legacyDocument) + if err != nil { + t.Fatalf("encode legacy fixture document: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO projects (id, capability_hash, name, source_image_key, source_image_content_type, source_image_size, document) +VALUES ($1, $2, 'legacy', $3, 'image/png', $4, $5::jsonb)`, id, retiredHash, sourceImageKey(id), len(image), legacyDocumentJSON); err != nil { + t.Fatalf("insert legacy project: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO legacy_project_recovery_authorizations (project_id, retired_capability_hash, authorized_by, case_reference) +VALUES ($1, $2, 'operator', 'INC-53')`, id, retiredHash); err != nil { + t.Fatalf("authorize legacy project: %v", err) + } + store := newFakeObjectStore() + store.objects[sourceImageKey(id)] = fakeObject{data: image, contentType: "image/png"} + router := newLegacyRecoveryRouter(repo, store, "operator-only-key") + + recover := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/projects/"+id+"/recover", nil) + req.Header.Set("X-HomeVox-Legacy-Recovery-Key", "operator-only-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + first := recover() + if first.Code != http.StatusOK { + t.Fatalf("first recovery status=%d body=%s", first.Code, first.Body.String()) + } + var body struct { + Capability string `json:"capability"` + Document struct { + Result struct { + Scale struct { + PixelToUnit *float64 `json:"pixel_to_unit"` + } `json:"scale"` + Metadata struct { + Confidence float64 `json:"confidence"` + ImageWidth int `json:"image_width"` + ImageHeight int `json:"image_height"` + } `json:"metadata"` + } `json:"result"` + } `json:"document"` + } + if err := json.Unmarshal(first.Body.Bytes(), &body); err != nil { + t.Fatalf("decode recovery response: %v", err) + } + if !projectCapabilityRegex.MatchString(body.Capability) || body.Document.Result.Scale.PixelToUnit != nil || + body.Document.Result.Metadata.Confidence != 0 || body.Document.Result.Metadata.ImageWidth != 2 || body.Document.Result.Metadata.ImageHeight != 3 { + t.Fatalf("legacy recovery did not return normalized document: %+v", body) + } + second := recover() + if second.Code != http.StatusNotFound { + t.Fatalf("second recovery status=%d body=%s", second.Code, second.Body.String()) + } + var auditCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM legacy_project_recovery_audit WHERE project_id = $1`, id).Scan(&auditCount); err != nil { + t.Fatalf("read recovery audit: %v", err) + } + if auditCount != 1 { + t.Fatalf("recovery audit count = %d, want 1", auditCount) + } } func TestProjectCreateRejectsOversizedMultipartBodyBeforeParsing(t *testing.T) { @@ -348,19 +604,44 @@ func TestProjectCreateRejectsSourceImageMetadataMismatch(t *testing.T) { } } -func TestProjectListRejectsLimitAbove100(t *testing.T) { +func TestProjectCreateRejectsEffectiveSourceDimensionMismatch(t *testing.T) { + router := newProjectRouter(newFakeProjectRepo(), newFakeObjectStore()) + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("name", "My Plan") + document := strings.Replace(projectDocumentForSourceImage(t), `"image_width":2`, `"image_width":200`, 1) + _ = writer.WriteField("document", document) + part, err := writer.CreateFormFile("source_image", "plan.png") + if err != nil { + t.Fatal(err) + } + _, _ = part.Write(validPNG(t)) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/projects", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "source_image_metadata_mismatch") { + t.Fatalf("expected dimension mismatch; status=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestProjectListCannotEnumerateEvenWithLimit(t *testing.T) { router := newProjectRouter(newFakeProjectRepo(), newFakeObjectStore()) req := httptest.NewRequest(http.MethodGet, "/api/projects?limit=101", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), `"code":"invalid_limit"`) { - t.Fatalf("status/body = %d/%s, want invalid_limit", w.Code, w.Body.String()) + if w.Code != http.StatusUnauthorized || !strings.Contains(w.Body.String(), `"code":"project_capability_required"`) { + t.Fatalf("status/body = %d/%s, want capability rejection", w.Code, w.Body.String()) } } func TestProjectGetMissingReturnsNotFound(t *testing.T) { router := newProjectRouter(newFakeProjectRepo(), newFakeObjectStore()) req := httptest.NewRequest(http.MethodGet, "/api/projects/00000000-0000-0000-0000-000000000000", nil) + authorizeProjectRequest(req, testProjectCapability) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { @@ -370,7 +651,7 @@ func TestProjectGetMissingReturnsNotFound(t *testing.T) { func TestProjectUpdateConflict(t *testing.T) { repo := newFakeProjectRepo() - created, err := repo.Create(context.Background(), "00000000-0000-4000-8000-000000000001", "Plan", "source", "image/png", 12, []byte(validProjectDocument)) + created, err := repo.Create(context.Background(), "00000000-0000-4000-8000-000000000001", testCapabilityHash(), "Plan", "source", "image/png", 12, []byte(validProjectDocument)) if err != nil { t.Fatalf("create fake project: %v", err) } @@ -379,6 +660,7 @@ func TestProjectUpdateConflict(t *testing.T) { payload := `{"name":"Plan","document":` + validProjectDocument + `,"expectedRevision":2}` updateReq := httptest.NewRequest(http.MethodPut, "/api/projects/"+created.ID, strings.NewReader(payload)) updateReq.Header.Set("Content-Type", "application/json") + authorizeProjectRequest(updateReq, testProjectCapability) updateW := httptest.NewRecorder() router.ServeHTTP(updateW, updateReq) if updateW.Code != http.StatusConflict { @@ -395,6 +677,7 @@ func TestProjectUpdateRejectsOversizedJSONBodyBeforeBinding(t *testing.T) { req := httptest.NewRequest(http.MethodPut, "/api/projects/"+id, strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") + authorizeProjectRequest(req, testProjectCapability) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -418,6 +701,7 @@ func TestProjectUpdateReturnsNotFoundForUnknownID(t *testing.T) { payload := `{"name":"Plan","document":` + validProjectDocument + `,"expectedRevision":1}` req := httptest.NewRequest(http.MethodPut, "/api/projects/00000000-0000-0000-0000-000000000001", strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") + authorizeProjectRequest(req, testProjectCapability) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { @@ -427,14 +711,19 @@ func TestProjectUpdateReturnsNotFoundForUnknownID(t *testing.T) { func TestProjectUpdateRejectsSourceImageMetadataMutation(t *testing.T) { repo := newFakeProjectRepo() - _, err := repo.Create(context.Background(), "00000000-0000-0000-0000-000000000001", "Plan", "source", "image/png", 12, []byte(validProjectDocument)) + image := validPNG(t) + document := projectDocumentForSourceImage(t) + _, err := repo.Create(context.Background(), "00000000-0000-0000-0000-000000000001", testCapabilityHash(), "Plan", "source", "image/png", int64(len(image)), []byte(document)) if err != nil { t.Fatalf("create fixture: %v", err) } - router := newProjectRouter(repo, newFakeObjectStore()) - payload := `{"name":"Plan","document":` + strings.Replace(validProjectDocument, `"contentType":"image/png"`, `"contentType":"image/jpeg"`, 1) + `,"expectedRevision":1}` + store := newFakeObjectStore() + store.objects["source"] = fakeObject{data: image, contentType: "image/png"} + router := newProjectRouter(repo, store) + payload := `{"name":"Plan","document":` + strings.Replace(document, `"contentType":"image/png"`, `"contentType":"image/jpeg"`, 1) + `,"expectedRevision":1}` req := httptest.NewRequest(http.MethodPut, "/api/projects/00000000-0000-0000-0000-000000000001", strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") + authorizeProjectRequest(req, testProjectCapability) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -443,6 +732,51 @@ func TestProjectUpdateRejectsSourceImageMetadataMutation(t *testing.T) { } } +func TestProjectUpdateRejectsEffectiveSourceDimensionMutation(t *testing.T) { + repo := newFakeProjectRepo() + image := validPNG(t) + document := projectDocumentForSourceImage(t) + _, err := repo.Create(context.Background(), "00000000-0000-0000-0000-000000000001", testCapabilityHash(), "Plan", "source", "image/png", int64(len(image)), []byte(document)) + if err != nil { + t.Fatal(err) + } + store := newFakeObjectStore() + store.objects["source"] = fakeObject{data: image, contentType: "image/png"} + router := newProjectRouter(repo, store) + mutatedDocument := strings.Replace(document, `"image_width":2`, `"image_width":101`, 1) + payload := `{"name":"Plan","document":` + mutatedDocument + `,"expectedRevision":1}` + req := httptest.NewRequest(http.MethodPut, "/api/projects/00000000-0000-0000-0000-000000000001", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + authorizeProjectRequest(req, testProjectCapability) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "source_image_metadata_mismatch") { + t.Fatalf("expected dimension mismatch; status=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestProjectUpdateChecksImmutableSourceObjectDimensions(t *testing.T) { + repo := newFakeProjectRepo() + image := validPNG(t) + document := strings.Replace(validProjectDocument, `"size":12`, fmt.Sprintf(`"size":%d`, len(image)), 1) + _, err := repo.Create(context.Background(), "00000000-0000-0000-0000-000000000001", testCapabilityHash(), "Plan", "source", "image/png", int64(len(image)), []byte(document)) + if err != nil { + t.Fatal(err) + } + store := newFakeObjectStore() + store.objects["source"] = fakeObject{data: image, contentType: "image/png"} + router := newProjectRouter(repo, store) + payload := `{"name":"Plan","document":` + document + `,"expectedRevision":1}` + req := httptest.NewRequest(http.MethodPut, "/api/projects/00000000-0000-0000-0000-000000000001", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + authorizeProjectRequest(req, testProjectCapability) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "source_image_metadata_mismatch") { + t.Fatalf("expected immutable object dimension mismatch; status=%d body=%s", w.Code, w.Body.String()) + } +} + func TestProjectCreateCompensationDeletesUploadedObjectWhenRepoFails(t *testing.T) { repo := newFakeProjectRepo() repo.createErr = fmt.Errorf("forced failure") @@ -508,17 +842,20 @@ func TestProjectCreateFailsWhenCleanupFailsReturnsInfraError(t *testing.T) { type newFailingProjectRepo struct{} func (f newFailingProjectRepo) InitializeSchema(context.Context) error { return nil } -func (f newFailingProjectRepo) Create(context.Context, string, string, string, string, int64, json.RawMessage) (db.Project, error) { +func (f newFailingProjectRepo) Create(context.Context, string, string, string, string, string, int64, json.RawMessage) (db.Project, error) { return db.Project{}, fmt.Errorf("forced failure") } -func (f newFailingProjectRepo) Get(context.Context, string) (db.Project, error) { +func (f newFailingProjectRepo) Get(context.Context, string, string) (db.Project, error) { return db.Project{}, db.ErrProjectNotFound } -func (f newFailingProjectRepo) Update(context.Context, string, int, string, json.RawMessage) (db.Project, error) { +func (f newFailingProjectRepo) Update(context.Context, string, string, int, string, json.RawMessage) (db.Project, error) { return db.Project{}, fmt.Errorf("forced failure") } -func (f newFailingProjectRepo) List(context.Context, int) ([]db.ProjectSummary, error) { - return nil, fmt.Errorf("forced failure") +func (f newFailingProjectRepo) LegacyRecoveryCandidate(context.Context, string) (db.Project, error) { + return db.Project{}, db.ErrProjectNotFound +} +func (f newFailingProjectRepo) RecoverLegacy(context.Context, string, string, string, json.RawMessage) (db.Project, error) { + return db.Project{}, db.ErrProjectNotFound } func (f newFailingProjectRepo) Close() {} @@ -568,7 +905,7 @@ func TestProjectDependenciesReadinessMatrix(t *testing.T) { } func TestProjectDocumentValidationBounds(t *testing.T) { - _, err := project.NormalizeDocument([]byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[{"name":"a","type":"room","approximate_bounds":{"x1":0,"y1":0,"x2":1,"y2":2}}],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture"}}}`)) + _, err := project.NormalizeDocument([]byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[{"name":"a","type":"room","approximate_bounds":{"x1":0,"y1":0,"x2":1,"y2":2}}],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`)) if err != nil { t.Fatalf("unexpected document validation error: %v", err) } diff --git a/backend/internal/api/projects.go b/backend/internal/api/projects.go index 1e9c352..bfb41c2 100644 --- a/backend/internal/api/projects.go +++ b/backend/internal/api/projects.go @@ -4,6 +4,9 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -33,10 +36,11 @@ type projectErrorEnvelope struct { } type projectDependencies struct { - databaseStatus persistenceStatus - s3Status persistenceStatus - repo db.ProjectRepository - store storage.ObjectStore + databaseStatus persistenceStatus + s3Status persistenceStatus + repo db.ProjectRepository + store storage.ObjectStore + legacyRecoveryKey string } func (deps projectDependencies) Close() { @@ -46,22 +50,24 @@ func (deps projectDependencies) Close() { } type databaseConfig struct { - DatabaseURL string - S3Endpoint string - S3Bucket string - S3AccessKey string - S3SecretKey string + DatabaseURL string + S3Endpoint string + S3Bucket string + S3AccessKey string + S3SecretKey string + LegacyRecoveryKey string } const ( - statusNotConfigured persistenceStatus = "not_configured" - statusIncomplete persistenceStatus = "incomplete_config" - statusUnavailable persistenceStatus = "unavailable" - statusReady persistenceStatus = "ready" - projectListLimitMax = 100 + statusNotConfigured persistenceStatus = "not_configured" + statusIncomplete persistenceStatus = "incomplete_config" + statusUnavailable persistenceStatus = "unavailable" + statusReady persistenceStatus = "ready" + projectCapabilityHeader = "X-HomeVox-Project-Capability" ) var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) +var projectCapabilityRegex = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) type projectRepositoryFactory func(context.Context, string) (db.ProjectRepository, error) type objectStoreFactory func(storage.Config) (storage.ObjectStore, error) @@ -79,7 +85,7 @@ func newProjectDependencies(ctx context.Context, cfg databaseConfig) projectDepe // independently. A verified database remains "ready" even when S3 is incomplete // or unavailable (and vice versa); routes still require both dependencies. func newProjectDependenciesWithFactories(ctx context.Context, cfg databaseConfig, newRepo projectRepositoryFactory, newStore objectStoreFactory) projectDependencies { - deps := projectDependencies{} + deps := projectDependencies{legacyRecoveryKey: cfg.LegacyRecoveryKey} if cfg.DatabaseURL == "" { deps.databaseStatus = statusNotConfigured @@ -123,8 +129,79 @@ func (deps projectDependencies) ready() bool { return deps.repo != nil && deps.store != nil && deps.databaseStatus == statusReady && deps.s3Status == statusReady } +func (deps projectDependencies) databaseReady() bool { + return deps.repo != nil && deps.databaseStatus == statusReady +} + func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { group := router.Group("/api/projects") + group.Use(func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.Header("Pragma", "no-cache") + c.Next() + }) + // Legacy rows predate bearer capabilities. Recovery is deliberately an + // operator-gated, one-time, audited action; no anonymous project-ID claim. + group.POST(":id/recover", func(c *gin.Context) { + if !deps.databaseReady() { + writeProjectError(c, http.StatusServiceUnavailable, "persistence_unavailable", "project persistence unavailable") + return + } + if deps.legacyRecoveryKey == "" || subtle.ConstantTimeCompare([]byte(c.GetHeader("X-HomeVox-Legacy-Recovery-Key")), []byte(deps.legacyRecoveryKey)) != 1 { + writeProjectError(c, http.StatusForbidden, "legacy_recovery_forbidden", "legacy recovery requires an authorized recovery key") + return + } + id := c.Param("id") + if !uuidRegex.MatchString(id) { + writeProjectError(c, http.StatusBadRequest, "invalid_project_id", "id must be UUID") + return + } + candidate, err := deps.repo.LegacyRecoveryCandidate(c.Request.Context(), id) + if err != nil { + if err == db.ErrProjectNotFound { + writeProjectError(c, http.StatusNotFound, "legacy_project_not_found", "legacy project not found or already recovered") + return + } + writeProjectError(c, http.StatusServiceUnavailable, "database_unavailable", "failed to inspect legacy project") + return + } + sourceObject, err := deps.store.GetObject(c.Request.Context(), candidate.SourceImageKey) + if err != nil { + writeProjectError(c, http.StatusServiceUnavailable, "storage_unavailable", "failed to verify source image") + return + } + contentType, width, height, err := imagevalidate.Decode(sourceObject.Data) + if err != nil || contentType != candidate.SourceImageContentType || int64(len(sourceObject.Data)) != candidate.SourceImageSize { + writeProjectError(c, http.StatusInternalServerError, "corrupt_source_image", "stored source image is inconsistent") + return + } + normalized, err := project.NormalizeLegacyDocument(candidate.Document, width, height) + if err != nil { + writeProjectError(c, http.StatusInternalServerError, "corrupt_project_document", "failed to decode project document") + return + } + normalizedDocument, err := json.Marshal(normalized) + if err != nil { + writeProjectError(c, http.StatusInternalServerError, "corrupt_project_document", "failed to encode project document") + return + } + capability, err := newProjectCapability() + if err != nil { + writeProjectError(c, http.StatusServiceUnavailable, "persistence_unavailable", "failed to allocate project capability") + return + } + recovered, err := deps.repo.RecoverLegacy(c.Request.Context(), id, hashProjectCapability(capability), c.ClientIP(), normalizedDocument) + if err != nil { + if err == db.ErrProjectNotFound { + writeProjectError(c, http.StatusNotFound, "legacy_project_not_found", "legacy project not found or already recovered") + return + } + writeProjectError(c, http.StatusServiceUnavailable, "database_unavailable", "failed to recover legacy project") + return + } + writeFullProject(c, http.StatusOK, recovered, capability) + }) + group.POST("", func(c *gin.Context) { if !deps.ready() { writeProjectError(c, http.StatusServiceUnavailable, "persistence_unavailable", "project persistence unavailable") @@ -177,12 +254,12 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { return } - contentType, _, _, err := imagevalidate.Decode(data) + contentType, width, height, err := imagevalidate.Decode(data) if err != nil || !project.IsSupportedContentType(contentType) { writeProjectError(c, http.StatusBadRequest, "unsupported_source_image", "source_image must be a valid PNG, JPEG, GIF, or WebP image") return } - if err := project.ValidateSourceImageMetadata(doc, header.Filename, contentType, int64(len(data))); err != nil { + if err := project.ValidateSourceImageMetadata(doc, header.Filename, contentType, int64(len(data)), width, height); err != nil { writeProjectError(c, http.StatusBadRequest, "source_image_metadata_mismatch", err.Error()) return } @@ -192,6 +269,11 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { writeProjectError(c, http.StatusServiceUnavailable, "persistence_unavailable", "failed to allocate project ID") return } + capability, err := newProjectCapability() + if err != nil { + writeProjectError(c, http.StatusServiceUnavailable, "persistence_unavailable", "failed to allocate project capability") + return + } key := sourceImageKey(projectID) err = deps.store.PutObject(c.Request.Context(), key, contentType, data) if err != nil { @@ -205,7 +287,7 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { writeProjectError(c, http.StatusBadRequest, "invalid_document", "invalid document") return } - created, err := deps.repo.Create(c.Request.Context(), projectID, name, key, contentType, int64(len(data)), rawDoc) + created, err := deps.repo.Create(c.Request.Context(), projectID, hashProjectCapability(capability), name, key, contentType, int64(len(data)), rawDoc) if err != nil { if deleteErr := deps.store.DeleteObject(c.Request.Context(), key); deleteErr != nil { log.Printf("project create rollback failed for object key=%s: %v", key, deleteErr) @@ -216,7 +298,7 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { return } - writeFullProject(c, http.StatusCreated, created) + writeFullProject(c, http.StatusCreated, created, capability) }) group.PUT(":id", func(c *gin.Context) { @@ -224,6 +306,10 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { writeProjectError(c, http.StatusServiceUnavailable, "persistence_unavailable", "project persistence unavailable") return } + capabilityHash, ok := requireProjectCapability(c) + if !ok { + return + } id := c.Param("id") if !uuidRegex.MatchString(id) { writeProjectError(c, http.StatusBadRequest, "invalid_project_id", "id must be UUID") @@ -261,21 +347,35 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { return } - current, err := deps.repo.Get(c.Request.Context(), id) + current, err := deps.repo.Get(c.Request.Context(), id, capabilityHash) if err != nil { if err == db.ErrProjectNotFound { - writeProjectError(c, http.StatusNotFound, "project_not_found", "project not found") + writeProjectError(c, http.StatusNotFound, "project_not_found", "project not found or capability is invalid") return } writeProjectError(c, http.StatusServiceUnavailable, "database_unavailable", "failed to get project") return } + if current.Revision != payload.ExpectedRevision { + writeProjectError(c, http.StatusConflict, "revision_conflict", "project has changed; load the latest version before saving") + return + } currentDocument, err := project.NormalizeDocument(current.Document) if err != nil { writeProjectError(c, http.StatusInternalServerError, "corrupt_project_document", "failed to decode project document") return } - if err := project.ValidateSourceImageMetadata(doc, currentDocument.Filename, current.SourceImageContentType, current.SourceImageSize); err != nil { + sourceObject, err := deps.store.GetObject(c.Request.Context(), current.SourceImageKey) + if err != nil { + writeProjectError(c, http.StatusServiceUnavailable, "storage_unavailable", "failed to verify source image") + return + } + contentType, width, height, err := imagevalidate.Decode(sourceObject.Data) + if err != nil || contentType != current.SourceImageContentType || int64(len(sourceObject.Data)) != current.SourceImageSize { + writeProjectError(c, http.StatusInternalServerError, "corrupt_source_image", "stored source image is inconsistent") + return + } + if err := project.ValidateSourceImageMetadata(doc, currentDocument.Filename, contentType, int64(len(sourceObject.Data)), width, height); err != nil { writeProjectError(c, http.StatusBadRequest, "source_image_metadata_mismatch", err.Error()) return } @@ -286,21 +386,21 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { return } - updated, err := deps.repo.Update(c.Request.Context(), id, payload.ExpectedRevision, name, rawDoc) + updated, err := deps.repo.Update(c.Request.Context(), id, capabilityHash, payload.ExpectedRevision, name, rawDoc) if err != nil { if err == db.ErrProjectNotFound { - writeProjectError(c, http.StatusNotFound, "project_not_found", "project not found") + writeProjectError(c, http.StatusNotFound, "project_not_found", "project not found or capability is invalid") return } if _, ok := err.(*db.RevisionConflictError); ok { - writeProjectError(c, http.StatusConflict, "revision_conflict", err.Error()) + writeProjectError(c, http.StatusConflict, "revision_conflict", "project has changed; load the latest version before saving") return } writeProjectError(c, http.StatusServiceUnavailable, "database_unavailable", "failed to update project") return } - writeFullProject(c, http.StatusOK, updated) + writeFullProject(c, http.StatusOK, updated, "") }) group.GET("", func(c *gin.Context) { @@ -308,35 +408,7 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { writeProjectError(c, http.StatusServiceUnavailable, "persistence_unavailable", "project persistence unavailable") return } - - limit := projectListLimitMax - if q := c.Query("limit"); q != "" { - parsed, err := strconv.Atoi(q) - if err != nil || parsed <= 0 || parsed > projectListLimitMax { - writeProjectError(c, http.StatusBadRequest, "invalid_limit", "limit must be an integer between 1 and 100") - return - } - limit = parsed - } - - summaries, err := deps.repo.List(c.Request.Context(), limit) - if err != nil { - writeProjectError(c, http.StatusServiceUnavailable, "database_unavailable", "failed to list projects") - return - } - - items := make([]gin.H, 0, len(summaries)) - for _, summary := range summaries { - items = append(items, gin.H{ - "id": summary.ID, - "name": summary.Name, - "revision": summary.Revision, - "createdAt": summary.CreatedAt.UTC().Format(time.RFC3339), - "updatedAt": summary.UpdatedAt.UTC().Format(time.RFC3339), - "sourceImageURL": fmt.Sprintf("/api/projects/%s/source-image", summary.ID), - }) - } - c.JSON(http.StatusOK, items) + writeProjectError(c, http.StatusUnauthorized, "project_capability_required", "project listing requires an authenticated owner") }) group.GET(":id", func(c *gin.Context) { @@ -344,22 +416,26 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { writeProjectError(c, http.StatusServiceUnavailable, "persistence_unavailable", "project persistence unavailable") return } + capabilityHash, ok := requireProjectCapability(c) + if !ok { + return + } id := c.Param("id") if !uuidRegex.MatchString(id) { writeProjectError(c, http.StatusBadRequest, "invalid_project_id", "id must be UUID") return } - projectModel, err := deps.repo.Get(c.Request.Context(), id) + projectModel, err := deps.repo.Get(c.Request.Context(), id, capabilityHash) if err != nil { if err == db.ErrProjectNotFound { - writeProjectError(c, http.StatusNotFound, "project_not_found", "project not found") + writeProjectError(c, http.StatusNotFound, "project_not_found", "project not found or capability is invalid") return } writeProjectError(c, http.StatusServiceUnavailable, "database_unavailable", "failed to get project") return } - writeFullProject(c, http.StatusOK, projectModel) + writeFullProject(c, http.StatusOK, projectModel, "") }) group.GET(":id/source-image", func(c *gin.Context) { @@ -367,16 +443,20 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { writeProjectError(c, http.StatusServiceUnavailable, "persistence_unavailable", "project persistence unavailable") return } + capabilityHash, ok := requireProjectCapability(c) + if !ok { + return + } id := c.Param("id") if !uuidRegex.MatchString(id) { writeProjectError(c, http.StatusBadRequest, "invalid_project_id", "id must be UUID") return } - projectModel, err := deps.repo.Get(c.Request.Context(), id) + projectModel, err := deps.repo.Get(c.Request.Context(), id, capabilityHash) if err != nil { if err == db.ErrProjectNotFound { - writeProjectError(c, http.StatusNotFound, "project_not_found", "project not found") + writeProjectError(c, http.StatusNotFound, "project_not_found", "project not found or capability is invalid") return } writeProjectError(c, http.StatusServiceUnavailable, "database_unavailable", "failed to get project") @@ -409,14 +489,14 @@ func writeProjectError(c *gin.Context, status int, code, message string) { c.JSON(status, body) } -func writeFullProject(c *gin.Context, status int, projectModel db.Project) { +func writeFullProject(c *gin.Context, status int, projectModel db.Project, capability string) { var parsed floorplan.ParseResponse if err := json.Unmarshal(projectModel.Document, &parsed); err != nil { writeProjectError(c, http.StatusInternalServerError, "corrupt_project_document", "failed to decode project document") return } - c.JSON(status, gin.H{ + body := gin.H{ "id": projectModel.ID, "name": projectModel.Name, "revision": projectModel.Revision, @@ -426,7 +506,33 @@ func writeFullProject(c *gin.Context, status int, projectModel db.Project) { "createdAt": projectModel.CreatedAt.UTC().Format(time.RFC3339), "updatedAt": projectModel.UpdatedAt.UTC().Format(time.RFC3339), "sourceImageURL": fmt.Sprintf("/api/projects/%s/source-image", projectModel.ID), - }) + } + if capability != "" { + body["capability"] = capability + } + c.JSON(status, body) +} + +func requireProjectCapability(c *gin.Context) (string, bool) { + capability := c.GetHeader(projectCapabilityHeader) + if !projectCapabilityRegex.MatchString(capability) { + writeProjectError(c, http.StatusUnauthorized, "project_capability_required", "a valid project capability is required") + return "", false + } + return hashProjectCapability(capability), true +} + +func newProjectCapability() (string, error) { + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw[:]), nil +} + +func hashProjectCapability(capability string) string { + sum := sha256.Sum256([]byte(capability)) + return hex.EncodeToString(sum[:]) } func newProjectID() (string, error) { diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index dd1efbf..e771eae 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -4,9 +4,11 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -21,6 +23,7 @@ import ( ) const maxFloorplanUploadBytes = 10 << 20 +const maxMultipartOverheadBytes = 64 << 10 const persistenceStartupTimeout = 5 * time.Second type healthResponse struct { @@ -34,6 +37,11 @@ func NewRouter(cfg config.Config, frontendDirs ...string) *gin.Engine { return router } +func requestBodyTooLarge(err error) bool { + var maxBytesError *http.MaxBytesError + return errors.As(err, &maxBytesError) +} + // NewRouterWithCleanup exposes the persistence-resource cleanup required by // the long-running server while keeping the lightweight test constructor. func NewRouterWithCleanup(cfg config.Config, frontendDirs ...string) (*gin.Engine, func()) { @@ -49,11 +57,12 @@ func newRouterWithCleanup(cfg config.Config, startupTimeout time.Duration, initi parser := floorplan.NewParser(ai.NewClientFromConfig(cfg)) startupContext, cancelStartup := context.WithTimeout(context.Background(), startupTimeout) deps := initializeDependencies(startupContext, databaseConfig{ - DatabaseURL: cfg.DatabaseURL, - S3Endpoint: cfg.S3Endpoint, - S3Bucket: cfg.S3Bucket, - S3AccessKey: cfg.S3AccessKey, - S3SecretKey: cfg.S3SecretKey, + DatabaseURL: cfg.DatabaseURL, + S3Endpoint: cfg.S3Endpoint, + S3Bucket: cfg.S3Bucket, + S3AccessKey: cfg.S3AccessKey, + S3SecretKey: cfg.S3SecretKey, + LegacyRecoveryKey: cfg.LegacyRecoveryKey, }) cancelStartup() databaseStatus, s3Status, databaseConfigured, s3Configured := projectStatusesFromDependencies(deps) @@ -80,8 +89,13 @@ func newRouterWithCleanup(cfg config.Config, startupTimeout time.Duration, initi registerProjectRoutes(router, deps) router.POST("/api/floorplans/parse", func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxFloorplanUploadBytes+maxMultipartOverheadBytes) file, header, err := c.Request.FormFile("floorplan") if err != nil { + if requestBodyTooLarge(err) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "floorplan image must be 10 MiB or smaller"}) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": "field floorplan is required"}) return } @@ -102,23 +116,41 @@ func newRouterWithCleanup(cfg config.Config, startupTimeout time.Duration, initi return } - contentType, _, _, err := imagevalidate.Decode(data) + contentType, width, height, err := imagevalidate.Decode(data) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "floorplan must be a valid PNG, JPEG, GIF, or WebP image"}) return } imageDataURL := fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(data)) - result, err := parser.Parse(c.Request.Context(), imageDataURL) + result, err := parser.ParseAtDimensions(c.Request.Context(), imageDataURL, width, height) if err != nil { - status := http.StatusBadGateway if cfg.AIAPIKey == "" || cfg.AIBaseURL == "" || cfg.AIModel == "" { - status = http.StatusServiceUnavailable + c.JSON(http.StatusServiceUnavailable, gin.H{ + "code": "ai_transport_unavailable", + "error": "识别服务暂时不可用,请稍后重试。", + }) + return + } + switch floorplan.ErrorCode(err) { + case floorplan.ParseErrorSchema: + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "code": "ai_schema_invalid", + "error": "识别结果格式不完整,未生成可编辑户型。请裁切后上传更清晰的纯户型图。", + }) + case floorplan.ParseErrorContent: + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "code": "ai_content_unreliable", + "error": "这张图片中的户型边界无法可靠识别。请裁切到单个户型或上传更清晰的平面图。", + }) + default: + c.JSON(http.StatusBadGateway, gin.H{ + "code": "ai_transport_unavailable", + "error": "识别服务暂时不可用,请稍后重试。", + }) } - c.JSON(status, gin.H{"error": err.Error()}) return } - document := floorplan.ParseResponse{ Filename: header.Filename, ContentType: contentType, @@ -127,12 +159,67 @@ func newRouterWithCleanup(cfg config.Config, startupTimeout time.Duration, initi } canonical, err := project.NormalizeDocument(mustJSON(document)) if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "ai returned an invalid floorplan document"}) + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "code": "ai_schema_invalid", + "error": "识别结果格式不完整,未生成可编辑户型。请裁切后上传更清晰的纯户型图。", + }) return } c.JSON(http.StatusOK, canonical) }) + router.POST("/api/floorplans/candidates", func(c *gin.Context) { + // Bound the request before multipart parsing so oversized bodies are never + // parsed/spooled by FormFile. The allowance covers multipart framing. + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxFloorplanUploadBytes+maxMultipartOverheadBytes) + file, header, err := c.Request.FormFile("floorplan") + if err != nil { + if requestBodyTooLarge(err) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "floorplan image must be 10 MiB or smaller"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "field floorplan is required"}) + return + } + defer file.Close() + if header.Size > maxFloorplanUploadBytes { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "floorplan image must be 10 MiB or smaller"}) + return + } + data, err := io.ReadAll(io.LimitReader(file, maxFloorplanUploadBytes+1)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read floorplan image"}) + return + } + if len(data) > maxFloorplanUploadBytes { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "floorplan image must be 10 MiB or smaller"}) + return + } + contentType, width, height, err := imagevalidate.Decode(data) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "floorplan must be a valid PNG, JPEG, GIF, or WebP image"}) + return + } + if cfg.AIAPIKey == "" || cfg.AIBaseURL == "" || cfg.AIModel == "" { + c.JSON(http.StatusServiceUnavailable, gin.H{"code": "ai_transport_unavailable", "error": "识别服务暂时不可用,请稍后重试。"}) + return + } + imageDataURL := fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(data)) + result, err := parser.AnalyzeCandidates(c.Request.Context(), imageDataURL, width, height) + if err != nil { + switch floorplan.ErrorCode(err) { + case floorplan.ParseErrorSchema: + c.JSON(http.StatusUnprocessableEntity, gin.H{"code": "ai_schema_invalid", "error": "识别结果格式不完整,未生成可靠裁切区域。请上传更清晰的图片。"}) + case floorplan.ParseErrorContent: + c.JSON(http.StatusUnprocessableEntity, gin.H{"code": "ai_content_unreliable", "error": "这张图片无法可靠判断户型区域。请手动裁切或上传更清晰的图片。"}) + default: + c.JSON(http.StatusBadGateway, gin.H{"code": "ai_transport_unavailable", "error": "识别服务暂时不可用,请稍后重试。"}) + } + return + } + c.JSON(http.StatusOK, result) + }) + if len(frontendDirs) > 0 && frontendDirs[0] != "" { registerFrontend(router, frontendDirs[0]) } @@ -185,9 +272,19 @@ func registerFrontend(router *gin.Engine, frontendDir string) { func corsMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") + origin := c.GetHeader("Origin") + if origin != "" { + parsed, err := url.Parse(origin) + host, scheme, forwardedOK := publicRequestOrigin(c.Request) + if err != nil || !forwardedOK || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host != host || (scheme != "" && parsed.Scheme != scheme) || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + c.AbortWithStatus(http.StatusForbidden) + return + } + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + } c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, "+projectCapabilityHeader) if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return @@ -195,3 +292,24 @@ func corsMiddleware() gin.HandlerFunc { c.Next() } } + +func publicRequestOrigin(request *http.Request) (host, scheme string, ok bool) { + forwardedHost := lastForwardedValue(request.Header.Get("X-Forwarded-Host")) + forwardedProto := strings.ToLower(lastForwardedValue(request.Header.Get("X-Forwarded-Proto"))) + if forwardedHost == "" && forwardedProto == "" { + return request.Host, "", request.Host != "" + } + if forwardedHost == "" || (forwardedProto != "http" && forwardedProto != "https") { + return "", "", false + } + parsed, err := url.Parse("//" + forwardedHost) + if err != nil || parsed.Host != forwardedHost || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", "", false + } + return forwardedHost, forwardedProto, true +} + +func lastForwardedValue(value string) string { + parts := strings.Split(value, ",") + return strings.TrimSpace(parts[len(parts)-1]) +} diff --git a/backend/internal/api/router_test.go b/backend/internal/api/router_test.go index 7e7c391..4add823 100644 --- a/backend/internal/api/router_test.go +++ b/backend/internal/api/router_test.go @@ -4,6 +4,9 @@ import ( "bytes" "context" "encoding/json" + "fmt" + "image" + "image/png" "mime/multipart" "net/http" "net/http/httptest" @@ -16,26 +19,69 @@ import ( "github.com/KingBoyAndGirl/HomeVox/backend/internal/config" ) -func TestRouterAppliesCorsHeaders(t *testing.T) { +func TestRouterAllowsOnlySameHostCorsPreflight(t *testing.T) { router := NewRouter(config.Config{}) req := httptest.NewRequest(http.MethodOptions, "/api/config", nil) - req.Header.Set("Origin", "http://localhost:5173") + req.Header.Set("Origin", "http://example.com") req.Header.Set("Access-Control-Request-Method", http.MethodGet) + req.Header.Set("Access-Control-Request-Headers", projectCapabilityHeader) w := httptest.NewRecorder() router.ServeHTTP(w, req) - if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" { - t.Fatalf("Access-Control-Allow-Origin = %q, want *", got) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://example.com" { + t.Fatalf("Access-Control-Allow-Origin = %q, want exact same-host origin", got) } if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" { t.Fatal("Access-Control-Allow-Methods header missing") } + if got := w.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, projectCapabilityHeader) { + t.Fatalf("Access-Control-Allow-Headers = %q, want project capability header", got) + } if w.Code != http.StatusNoContent { t.Fatalf("OPTIONS status = %d, want %d", w.Code, http.StatusNoContent) } } +func TestRouterRejectsCrossHostCorsPreflight(t *testing.T) { + router := NewRouter(config.Config{}) + req := httptest.NewRequest(http.MethodOptions, "/api/projects/00000000-0000-0000-0000-000000000001", nil) + req.Header.Set("Origin", "https://attacker.example") + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + req.Header.Set("Access-Control-Request-Headers", projectCapabilityHeader) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("OPTIONS status = %d, want %d", w.Code, http.StatusForbidden) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("cross-host response exposed Access-Control-Allow-Origin %q", got) + } +} + +func TestRouterAllowsSamePublicOriginBehindIngress(t *testing.T) { + router := NewRouter(config.Config{}) + req := httptest.NewRequest(http.MethodOptions, "/api/projects/00000000-0000-0000-0000-000000000001", nil) + req.Host = "homevox:18088" + req.Header.Set("Origin", "https://homevox.example") + req.Header.Set("X-Forwarded-Host", "homevox.example") + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + req.Header.Set("Access-Control-Request-Headers", projectCapabilityHeader) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("proxied same-origin OPTIONS status = %d, want %d", w.Code, http.StatusNoContent) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://homevox.example" { + t.Fatalf("Access-Control-Allow-Origin = %q, want public origin", got) + } +} + func TestConfigReportsUnavailableDatabaseWhenConfiguredConnectionFails(t *testing.T) { router := NewRouter(config.Config{DatabaseURL: "postgres://example"}) req := httptest.NewRequest(http.MethodGet, "/api/config", nil) @@ -117,6 +163,25 @@ func TestConfiguredUnresponsivePersistenceOnlyDisablesProjectAPIs(t *testing.T) } } +func TestRouterPassesLegacyRecoveryKeyToProjectDependencies(t *testing.T) { + var received databaseConfig + router, cleanup := newRouterWithCleanup( + config.Config{LegacyRecoveryKey: "operator-only-key"}, + time.Second, + func(_ context.Context, cfg databaseConfig) projectDependencies { + received = cfg + return projectDependencies{databaseStatus: statusNotConfigured, s3Status: statusNotConfigured} + }, + ) + defer cleanup() + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/health", nil)) + if received.LegacyRecoveryKey != "operator-only-key" { + t.Fatalf("LegacyRecoveryKey = %q, want configured operator key", received.LegacyRecoveryKey) + } +} + func TestParseFloorplanRequiresImageFile(t *testing.T) { router := NewRouter(config.Config{}) body := &bytes.Buffer{} @@ -165,6 +230,123 @@ func TestParseFloorplanReportsMissingAIConfig(t *testing.T) { } } +func TestParseFloorplanBindsProviderCoordinatesToDecodedImageDimensions(t *testing.T) { + content := `{"rooms":[],"walls":[{"id":"wall-1","x1":0,"y1":0,"x2":1,"y2":0}],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"vision","confidence":0.8,"image_width":20,"image_height":30}}` + vision := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Messages []struct { + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil || len(request.Messages) != 2 { + t.Fatalf("decode request: %v", err) + } + var user []struct { + Text string `json:"text"` + } + if err := json.Unmarshal(request.Messages[1].Content, &user); err != nil || len(user) != 2 || + !strings.Contains(user[0].Text, "exactly 2 pixels wide and 3 pixels high") { + t.Fatalf("dimension-bound prompt = %s", request.Messages[1].Content) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"choices":[{"message":{"content":%q}}]}`, content) + })) + defer vision.Close() + router := NewRouter(config.Config{AIBaseURL: vision.URL, AIAPIKey: "controlled-test-key", AIModel: "test-model"}) + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("floorplan", "effective-crop.png") + if err != nil { + t.Fatal(err) + } + _, _ = part.Write(validPNG(t)) // 2 × 3 pixels + if err := writer.Close(); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/floorplans/parse", body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + var parsed struct { + Result struct { + Metadata struct { + ImageWidth int `json:"image_width"` + ImageHeight int `json:"image_height"` + } `json:"metadata"` + } `json:"result"` + } + if err := json.Unmarshal(response.Body.Bytes(), &parsed); err != nil { + t.Fatalf("decode response: %v", err) + } + if parsed.Result.Metadata.ImageWidth != 2 || parsed.Result.Metadata.ImageHeight != 3 { + t.Fatalf("metadata dimensions = %dx%d, want decoded 2x3", parsed.Result.Metadata.ImageWidth, parsed.Result.Metadata.ImageHeight) + } +} + +func TestParseFloorplanClassifiesInvalidAIOutputWithoutLeakingDiagnostics(t *testing.T) { + tests := map[string]struct { + content string + wantCode string + wantText string + }{ + "schema": { + content: `{"rooms":[],"walls":[{"id":"wall-1","x1":0,"y1":0,"x2":100,"y2":0}],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":"unknown"},"metadata":{"source":"vision","confidence":0.8,"image_width":100,"image_height":80}}`, + wantCode: "ai_schema_invalid", + wantText: "格式不完整", + }, + "unreliable topology": { + content: `{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"vision","confidence":0.2,"image_width":100,"image_height":80}}`, + wantCode: "ai_content_unreliable", + wantText: "裁切", + }, + } + for name, testCase := range tests { + t.Run(name, func(t *testing.T) { + vision := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"choices":[{"message":{"content":%q}}]}`, testCase.content) + })) + defer vision.Close() + router := NewRouter(config.Config{AIBaseURL: vision.URL, AIAPIKey: "test-key", AIModel: "test-model"}) + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("floorplan", "plan.png") + if err != nil { + t.Fatalf("create form file: %v", err) + } + _, _ = part.Write(validPNG(t)) + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + request := httptest.NewRequest(http.MethodPost, "/api/floorplans/parse", body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, body=%s", response.Code, response.Body.String()) + } + var payload struct { + Code string `json:"code"` + Error string `json:"error"` + } + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Code != testCase.wantCode || !strings.Contains(payload.Error, testCase.wantText) { + t.Fatalf("payload = %#v", payload) + } + if strings.Contains(payload.Error, "pixel_to_unit") || strings.Contains(payload.Error, "decode ai") { + t.Fatalf("internal parser diagnostics leaked: %q", payload.Error) + } + }) + } +} + type routerTestCase struct { name string method string @@ -262,3 +444,64 @@ func TestParseFloorplanRejectsCorruptImagesBeforeVision(t *testing.T) { t.Fatalf("corrupt images reached Vision %d times", visionCalls) } } + +func TestAnalyzeFloorplanCandidatesReturnsSourcePixelRectsAndFailsClosed(t *testing.T) { + vision := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"mode\":\"single\",\"candidates\":[{\"x\":1,\"y\":2,\"width\":10,\"height\":10}]}"}}]}`)) + })) + defer vision.Close() + router := NewRouter(config.Config{AIBaseURL: vision.URL, AIAPIKey: "test-key", AIModel: "test-model"}) + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("floorplan", "plan.png") + if err != nil { + t.Fatal(err) + } + imageData := &bytes.Buffer{} + if err := png.Encode(imageData, image.NewRGBA(image.Rect(0, 0, 20, 20))); err != nil { + t.Fatal(err) + } + _, _ = part.Write(imageData.Bytes()) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/floorplans/candidates", body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), `"mode":"single"`) || !strings.Contains(response.Body.String(), `"width":10`) { + t.Fatalf("body=%s", response.Body.String()) + } +} + +func TestAnalyzeFloorplanCandidatesRejectsOversizedMultipartBeforeImageHandling(t *testing.T) { + router := NewRouter(config.Config{AIBaseURL: "https://example.test/v1", AIAPIKey: "test-key", AIModel: "test-model"}) + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("floorplan", "oversized.png") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(bytes.Repeat([]byte("x"), maxFloorplanUploadBytes+maxMultipartOverheadBytes+1)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + request := httptest.NewRequest(http.MethodPost, "/api/floorplans/candidates", body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), "10 MiB") { + t.Fatalf("expected actionable size error, body=%s", response.Body.String()) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e996493..4ef18f7 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -7,31 +7,33 @@ import ( ) type Config struct { - ListenAddr string - FrontendDir string - DatabaseURL string - S3Endpoint string - S3Bucket string - S3AccessKey string - S3SecretKey string - AIBaseURL string - AIAPIKey string - AIModel string + ListenAddr string + FrontendDir string + DatabaseURL string + S3Endpoint string + S3Bucket string + S3AccessKey string + S3SecretKey string + AIBaseURL string + AIAPIKey string + AIModel string + LegacyRecoveryKey string } func Load() Config { _ = godotenv.Load() return Config{ - ListenAddr: fixedListenAddr(), - FrontendDir: os.Getenv("HOMEVOX_FRONTEND_DIR"), - DatabaseURL: os.Getenv("DATABASE_URL"), - S3Endpoint: os.Getenv("S3_ENDPOINT"), - S3Bucket: os.Getenv("S3_BUCKET"), - S3AccessKey: os.Getenv("S3_ACCESS_KEY_ID"), - S3SecretKey: os.Getenv("S3_SECRET_ACCESS_KEY"), - AIBaseURL: getEnv("AI_BASE_URL", "https://api.openai.com/v1"), - AIAPIKey: os.Getenv("AI_API_KEY"), - AIModel: getEnv("AI_MODEL", "gpt-4o-mini"), + ListenAddr: fixedListenAddr(), + FrontendDir: os.Getenv("HOMEVOX_FRONTEND_DIR"), + DatabaseURL: os.Getenv("DATABASE_URL"), + S3Endpoint: os.Getenv("S3_ENDPOINT"), + S3Bucket: os.Getenv("S3_BUCKET"), + S3AccessKey: os.Getenv("S3_ACCESS_KEY_ID"), + S3SecretKey: os.Getenv("S3_SECRET_ACCESS_KEY"), + AIBaseURL: getEnv("AI_BASE_URL", "https://api.openai.com/v1"), + AIAPIKey: os.Getenv("AI_API_KEY"), + AIModel: getEnv("AI_MODEL", "gpt-4o-mini"), + LegacyRecoveryKey: os.Getenv("HOMEVOX_LEGACY_RECOVERY_KEY"), } } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index b28800d..aca9da4 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -32,6 +32,16 @@ func TestLoadReadsAIAPIKey(t *testing.T) { } } +func TestLoadReadsLegacyRecoveryKey(t *testing.T) { + t.Setenv("HOMEVOX_LEGACY_RECOVERY_KEY", "operator-only-recovery-key") + + cfg := Load() + + if cfg.LegacyRecoveryKey != "operator-only-recovery-key" { + t.Fatal("LegacyRecoveryKey was not loaded from HOMEVOX_LEGACY_RECOVERY_KEY") + } +} + func TestLoadRequiresExplicitFrontendDirectory(t *testing.T) { t.Setenv("HOMEVOX_FRONTEND_DIR", "") diff --git a/backend/internal/db/postgres.go b/backend/internal/db/postgres.go index 8cca1dd..ff0943b 100644 --- a/backend/internal/db/postgres.go +++ b/backend/internal/db/postgres.go @@ -37,20 +37,13 @@ type Project struct { UpdatedAt time.Time } -type ProjectSummary struct { - ID string - Name string - Revision int - UpdatedAt time.Time - CreatedAt time.Time -} - type ProjectRepository interface { InitializeSchema(ctx context.Context) error - Create(ctx context.Context, id, name, sourceImageKey, sourceImageContentType string, sourceImageSize int64, document json.RawMessage) (Project, error) - Get(ctx context.Context, id string) (Project, error) - Update(ctx context.Context, id string, expectedRevision int, name string, document json.RawMessage) (Project, error) - List(ctx context.Context, limit int) ([]ProjectSummary, error) + Create(ctx context.Context, id, capabilityHash, name, sourceImageKey, sourceImageContentType string, sourceImageSize int64, document json.RawMessage) (Project, error) + Get(ctx context.Context, id, capabilityHash string) (Project, error) + Update(ctx context.Context, id, capabilityHash string, expectedRevision int, name string, document json.RawMessage) (Project, error) + LegacyRecoveryCandidate(ctx context.Context, id string) (Project, error) + RecoverLegacy(ctx context.Context, id, capabilityHash, actor string, document json.RawMessage) (Project, error) } type PostgresRepository struct { @@ -89,6 +82,40 @@ CREATE TABLE IF NOT EXISTS projects ( updated_at timestamptz NOT NULL DEFAULT timezone('UTC', now()) ); +ALTER TABLE projects ADD COLUMN IF NOT EXISTS capability_hash text; + +-- Legacy rows retain NULL until an authorized one-time recovery claim. Never +-- overwrite them with an unrecoverable random digest. +CREATE TABLE IF NOT EXISTS legacy_project_recovery_audit ( + project_id uuid PRIMARY KEY REFERENCES projects(id), + recovered_at timestamptz NOT NULL DEFAULT timezone('UTC', now()), + actor text NOT NULL +); + +-- d53b949 replaced NULL legacy capabilities with random digests. Those digests +-- are indistinguishable from real bearer hashes, so they are recoverable only +-- after an operator records the exact observed digest and case reference. +CREATE TABLE IF NOT EXISTS legacy_project_recovery_authorizations ( + project_id uuid PRIMARY KEY REFERENCES projects(id), + retired_capability_hash text NOT NULL CHECK (retired_capability_hash ~ '^[0-9a-f]{64}$'), + authorized_at timestamptz NOT NULL DEFAULT timezone('UTC', now()), + authorized_by text NOT NULL, + case_reference text NOT NULL +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'projects_capability_hash_format' + AND conrelid = 'projects'::regclass + ) THEN + ALTER TABLE projects ADD CONSTRAINT projects_capability_hash_format CHECK (capability_hash ~ '^[0-9a-f]{64}$'); + END IF; +END +$$; + CREATE INDEX IF NOT EXISTS projects_updated_at_idx ON projects (updated_at DESC); ` if _, err := r.pool.Exec(ctx, schemaSQL); err != nil { @@ -115,13 +142,14 @@ EXECUTE PROCEDURE project_touch_updated_at();`) return err } -func (r *PostgresRepository) Create(ctx context.Context, id, name, sourceImageKey, sourceImageContentType string, sourceImageSize int64, document json.RawMessage) (Project, error) { +func (r *PostgresRepository) Create(ctx context.Context, id, capabilityHash, name, sourceImageKey, sourceImageContentType string, sourceImageSize int64, document json.RawMessage) (Project, error) { var created Project row := r.pool.QueryRow(ctx, ` -INSERT INTO projects (id, name, source_image_key, source_image_content_type, source_image_size, document) -VALUES ($1, $2, $3, $4, $5, $6) +INSERT INTO projects (id, capability_hash, name, source_image_key, source_image_content_type, source_image_size, document) +VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, name, source_image_key, source_image_content_type, source_image_size, revision, created_at, updated_at;`, id, + capabilityHash, name, sourceImageKey, sourceImageContentType, @@ -136,13 +164,13 @@ RETURNING id, name, source_image_key, source_image_content_type, source_image_si return normalizeProjectTimes(created), nil } -func (r *PostgresRepository) Get(ctx context.Context, id string) (Project, error) { +func (r *PostgresRepository) Get(ctx context.Context, id, capabilityHash string) (Project, error) { var project Project row := r.pool.QueryRow(ctx, ` SELECT id, name, source_image_key, source_image_content_type, source_image_size, revision, document, created_at, updated_at FROM projects -WHERE id = $1; -`, id) +WHERE id = $1 AND capability_hash = $2; +`, id, capabilityHash) err := row.Scan(&project.ID, &project.Name, &project.SourceImageKey, &project.SourceImageContentType, &project.SourceImageSize, &project.Revision, &project.Document, &project.CreatedAt, &project.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -153,60 +181,26 @@ WHERE id = $1; return normalizeProjectTimes(project), nil } -func (r *PostgresRepository) List(ctx context.Context, limit int) ([]ProjectSummary, error) { - if limit <= 0 || limit > 100 { - limit = 100 - } - - rows, err := r.pool.Query(ctx, ` -SELECT id, name, revision, created_at, updated_at -FROM projects -ORDER BY updated_at DESC, id DESC -LIMIT $1; -`, limit) - if err != nil { - return nil, fmt.Errorf("list projects: %w", err) - } - defer rows.Close() - - var projects []ProjectSummary - for rows.Next() { - var project ProjectSummary - err = rows.Scan(&project.ID, &project.Name, &project.Revision, &project.CreatedAt, &project.UpdatedAt) - if err != nil { - return nil, fmt.Errorf("scan project summary: %w", err) - } - projects = append(projects, project) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("scan project summaries: %w", err) - } - for i := range projects { - projects[i].CreatedAt = projects[i].CreatedAt.UTC() - projects[i].UpdatedAt = projects[i].UpdatedAt.UTC() - } - return projects, nil -} - -func (r *PostgresRepository) Update(ctx context.Context, id string, expectedRevision int, name string, document json.RawMessage) (Project, error) { +func (r *PostgresRepository) Update(ctx context.Context, id, capabilityHash string, expectedRevision int, name string, document json.RawMessage) (Project, error) { var updated Project row := r.pool.QueryRow(ctx, ` UPDATE projects SET name = $2, document = $3, revision = revision + 1 -WHERE id = $1 AND revision = $4 +WHERE id = $1 AND revision = $4 AND capability_hash = $5 RETURNING id, name, source_image_key, source_image_content_type, source_image_size, revision, created_at, updated_at;`, id, name, document, expectedRevision, + capabilityHash, ) err := row.Scan(&updated.ID, &updated.Name, &updated.SourceImageKey, &updated.SourceImageContentType, &updated.SourceImageSize, &updated.Revision, &updated.CreatedAt, &updated.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { var current int - errFound := r.pool.QueryRow(ctx, `SELECT revision FROM projects WHERE id = $1;`, id).Scan(¤t) + errFound := r.pool.QueryRow(ctx, `SELECT revision FROM projects WHERE id = $1 AND capability_hash = $2;`, id, capabilityHash).Scan(¤t) if errFound == nil { return Project{}, &RevisionConflictError{ID: id, Expected: expectedRevision, Current: current} } @@ -221,6 +215,54 @@ RETURNING id, name, source_image_key, source_image_content_type, source_image_si return normalizeProjectTimes(updated), nil } +func (r *PostgresRepository) LegacyRecoveryCandidate(ctx context.Context, id string) (Project, error) { + var candidate Project + err := r.pool.QueryRow(ctx, ` +SELECT id, name, source_image_key, source_image_content_type, source_image_size, revision, document, created_at, updated_at +FROM projects WHERE id = $1;`, id).Scan( + &candidate.ID, &candidate.Name, &candidate.SourceImageKey, &candidate.SourceImageContentType, + &candidate.SourceImageSize, &candidate.Revision, &candidate.Document, &candidate.CreatedAt, &candidate.UpdatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return Project{}, ErrProjectNotFound + } + if err != nil { + return Project{}, fmt.Errorf("get legacy recovery candidate: %w", err) + } + return normalizeProjectTimes(candidate), nil +} + +func (r *PostgresRepository) RecoverLegacy(ctx context.Context, id, capabilityHash, actor string, document json.RawMessage) (Project, error) { + var recovered Project + err := r.pool.QueryRow(ctx, ` +WITH claimed AS ( + UPDATE projects AS project + SET capability_hash = $2, document = $4 + WHERE project.id = $1 + AND NOT EXISTS (SELECT 1 FROM legacy_project_recovery_audit audit WHERE audit.project_id = project.id) + AND ( + project.capability_hash IS NULL + OR EXISTS ( + SELECT 1 + FROM legacy_project_recovery_authorizations recovery_authorization + WHERE recovery_authorization.project_id = project.id + AND recovery_authorization.retired_capability_hash = project.capability_hash + ) + ) + RETURNING project.id, project.name, project.source_image_key, project.source_image_content_type, project.source_image_size, project.revision, project.document, project.created_at, project.updated_at +), audit AS ( + INSERT INTO legacy_project_recovery_audit (project_id, actor) SELECT id, $3 FROM claimed +) +SELECT id, name, source_image_key, source_image_content_type, source_image_size, revision, document, created_at, updated_at FROM claimed;`, id, capabilityHash, actor, document).Scan(&recovered.ID, &recovered.Name, &recovered.SourceImageKey, &recovered.SourceImageContentType, &recovered.SourceImageSize, &recovered.Revision, &recovered.Document, &recovered.CreatedAt, &recovered.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Project{}, ErrProjectNotFound + } + if err != nil { + return Project{}, fmt.Errorf("recover legacy project: %w", err) + } + return normalizeProjectTimes(recovered), nil +} + func normalizeProjectTimes(project Project) Project { project.CreatedAt = project.CreatedAt.UTC() project.UpdatedAt = project.UpdatedAt.UTC() diff --git a/backend/internal/db/postgres_integration_test.go b/backend/internal/db/postgres_integration_test.go index 525fb61..adfb678 100644 --- a/backend/internal/db/postgres_integration_test.go +++ b/backend/internal/db/postgres_integration_test.go @@ -43,7 +43,8 @@ func TestPostgresRepositoryLifecycle(t *testing.T) { t.Fatalf("marshal doc: %v", err) } - created, err := repo.Create(ctx, "00000000-0000-4000-8000-000000000001", "Suite 1", "source/one.png", "image/png", 12, docJSON) + const capabilityHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + created, err := repo.Create(ctx, "00000000-0000-4000-8000-000000000001", capabilityHash, "Suite 1", "source/one.png", "image/png", 12, docJSON) if err != nil { t.Fatalf("create: %v", err) } @@ -57,20 +58,22 @@ func TestPostgresRepositoryLifecycle(t *testing.T) { t.Fatalf("created_at not UTC: %v", created.CreatedAt.Location()) } - got, err := repo.Get(ctx, created.ID) + got, err := repo.Get(ctx, created.ID, capabilityHash) if err != nil { t.Fatalf("get: %v", err) } if got.ID != created.ID { t.Fatalf("get id = %s, want %s", got.ID, created.ID) } - - list, err := repo.List(ctx, 10) - if err != nil { - t.Fatalf("list: %v", err) + if _, err := repo.Get(ctx, created.ID, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); err != ErrProjectNotFound { + t.Fatalf("wrong capability hash error = %v, want ErrProjectNotFound", err) } - if len(list) != 1 { - t.Fatalf("list len = %d, want 1", len(list)) + var storedCapabilityHash string + if err := repo.pool.QueryRow(ctx, `SELECT capability_hash FROM projects WHERE id = $1`, created.ID).Scan(&storedCapabilityHash); err != nil { + t.Fatalf("read stored capability hash: %v", err) + } + if storedCapabilityHash != capabilityHash { + t.Fatalf("stored capability hash mismatch") } updatedDoc := floorplan.ParseResult{Walls: []floorplan.Segment{{X1: 0, Y1: 0, X2: 2, Y2: 0}}} @@ -78,18 +81,86 @@ func TestPostgresRepositoryLifecycle(t *testing.T) { if err != nil { t.Fatalf("marshal updated doc: %v", err) } - updated, err := repo.Update(ctx, created.ID, 1, "Suite 1+", updatedDocJSON) + updated, err := repo.Update(ctx, created.ID, capabilityHash, 1, "Suite 1+", updatedDocJSON) if err != nil { t.Fatalf("update: %v", err) } if updated.Revision != 2 { t.Fatalf("updated revision = %d, want 2", updated.Revision) } - if _, err := repo.Update(ctx, created.ID, 1, "bad", updatedDocJSON); err == nil { + if _, err := repo.Update(ctx, created.ID, capabilityHash, 1, "bad", updatedDocJSON); err == nil { t.Fatal("expected revision conflict") } } +func TestInitializeSchemaRecoversAuthorizedD53LegacyProjectWithoutTouchingBearerProjects(t *testing.T) { + dsn := mustTestDatabaseURL(t) + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("new setup pool: %v", err) + } + defer pool.Close() + + const legacyID = "00000000-0000-4000-8000-000000000099" + const bearerID = "00000000-0000-4000-8000-000000000098" + const retiredHash = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + const bearerHash = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + const recoveredHash = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + if _, err := pool.Exec(ctx, `DROP TABLE IF EXISTS projects CASCADE; +CREATE TABLE projects ( + id uuid PRIMARY KEY, + name text NOT NULL, + source_image_key text NOT NULL UNIQUE, + source_image_content_type text NOT NULL, + source_image_size bigint NOT NULL, + document jsonb NOT NULL, + revision integer NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT timezone('UTC', now()), + updated_at timestamptz NOT NULL DEFAULT timezone('UTC', now()), + capability_hash text NOT NULL +); +INSERT INTO projects (id, capability_hash, name, source_image_key, source_image_content_type, source_image_size, document) VALUES +('00000000-0000-4000-8000-000000000099', '`+retiredHash+`', 'd53 legacy', 'legacy/source.png', 'image/png', 12, '{}'), +('00000000-0000-4000-8000-000000000098', '`+bearerHash+`', 'bearer project', 'bearer/source.png', 'image/png', 12, '{}');`); err != nil { + t.Fatalf("create d53 schema: %v", err) + } + + repo := &PostgresRepository{pool: pool} + if err := repo.InitializeSchema(ctx); err != nil { + t.Fatalf("migrate d53 schema: %v", err) + } + if _, err := repo.RecoverLegacy(ctx, legacyID, recoveredHash, "operator", json.RawMessage(`{}`)); err != ErrProjectNotFound { + t.Fatalf("unauthorized d53 recovery = %v, want ErrProjectNotFound", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO legacy_project_recovery_authorizations (project_id, retired_capability_hash, authorized_by, case_reference) VALUES ($1, $2, 'operator', 'INC-19')`, legacyID, retiredHash); err != nil { + t.Fatalf("authorize d53 recovery: %v", err) + } + recovered, err := repo.RecoverLegacy(ctx, legacyID, recoveredHash, "operator", json.RawMessage(`{}`)) + if err != nil { + t.Fatalf("recover authorized d53 project: %v", err) + } + if recovered.ID != legacyID { + t.Fatalf("recovered id = %s, want %s", recovered.ID, legacyID) + } + if _, err := repo.Get(ctx, legacyID, recoveredHash); err != nil { + t.Fatalf("get recovered project: %v", err) + } + if _, err := repo.RecoverLegacy(ctx, bearerID, recoveredHash, "operator", json.RawMessage(`{}`)); err != ErrProjectNotFound { + t.Fatalf("bearer recovery = %v, want ErrProjectNotFound", err) + } + if _, err := repo.Get(ctx, bearerID, bearerHash); err != nil { + t.Fatalf("bearer project was changed: %v", err) + } + var auditCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM legacy_project_recovery_audit WHERE project_id = $1`, legacyID).Scan(&auditCount); err != nil { + t.Fatalf("read recovery audit: %v", err) + } + if auditCount != 1 { + t.Fatalf("recovery audit count = %d, want 1", auditCount) + } +} + func truncateProjectsTable(ctx context.Context, dsn string) error { pool, err := pgxpool.New(ctx, dsn) if err != nil { @@ -97,6 +168,6 @@ func truncateProjectsTable(ctx context.Context, dsn string) error { } defer pool.Close() - _, err = pool.Exec(ctx, `TRUNCATE TABLE projects;`) + _, err = pool.Exec(ctx, `TRUNCATE TABLE projects CASCADE;`) return err } diff --git a/backend/internal/floorplan/parser.go b/backend/internal/floorplan/parser.go index e67a437..7f6af9c 100644 --- a/backend/internal/floorplan/parser.go +++ b/backend/internal/floorplan/parser.go @@ -3,6 +3,7 @@ package floorplan import ( "context" "encoding/json" + "errors" "fmt" "io" "math" @@ -11,8 +12,37 @@ import ( "github.com/KingBoyAndGirl/HomeVox/backend/internal/ai" ) -const visionSystemPrompt = "You extract residential floor-plan structure. Return only one strict JSON object matching this schema: {rooms:[{name,type,approximate_bounds:{x1,y1,x2,y2},area_ratio}], walls:[{id,x1,y1,x2,y2}], doors:[{id,kind,wallId,position,width,source,confirmed}], windows:[{id,kind,wallId,position,width,source,confirmed}], scale:{unit,pixel_to_unit}, metadata:{source,confidence,image_width,image_height}}. Every listed field is required, no additional fields are accepted, and arrays may be empty. Use pixel coordinates when exact scale is unknown. Never infer or fabricate wall associations, opening widths, architectural dimensions, scale, orientation, height, thickness, or load-bearing status." +const visionSystemPrompt = "You extract residential floor-plan structure. Return only one strict JSON object matching this schema: {rooms:[{name,type,approximate_bounds:{x1,y1,x2,y2},area_ratio}], walls:[{id,x1,y1,x2,y2}], doors:[{id,kind,wallId,position,width,source,confirmed}], windows:[{id,kind,wallId,position,width,source,confirmed}], scale:{unit,pixel_to_unit}, metadata:{source,confidence,image_width,image_height}}. Every listed field is required, no additional fields are accepted, and arrays may be empty. All coordinates and opening widths are image pixels. scale.pixel_to_unit must be either a finite number or null, never a string: when no physical scale is explicitly visible, return exactly scale:{unit:\"px\",pixel_to_unit:null}; do not infer a conversion, orientation, height, thickness, or load-bearing status. metadata.confidence must be a finite number and image_width/image_height must be integers." const visionUserPrompt = "Parse this floor-plan image into the required JSON structure. Do not include markdown fences. Omit an opening if its wall-local position or width cannot be established." +const dimensionedVisionUserPrompt = "Parse this floor-plan image into the required JSON structure. The original uploaded image is exactly %d pixels wide and %d pixels high. Use that exact original %d x %d pixel coordinate grid for every room bound, wall endpoint, and opening width, and return metadata.image_width exactly %d and metadata.image_height exactly %d. Do not include markdown fences. Omit an opening if its wall-local position or width cannot be established." +const candidateSystemPrompt = "Find reliable single-floor-plan crops in this source image. Return only one strict JSON object matching this schema: {mode:string,candidates:[{x:number,y:number,width:number,height:number}]}. mode must be exactly one of \"single\", \"composite\", or \"uncertain\". Candidates must be absolute integer-pixel rectangles on the source image. Return mode \"uncertain\" with candidates:[] when no reliable crop can be established. Return \"single\" with exactly one candidate; return \"composite\" with two or more non-overlapping candidates. Do not add fields or markdown." +const candidateUserPrompt = "Detect reliable crop rectangles that each contain one standalone floor plan. Do not guess a crop when the image is ambiguous." + +const minCandidateAreaPixels = 64 + +type ParseErrorCode string + +const ( + ParseErrorTransport ParseErrorCode = "transport" + ParseErrorSchema ParseErrorCode = "schema" + ParseErrorContent ParseErrorCode = "content" +) + +type ParseError struct { + Code ParseErrorCode + Err error +} + +func (e *ParseError) Error() string { return e.Err.Error() } +func (e *ParseError) Unwrap() error { return e.Err } + +func ErrorCode(err error) ParseErrorCode { + var parseError *ParseError + if errors.As(err, &parseError) { + return parseError.Code + } + return ParseErrorTransport +} type Parser struct { client *ai.Client @@ -23,6 +53,18 @@ func NewParser(client *ai.Client) *Parser { } func (p *Parser) Parse(ctx context.Context, imageDataURL string) (ParseResult, error) { + return p.parse(ctx, imageDataURL, visionUserPrompt, 0, 0) +} + +func (p *Parser) ParseAtDimensions(ctx context.Context, imageDataURL string, imageWidth, imageHeight int) (ParseResult, error) { + if imageWidth <= 0 || imageHeight <= 0 { + return ParseResult{}, &ParseError{Code: ParseErrorContent, Err: fmt.Errorf("source image dimensions must be positive")} + } + prompt := fmt.Sprintf(dimensionedVisionUserPrompt, imageWidth, imageHeight, imageWidth, imageHeight, imageWidth, imageHeight) + return p.parse(ctx, imageDataURL, prompt, imageWidth, imageHeight) +} + +func (p *Parser) parse(ctx context.Context, imageDataURL, userPrompt string, imageWidth, imageHeight int) (ParseResult, error) { if p.client == nil || p.client.APIKey == "" { return ParseResult{}, fmt.Errorf("AI_API_KEY is required to parse floor plans") } @@ -38,7 +80,7 @@ func (p *Parser) Parse(ctx context.Context, imageDataURL string) (ParseResult, e { Role: "user", Content: []map[string]any{ - {"type": "text", "text": visionUserPrompt}, + {"type": "text", "text": userPrompt}, {"type": "image_url", "image_url": map[string]string{"url": imageDataURL}}, }, }, @@ -46,19 +88,32 @@ func (p *Parser) Parse(ctx context.Context, imageDataURL string) (ParseResult, e response, err := p.client.Chat(ctx, messages) if err != nil { - return ParseResult{}, err + return ParseResult{}, &ParseError{Code: ParseErrorTransport, Err: err} } content, err := firstChoiceContent(response) if err != nil { - return ParseResult{}, err + return ParseResult{}, &ParseError{Code: ParseErrorSchema, Err: err} } result, err := decodeCanonicalParseResult(content) if err != nil { - return ParseResult{}, err + return ParseResult{}, &ParseError{Code: ParseErrorSchema, Err: err} + } + if imageWidth > 0 && imageHeight > 0 { + // Provider preprocessing may self-report different dimensions even when + // the returned geometry follows the requested source-pixel grid. Decoded + // upload bytes remain authoritative; geometry is checked against those + // bounds below instead of trusting provider metadata. + result.Metadata.ImageWidth = imageWidth + result.Metadata.ImageHeight = imageHeight } if err := validateParsedResult(result); err != nil { - return ParseResult{}, err + return ParseResult{}, &ParseError{Code: ParseErrorContent, Err: err} + } + if imageWidth > 0 && imageHeight > 0 { + if err := validateParsedBounds(result, imageWidth, imageHeight); err != nil { + return ParseResult{}, &ParseError{Code: ParseErrorContent, Err: err} + } } // Vision output is an unmeasured interpretation, never an architectural // confirmation. Preserve only explicit manual/measurement confirmations. @@ -71,13 +126,160 @@ func (p *Parser) Parse(ctx context.Context, imageDataURL string) (ParseResult, e return result, nil } +func validateParsedBounds(result ParseResult, imageWidth, imageHeight int) error { + inBounds := func(x, y float64) bool { + return x >= 0 && x <= float64(imageWidth) && y >= 0 && y <= float64(imageHeight) + } + for i, room := range result.Rooms { + bounds := room.ApproximateBounds + if bounds.X1 > bounds.X2 || bounds.Y1 > bounds.Y2 || !inBounds(bounds.X1, bounds.Y1) || !inBounds(bounds.X2, bounds.Y2) { + return fmt.Errorf("room[%d] exceeds source image bounds", i) + } + } + for i, wall := range result.Walls { + if !inBounds(wall.X1, wall.Y1) || !inBounds(wall.X2, wall.Y2) { + return fmt.Errorf("wall[%d] exceeds source image bounds", i) + } + } + return nil +} + +// AnalyzeCandidates finds reliable source-image crop candidates without +// creating a fallback rectangle. Returned coordinates stay in original pixels. +func (p *Parser) AnalyzeCandidates(ctx context.Context, imageDataURL string, imageWidth, imageHeight int) (CandidateDetection, error) { + if p.client == nil || p.client.APIKey == "" { + return CandidateDetection{}, fmt.Errorf("AI_API_KEY is required to analyze floor-plan candidates") + } + if p.client.BaseURL == "" || p.client.Model == "" { + return CandidateDetection{}, fmt.Errorf("AI_BASE_URL and AI_MODEL are required to analyze floor-plan candidates") + } + if imageWidth <= 0 || imageHeight <= 0 { + return CandidateDetection{}, &ParseError{Code: ParseErrorContent, Err: fmt.Errorf("source image dimensions must be positive")} + } + response, err := p.client.Chat(ctx, []ai.Message{ + {Role: "system", Content: candidateSystemPrompt}, + {Role: "user", Content: []map[string]any{ + {"type": "text", "text": candidateUserPrompt}, + {"type": "image_url", "image_url": map[string]string{"url": imageDataURL}}, + }}, + }) + if err != nil { + return CandidateDetection{}, &ParseError{Code: ParseErrorTransport, Err: err} + } + content, err := firstChoiceContent(response) + if err != nil { + return CandidateDetection{}, &ParseError{Code: ParseErrorSchema, Err: err} + } + result, err := decodeCandidateDetection(content) + if err != nil { + return CandidateDetection{}, &ParseError{Code: ParseErrorSchema, Err: err} + } + if err := validateCandidateDetection(result, imageWidth, imageHeight); err != nil { + return CandidateDetection{}, &ParseError{Code: ParseErrorContent, Err: err} + } + return result, nil +} + +func decodeCandidateDetection(content string) (CandidateDetection, error) { + decoder := json.NewDecoder(strings.NewReader(content)) + if err := validateJSONValue(decoder, true); err != nil { + return CandidateDetection{}, fmt.Errorf("decode ai candidate result: %w", err) + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return CandidateDetection{}, fmt.Errorf("decode ai candidate result: trailing JSON value") + } + return CandidateDetection{}, fmt.Errorf("decode ai candidate result: trailing content: %w", err) + } + var root map[string]json.RawMessage + if err := json.Unmarshal([]byte(content), &root); err != nil { + return CandidateDetection{}, fmt.Errorf("decode ai candidate result: %w", err) + } + if err := validateObject(root, map[string]rawValidator{ + "mode": validateString, "candidates": validateCandidateRects, + }); err != nil { + return CandidateDetection{}, fmt.Errorf("decode ai candidate result: %w", err) + } + var result CandidateDetection + if err := json.Unmarshal([]byte(content), &result); err != nil { + return CandidateDetection{}, fmt.Errorf("decode ai candidate result: %w", err) + } + return result, nil +} + +func validateCandidateRects(raw json.RawMessage) error { + return validateArray(raw, func(item json.RawMessage) error { + object, err := decodeObject(item) + if err != nil { + return err + } + return validateObject(object, map[string]rawValidator{ + "x": validateNumber, "y": validateNumber, "width": validateNumber, "height": validateNumber, + }) + }) +} + +func validateCandidateDetection(result CandidateDetection, imageWidth, imageHeight int) error { + switch result.Mode { + case CandidateModeUncertain: + if len(result.Candidates) != 0 { + return fmt.Errorf("uncertain candidate detection must not fabricate candidates") + } + return nil + case CandidateModeSingle: + if len(result.Candidates) != 1 { + return fmt.Errorf("single candidate detection must contain exactly one candidate") + } + case CandidateModeComposite: + if len(result.Candidates) < 2 { + return fmt.Errorf("composite candidate detection must contain at least two candidates") + } + default: + return fmt.Errorf("candidate detection has invalid mode") + } + for i, candidate := range result.Candidates { + if !finite(candidate.X) || !finite(candidate.Y) || !finite(candidate.Width) || !finite(candidate.Height) || + math.Trunc(candidate.X) != candidate.X || math.Trunc(candidate.Y) != candidate.Y || + math.Trunc(candidate.Width) != candidate.Width || math.Trunc(candidate.Height) != candidate.Height || + candidate.X < 0 || candidate.Y < 0 || candidate.Width <= 0 || candidate.Height <= 0 || + candidate.Width*candidate.Height < minCandidateAreaPixels || + candidate.X+candidate.Width > float64(imageWidth) || candidate.Y+candidate.Height > float64(imageHeight) { + return fmt.Errorf("candidate[%d] has invalid source-pixel rectangle", i) + } + for j := 0; j < i; j++ { + if overlaps(candidate, result.Candidates[j]) { + return fmt.Errorf("candidate[%d] overlaps candidate[%d]", i, j) + } + } + } + return nil +} + +func overlaps(a, b CandidateRect) bool { + left, top := math.Max(a.X, b.X), math.Max(a.Y, b.Y) + right, bottom := math.Min(a.X+a.Width, b.X+b.Width), math.Min(a.Y+a.Height, b.Y+b.Height) + return right > left && bottom > top +} + func validateParsedResult(result ParseResult) error { - if strings.TrimSpace(result.Metadata.Source) == "" || strings.TrimSpace(result.Scale.Unit) == "" { + if strings.TrimSpace(result.Metadata.Source) == "" || strings.TrimSpace(result.Scale.Unit) == "" || + !result.Scale.HasPixelToUnit() || !result.Metadata.HasRequiredFields() { return fmt.Errorf("ai result is missing required schema fields") } - if !finite(result.Metadata.Confidence) || !finite(result.Scale.PixelToUnit) { + if !finite(result.Metadata.Confidence) { return fmt.Errorf("ai result has invalid numeric metadata") } + if result.Scale.PixelToUnit == nil { + if result.Scale.Unit != "px" { + return fmt.Errorf("unknown scale must use pixel coordinates") + } + } else if !finite(*result.Scale.PixelToUnit) || *result.Scale.PixelToUnit <= 0 { + return fmt.Errorf("ai result has invalid scale conversion") + } + if len(result.Walls) == 0 { + return fmt.Errorf("ai result has no reliable wall topology") + } wallIDs := make(map[string]Segment, len(result.Walls)) for i, wall := range result.Walls { if wall.ID == "" { @@ -294,6 +496,13 @@ func validateNumber(raw json.RawMessage) error { return nil } +func validateNullableNumber(raw json.RawMessage) error { + if string(raw) == "null" { + return nil + } + return validateNumber(raw) +} + func validateInteger(raw json.RawMessage) error { var value any if err := json.Unmarshal(raw, &value); err != nil { @@ -370,7 +579,7 @@ func validateScale(raw json.RawMessage) error { if err != nil { return err } - return validateObject(object, map[string]rawValidator{"unit": validateString, "pixel_to_unit": validateNumber}) + return validateObject(object, map[string]rawValidator{"unit": validateString, "pixel_to_unit": validateNullableNumber}) } func validateMetadata(raw json.RawMessage) error { diff --git a/backend/internal/floorplan/parser_test.go b/backend/internal/floorplan/parser_test.go index f4fc509..d49a968 100644 --- a/backend/internal/floorplan/parser_test.go +++ b/backend/internal/floorplan/parser_test.go @@ -55,7 +55,11 @@ func TestParseUsesOpenAICompatibleVisionContractAndRejectsInvalidOpeningGeometry if request.Model != "vision-test" || len(request.Messages) != 2 { t.Fatalf("vision request = %#v", request) } - if request.Messages[0].Role != "system" || string(request.Messages[0].Content) != `"`+visionSystemPrompt+`"` { + expectedSystem, err := json.Marshal(visionSystemPrompt) + if err != nil { + t.Fatalf("marshal expected system prompt: %v", err) + } + if request.Messages[0].Role != "system" || string(request.Messages[0].Content) != string(expectedSystem) { t.Fatalf("system message = %s", request.Messages[0].Content) } if request.Messages[1].Role != "user" { @@ -80,6 +84,35 @@ func TestParseUsesOpenAICompatibleVisionContractAndRejectsInvalidOpeningGeometry } } +func TestParseAtDimensionsRejectsGeometryOutsideDecodedImage(t *testing.T) { + body := strings.Replace(canonicalParseJSON, `"x2":200,"y2":0`, `"x2":201,"y2":0`, 1) + server := visionServer(t, body, nil) + defer server.Close() + + _, err := NewParser(ai.NewClient(server.URL+"/v1", "test-key", "vision-test")).ParseAtDimensions( + context.Background(), "data:image/png;base64,cG5n", 200, 80, + ) + if err == nil || ErrorCode(err) != ParseErrorContent || !strings.Contains(err.Error(), "source image bounds") { + t.Fatalf("error = %v", err) + } +} + +func TestParseAtDimensionsOverridesProviderPreprocessingDimensions(t *testing.T) { + body := strings.Replace(canonicalParseJSON, `"image_width":200,"image_height":80`, `"image_width":100,"image_height":40`, 1) + server := visionServer(t, body, nil) + defer server.Close() + + result, err := NewParser(ai.NewClient(server.URL+"/v1", "test-key", "vision-test")).ParseAtDimensions( + context.Background(), "data:image/png;base64,cG5n", 200, 80, + ) + if err != nil { + t.Fatalf("ParseAtDimensions() error = %v", err) + } + if result.Metadata.ImageWidth != 200 || result.Metadata.ImageHeight != 80 { + t.Fatalf("metadata dimensions = %dx%d, want decoded 200x80", result.Metadata.ImageWidth, result.Metadata.ImageHeight) + } +} + func TestParseRejectsInvalidOpenAIEnvelopes(t *testing.T) { for name, body := range map[string]string{ "non JSON": `not-json`, "empty choices": `{"choices":[]}`, "empty content": `{"choices":[{"message":{"content":""}}]}`, @@ -110,6 +143,8 @@ func TestParseRejectsNonCanonicalAIJSON(t *testing.T) { "partial metadata": strings.Replace(valid, `,"image_height":80`, ``, 1), "null object": strings.Replace(valid, `"scale":{"unit":"px","pixel_to_unit":1}`, `"scale":null`, 1), "null array": strings.Replace(valid, `"doors":[`, `"doors":null`, 1), + "unknown as string": strings.Replace(valid, `"pixel_to_unit":1`, `"pixel_to_unit":"unknown"`, 1), + "null confidence": strings.Replace(valid, `"confidence":0.9`, `"confidence":null`, 1), "wrong type": strings.Replace(valid, `"width":40`, `"width":"40"`, 1), "duplicate key": strings.Replace(valid, `"id":"wall-1"`, `"id":"wall-1","id":"wall-2"`, 1), "trailing JSON": valid + ` {"ignored":true}`, @@ -134,11 +169,36 @@ func TestParseAcceptsOnlyCompleteCanonicalAIJSON(t *testing.T) { if err != nil { t.Fatalf("Parse() error = %v", err) } - if result.Walls[0].ID != "wall-1" || result.Doors[0].Source != "ai" || result.Scale.PixelToUnit != 1 || result.Windows[0].Confirmed { + if result.Walls[0].ID != "wall-1" || result.Doors[0].Source != "ai" || result.Scale.PixelToUnit == nil || *result.Scale.PixelToUnit != 1 || result.Windows[0].Confirmed { t.Fatalf("unexpected canonical result: %#v", result) } } +func TestParseAcceptsExplicitUnknownPixelScaleWithoutInventingMeasurement(t *testing.T) { + unknownScale := strings.Replace(canonicalParseJSON, `"unit":"px","pixel_to_unit":1`, `"unit":"px","pixel_to_unit":null`, 1) + server := visionServer(t, unknownScale, nil) + defer server.Close() + + result, err := NewParser(ai.NewClient(server.URL+"/v1", "test-key", "vision-test")).Parse(context.Background(), "data:image/png;base64,cG5n") + if err != nil { + t.Fatalf("Parse() rejected explicit unknown pixel scale: %v", err) + } + if result.Scale.Unit != "px" || result.Scale.PixelToUnit != nil { + t.Fatalf("scale = %#v, want pixel coordinates with no fabricated conversion", result.Scale) + } +} + +func TestParseRejectsUnknownScaleWithNonPixelUnit(t *testing.T) { + unknownScale := strings.Replace(canonicalParseJSON, `"unit":"px","pixel_to_unit":1`, `"unit":"m","pixel_to_unit":null`, 1) + server := visionServer(t, unknownScale, nil) + defer server.Close() + + _, err := NewParser(ai.NewClient(server.URL+"/v1", "test-key", "vision-test")).Parse(context.Background(), "data:image/png;base64,cG5n") + if err == nil || ErrorCode(err) != ParseErrorContent { + t.Fatalf("error = %v, code = %s; want invalid semantic scale content error", err, ErrorCode(err)) + } +} + func visionServer(t *testing.T, content string, check func(*http.Request)) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -149,3 +209,60 @@ func visionServer(t *testing.T, content string, check func(*http.Request)) *http _, _ = fmt.Fprintf(w, `{"choices":[{"message":{"content":%q}}]}`, content) })) } + +func TestAnalyzeCandidatesUsesStrictContractAndRejectsUnsafeCrops(t *testing.T) { + valid := `{"mode":"composite","candidates":[{"x":10,"y":20,"width":100,"height":80},{"x":150,"y":20,"width":90,"height":80}]}` + server := visionServer(t, valid, func(r *http.Request) { + var request struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + if len(request.Messages) != 2 || request.Messages[0].Role != "system" { + t.Fatalf("messages=%#v", request.Messages) + } + var system string + if err := json.Unmarshal(request.Messages[0].Content, &system); err != nil || system != candidateSystemPrompt { + t.Fatalf("system=%q err=%v", system, err) + } + }) + defer server.Close() + result, err := NewParser(ai.NewClient(server.URL, "test-key", "vision-test")).AnalyzeCandidates(context.Background(), "data:image/png;base64,cG5n", 300, 200) + if err != nil { + t.Fatalf("AnalyzeCandidates() error = %v", err) + } + if result.Mode != CandidateModeComposite || len(result.Candidates) != 2 || result.Candidates[1].X != 150 { + t.Fatalf("result=%#v", result) + } + + uncertainServer := visionServer(t, `{"mode":"uncertain","candidates":[]}`, nil) + defer uncertainServer.Close() + uncertain, err := NewParser(ai.NewClient(uncertainServer.URL, "test-key", "vision-test")).AnalyzeCandidates(context.Background(), "data:image/png;base64,cG5n", 300, 200) + if err != nil || uncertain.Mode != CandidateModeUncertain || len(uncertain.Candidates) != 0 { + t.Fatalf("uncertain=%#v err=%v", uncertain, err) + } + + for name, content := range map[string]string{ + "empty single": `{"mode":"single","candidates":[]}`, + "out of bounds": `{"mode":"single","candidates":[{"x":250,"y":0,"width":60,"height":80}]}`, + "zero area": `{"mode":"single","candidates":[{"x":0,"y":0,"width":0,"height":80}]}`, + "overlap": `{"mode":"composite","candidates":[{"x":0,"y":0,"width":100,"height":100},{"x":50,"y":0,"width":100,"height":100}]}`, + "unknown field": `{"mode":"single","candidates":[{"x":0,"y":0,"width":100,"height":80,"score":0.9}]}`, + } { + t.Run(name, func(t *testing.T) { + s := visionServer(t, content, nil) + defer s.Close() + _, err := NewParser(ai.NewClient(s.URL, "key", "model")).AnalyzeCandidates(context.Background(), "data:image/png;base64,cG5n", 300, 200) + if err == nil { + t.Fatalf("accepted unsafe result: %s", content) + } + if ErrorCode(err) != ParseErrorContent && name != "unknown field" { + t.Fatalf("code=%s error=%v", ErrorCode(err), err) + } + }) + } +} diff --git a/backend/internal/floorplan/types.go b/backend/internal/floorplan/types.go index b31e6c4..ddec476 100644 --- a/backend/internal/floorplan/types.go +++ b/backend/internal/floorplan/types.go @@ -1,5 +1,10 @@ package floorplan +import ( + "encoding/json" + "fmt" +) + type Bounds struct { X1 float64 `json:"x1"` Y1 float64 `json:"y1"` @@ -41,8 +46,10 @@ type Opening struct { } type Scale struct { - Unit string `json:"unit"` - PixelToUnit float64 `json:"pixel_to_unit"` + Unit string `json:"unit"` + PixelToUnit *float64 `json:"pixel_to_unit"` + + pixelToUnitPresent bool } type Metadata struct { @@ -50,6 +57,10 @@ type Metadata struct { Confidence float64 `json:"confidence"` ImageWidth int `json:"image_width"` ImageHeight int `json:"image_height"` + + confidencePresent bool + imageWidthPresent bool + imageHeightPresent bool } type ParseResult struct { @@ -71,3 +82,142 @@ type ParseResponse struct { Size int `json:"size"` Result ParseResult `json:"result"` } + +type CandidateDetectionMode string + +const ( + CandidateModeSingle CandidateDetectionMode = "single" + CandidateModeComposite CandidateDetectionMode = "composite" + CandidateModeUncertain CandidateDetectionMode = "uncertain" +) + +type CandidateRect struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +type CandidateDetection struct { + Mode CandidateDetectionMode `json:"mode"` + Candidates []CandidateRect `json:"candidates"` +} + +// HasPixelToUnit distinguishes an explicit null (an honest unknown physical +// conversion) from an omitted field. The field is part of the durable schema +// and must always be present as either a finite number or null. +func (s Scale) HasPixelToUnit() bool { + return s.pixelToUnitPresent +} + +func (m Metadata) HasRequiredFields() bool { + return m.confidencePresent && m.imageWidthPresent && m.imageHeightPresent +} + +func (s *Scale) UnmarshalJSON(data []byte) error { + fields, err := requiredObjectFields(data, "scale") + if err != nil { + return err + } + unit, err := requiredStringField(fields, "unit") + if err != nil { + return fmt.Errorf("scale: %w", err) + } + rawConversion, present := fields["pixel_to_unit"] + if !present { + return fmt.Errorf("scale: missing required field %q", "pixel_to_unit") + } + s.Unit = unit + s.pixelToUnitPresent = true + if string(rawConversion) == "null" { + s.PixelToUnit = nil + return nil + } + var conversion float64 + if err := json.Unmarshal(rawConversion, &conversion); err != nil { + return fmt.Errorf("scale pixel_to_unit: %w", err) + } + s.PixelToUnit = &conversion + return nil +} + +func (m *Metadata) UnmarshalJSON(data []byte) error { + fields, err := requiredObjectFields(data, "metadata") + if err != nil { + return err + } + source, err := requiredStringField(fields, "source") + if err != nil { + return fmt.Errorf("metadata: %w", err) + } + confidence, err := requiredFloatField(fields, "confidence") + if err != nil { + return fmt.Errorf("metadata: %w", err) + } + width, err := requiredIntegerField(fields, "image_width") + if err != nil { + return fmt.Errorf("metadata: %w", err) + } + height, err := requiredIntegerField(fields, "image_height") + if err != nil { + return fmt.Errorf("metadata: %w", err) + } + m.Source = source + m.Confidence = confidence + m.ImageWidth = width + m.ImageHeight = height + m.confidencePresent = true + m.imageWidthPresent = true + m.imageHeightPresent = true + return nil +} + +func requiredObjectFields(data []byte, name string) (map[string]json.RawMessage, error) { + if string(data) == "null" { + return nil, fmt.Errorf("%s must be an object", name) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil || fields == nil { + if err != nil { + return nil, err + } + return nil, fmt.Errorf("%s must be an object", name) + } + return fields, nil +} + +func requiredStringField(fields map[string]json.RawMessage, name string) (string, error) { + raw, present := fields[name] + if !present || string(raw) == "null" { + return "", fmt.Errorf("missing required field %q", name) + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", err + } + return value, nil +} + +func requiredFloatField(fields map[string]json.RawMessage, name string) (float64, error) { + raw, present := fields[name] + if !present || string(raw) == "null" { + return 0, fmt.Errorf("missing required field %q", name) + } + var value float64 + if err := json.Unmarshal(raw, &value); err != nil { + return 0, err + } + return value, nil +} + +func requiredIntegerField(fields map[string]json.RawMessage, name string) (int, error) { + raw, present := fields[name] + if !present || string(raw) == "null" { + return 0, fmt.Errorf("missing required field %q", name) + } + var value int + if err := json.Unmarshal(raw, &value); err != nil { + return 0, err + } + return value, nil +} diff --git a/backend/internal/imagevalidate/image.go b/backend/internal/imagevalidate/image.go index 01e5f92..28916e1 100644 --- a/backend/internal/imagevalidate/image.go +++ b/backend/internal/imagevalidate/image.go @@ -19,6 +19,11 @@ var supported = map[string]string{ "webp": "image/webp", } +const ( + MaxImageDimension = 16384 + MaxImagePixels = 100_000_000 +) + // Decode validates the complete encoded image and returns its canonical MIME // type and dimensions. Decode, rather than a signature check, rejects truncated // and corrupt images before they can reach a provider or object storage. @@ -26,11 +31,25 @@ func Decode(data []byte) (contentType string, width, height int, err error) { if len(data) == 0 { return "", 0, 0, fmt.Errorf("image is empty") } + config, configFormat, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return "", 0, 0, fmt.Errorf("decode image config: %w", err) + } + contentType, ok := supported[configFormat] + if !ok { + return "", 0, 0, fmt.Errorf("unsupported decoded image format %q", configFormat) + } + if config.Width <= 0 || config.Height <= 0 { + return "", 0, 0, fmt.Errorf("image dimensions must be positive") + } + if config.Width > MaxImageDimension || config.Height > MaxImageDimension || int64(config.Width)*int64(config.Height) > MaxImagePixels { + return "", 0, 0, fmt.Errorf("image dimensions exceed %dx%d or %d pixels", MaxImageDimension, MaxImageDimension, MaxImagePixels) + } imageValue, format, err := image.Decode(bytes.NewReader(data)) if err != nil { return "", 0, 0, fmt.Errorf("decode image: %w", err) } - contentType, ok := supported[format] + contentType, ok = supported[format] if !ok { return "", 0, 0, fmt.Errorf("unsupported decoded image format %q", format) } diff --git a/backend/internal/imagevalidate/image_test.go b/backend/internal/imagevalidate/image_test.go index 154533c..9fe0f4b 100644 --- a/backend/internal/imagevalidate/image_test.go +++ b/backend/internal/imagevalidate/image_test.go @@ -3,11 +3,14 @@ package imagevalidate import ( "bytes" "encoding/base64" + "encoding/binary" + "hash/crc32" "image" "image/color" "image/gif" "image/jpeg" "image/png" + "strings" "testing" ) @@ -56,6 +59,16 @@ func TestDecodeRejectsTruncatedOrCorruptImages(t *testing.T) { } } +func TestDecodeRejectsOversizedDimensionsBeforeFullDecode(t *testing.T) { + data := mustEncode(t, func(w *bytes.Buffer, value image.Image) error { return png.Encode(w, value) }) + binary.BigEndian.PutUint32(data[16:20], uint32(MaxImageDimension+1)) + binary.BigEndian.PutUint32(data[20:24], 1) + binary.BigEndian.PutUint32(data[29:33], crc32.ChecksumIEEE(data[12:29])) + if _, _, _, err := Decode(data); err == nil || !strings.Contains(err.Error(), "dimensions exceed") { + t.Fatalf("Decode oversized IHDR error = %v", err) + } +} + func mustEncode(t *testing.T, encode func(*bytes.Buffer, image.Image) error) []byte { t.Helper() var out bytes.Buffer diff --git a/backend/internal/project/project.go b/backend/internal/project/project.go index fe059f4..3e81597 100644 --- a/backend/internal/project/project.go +++ b/backend/internal/project/project.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "regexp" "strings" "unicode/utf8" @@ -19,12 +20,15 @@ const ( MaxCreateRequestBytes = MaxDocumentBytes + MaxSourceImageBytes + MaxCreateRequestOverhead MaxUpdateRequestBytes = MaxDocumentBytes + MaxCreateRequestOverhead MinNameLength = 1 + MinCanonicalWallLength = 1e-3 ) var SupportedImageContentTypes = []string{"image/png", "image/jpeg", "image/gif", "image/webp"} var imageContentTypeSet = map[string]struct{}{} +var stableGeometryIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + func init() { for _, contentType := range SupportedImageContentTypes { imageContentTypeSet[contentType] = struct{}{} @@ -69,6 +73,54 @@ func NormalizeDocument(raw json.RawMessage) (floorplan.ParseResponse, error) { return doc, nil } +// NormalizeLegacyDocument upgrades only the historically omitted durable +// fields before applying the current strict schema. Source dimensions come +// from the verified stored image, never from an inferred floorplan. +func NormalizeLegacyDocument(raw json.RawMessage, imageWidth, imageHeight int) (floorplan.ParseResponse, error) { + var document map[string]any + if err := json.Unmarshal(raw, &document); err != nil || document == nil { + if err != nil { + return floorplan.ParseResponse{}, fmt.Errorf("invalid legacy document JSON: %w", err) + } + return floorplan.ParseResponse{}, errors.New("legacy document must be an object") + } + result, ok := document["result"].(map[string]any) + if !ok || result == nil { + return floorplan.ParseResponse{}, errors.New("legacy document result is required") + } + scale, ok := result["scale"].(map[string]any) + if !ok || scale == nil { + return floorplan.ParseResponse{}, errors.New("legacy document scale is required") + } + if _, exists := scale["unit"]; !exists { + scale["unit"] = "px" + } + if _, exists := scale["pixel_to_unit"]; !exists { + scale["pixel_to_unit"] = nil + } + metadata, ok := result["metadata"].(map[string]any) + if !ok || metadata == nil { + return floorplan.ParseResponse{}, errors.New("legacy document metadata is required") + } + if source, exists := metadata["source"].(string); !exists || strings.TrimSpace(source) == "" { + metadata["source"] = "legacy_unconfirmed" + } + if _, exists := metadata["confidence"]; !exists { + metadata["confidence"] = 0 + } + if _, exists := metadata["image_width"]; !exists { + metadata["image_width"] = imageWidth + } + if _, exists := metadata["image_height"]; !exists { + metadata["image_height"] = imageHeight + } + encoded, err := json.Marshal(document) + if err != nil { + return floorplan.ParseResponse{}, fmt.Errorf("encode legacy document: %w", err) + } + return NormalizeDocument(encoded) +} + func validateDocumentEnvelope(doc floorplan.ParseResponse) error { if strings.TrimSpace(doc.Filename) == "" { return errors.New("document filename is required") @@ -82,21 +134,34 @@ func validateDocumentEnvelope(doc floorplan.ParseResponse) error { if strings.TrimSpace(doc.Result.Scale.Unit) == "" { return errors.New("document scale unit is required") } + if !doc.Result.Scale.HasPixelToUnit() || !doc.Result.Metadata.HasRequiredFields() { + return errors.New("document has missing required scale or metadata fields") + } if strings.TrimSpace(doc.Result.Metadata.Source) == "" { return errors.New("document metadata source is required") } if doc.Result.Metadata.ImageWidth < 0 || doc.Result.Metadata.ImageHeight < 0 { return errors.New("document metadata image dimensions must not be negative") } - if isNotFinite(doc.Result.Metadata.Confidence) || isNotFinite(doc.Result.Scale.PixelToUnit) { + if isNotFinite(doc.Result.Metadata.Confidence) { return errors.New("document has invalid numeric metadata") } + if conversion := doc.Result.Scale.PixelToUnit; conversion == nil { + if doc.Result.Scale.Unit != "px" { + return errors.New("unknown document scale must use pixel coordinates") + } + } else if isNotFinite(*conversion) || *conversion <= 0 { + return errors.New("document has invalid scale conversion") + } if err := validateSegmentSet(doc.Result.Walls); err != nil { return err } if err := validateBounds(doc.Result.Rooms); err != nil { return err } + if err := validateImageBounds(doc); err != nil { + return err + } allOpenings := append(append([]floorplan.Opening{}, doc.Result.Doors...), doc.Result.Windows...) if err := validateOpenings(doc.Result.Walls, allOpenings); err != nil { return err @@ -104,11 +169,29 @@ func validateDocumentEnvelope(doc floorplan.ParseResponse) error { return nil } +func validateImageBounds(doc floorplan.ParseResponse) error { + width := float64(doc.Result.Metadata.ImageWidth) + height := float64(doc.Result.Metadata.ImageHeight) + inside := func(x, y float64) bool { return x >= 0 && x <= width && y >= 0 && y <= height } + for i, wall := range doc.Result.Walls { + if !inside(wall.X1, wall.Y1) || !inside(wall.X2, wall.Y2) { + return fmt.Errorf("wall[%d] exceeds source image bounds", i) + } + } + for i, room := range doc.Result.Rooms { + bounds := room.ApproximateBounds + if !inside(bounds.X1, bounds.Y1) || !inside(bounds.X2, bounds.Y2) { + return fmt.Errorf("room[%d] exceeds source image bounds", i) + } + } + return nil +} + func validateSegmentSet(segments []floorplan.Segment) error { seen := map[string]struct{}{} for i, segment := range segments { - if strings.TrimSpace(segment.ID) == "" { - return fmt.Errorf("wall[%d] id is required", i) + if !stableGeometryIDPattern.MatchString(segment.ID) { + return fmt.Errorf("wall[%d] must use a stable id", i) } if _, ok := seen[segment.ID]; ok { return fmt.Errorf("wall[%d] has duplicate id", i) @@ -117,7 +200,7 @@ func validateSegmentSet(segments []floorplan.Segment) error { if isNotFinite(segment.X1) || isNotFinite(segment.Y1) || isNotFinite(segment.X2) || isNotFinite(segment.Y2) { return fmt.Errorf("wall[%d] has invalid numeric value", i) } - if math.Hypot(segment.X2-segment.X1, segment.Y2-segment.Y1) <= 0 { + if math.Hypot(segment.X2-segment.X1, segment.Y2-segment.Y1) <= MinCanonicalWallLength { return fmt.Errorf("wall[%d] is degenerate", i) } } @@ -177,8 +260,8 @@ func validateOpenings(walls []floorplan.Segment, openings []floorplan.Opening) e seen := map[string]struct{}{} grouped := map[string][]floorplan.Opening{} for i, o := range openings { - if strings.TrimSpace(o.ID) == "" { - return fmt.Errorf("opening[%d] id is required", i) + if !stableGeometryIDPattern.MatchString(o.ID) { + return fmt.Errorf("opening[%d] must use a stable id", i) } if _, ok := seen[o.ID]; ok { return fmt.Errorf("opening[%d] has duplicate id", i) @@ -237,7 +320,7 @@ func IsSupportedContentType(contentType string) bool { // ValidateSourceImageMetadata makes the durable document's source-image // metadata agree with the immutable object bytes stored for the project. -func ValidateSourceImageMetadata(doc floorplan.ParseResponse, filename, contentType string, size int64) error { +func ValidateSourceImageMetadata(doc floorplan.ParseResponse, filename, contentType string, size int64, width, height int) error { if doc.Filename != filename { return errors.New("document filename must match source_image filename") } @@ -247,5 +330,8 @@ func ValidateSourceImageMetadata(doc floorplan.ParseResponse, filename, contentT if int64(doc.Size) != size { return errors.New("document size must match source_image size") } + if doc.Result.Metadata.ImageWidth != width || doc.Result.Metadata.ImageHeight != height { + return errors.New("document image dimensions must match source_image dimensions") + } return nil } diff --git a/backend/internal/project/project_test.go b/backend/internal/project/project_test.go index 024b9f4..44b2b88 100644 --- a/backend/internal/project/project_test.go +++ b/backend/internal/project/project_test.go @@ -2,6 +2,7 @@ package project import ( "encoding/json" + "fmt" "strings" "testing" ) @@ -28,11 +29,11 @@ func TestValidateNameTrimsAndBounds(t *testing.T) { } func validDocumentJSON() []byte { - return []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture","image_width":100,"image_height":80}}}`) + return []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`) } func TestNormalizeDocumentPreservesParseResponseAndNormalizesArrays(t *testing.T) { - raw := []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"scale":{"unit":"px"},"metadata":{"source":"fixture"}}}`) + raw := []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`) doc, err := NormalizeDocument(raw) if err != nil { t.Fatalf("NormalizeDocument returned error: %v", err) @@ -46,12 +47,44 @@ func TestNormalizeDocumentPreservesParseResponseAndNormalizesArrays(t *testing.T if doc.Result.Rooms == nil || doc.Result.Walls == nil || doc.Result.Doors == nil || doc.Result.Windows == nil { t.Fatalf("normalize did not allocate empty doors/windows arrays") } + if doc.Result.Scale.PixelToUnit != nil { + t.Fatalf("unknown scale was changed to %#v", doc.Result.Scale.PixelToUnit) + } encoded, err := json.Marshal(doc) - if err != nil || !strings.Contains(string(encoded), `"filename":"plan.png"`) { + if err != nil || !strings.Contains(string(encoded), `"filename":"plan.png"`) || !strings.Contains(string(encoded), `"pixel_to_unit":null`) { t.Fatalf("marshal durable document: %s, %v", encoded, err) } } +func TestNormalizeDocumentRequiresExplicitScaleAndMetadataFields(t *testing.T) { + for name, raw := range map[string]string{ + "missing conversion": `{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`, + "unknown unit is physical": `{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"m","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`, + "missing confidence": `{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","image_width":100,"image_height":80}}}`, + "null dimensions": `{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":null,"image_height":80}}}`, + } { + t.Run(name, func(t *testing.T) { + if _, err := NormalizeDocument([]byte(raw)); err == nil { + t.Fatal("expected strict durable document validation error") + } + }) + } +} + +func TestNormalizeLegacyDocumentAddsOnlyHistoricalUnknownFields(t *testing.T) { + raw := []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"vision"}}}`) + doc, err := NormalizeLegacyDocument(raw, 640, 480) + if err != nil { + t.Fatalf("NormalizeLegacyDocument returned error: %v", err) + } + if doc.Result.Scale.PixelToUnit != nil || doc.Result.Metadata.Confidence != 0 { + t.Fatalf("legacy unknown facts were fabricated: %#v", doc.Result) + } + if doc.Result.Metadata.ImageWidth != 640 || doc.Result.Metadata.ImageHeight != 480 { + t.Fatalf("legacy image dimensions = %dx%d, want verified source dimensions", doc.Result.Metadata.ImageWidth, doc.Result.Metadata.ImageHeight) + } +} + func TestNormalizeDocumentRejectsInvalidJSON(t *testing.T) { if _, err := NormalizeDocument([]byte(`invalid`)); err == nil { t.Fatal("expected invalid json error") @@ -59,14 +92,14 @@ func TestNormalizeDocumentRejectsInvalidJSON(t *testing.T) { } func TestNormalizeDocumentRejectsInvalidBoundsOrdering(t *testing.T) { - raw := []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[{"name":"A","type":"room","approximate_bounds":{"x1":10,"y1":0,"x2":1,"y2":1}}],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture"}}}`) + raw := []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[{"name":"A","type":"room","approximate_bounds":{"x1":10,"y1":0,"x2":1,"y2":1}}],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`) if _, err := NormalizeDocument(raw); err == nil { t.Fatal("expected invalid bounds error") } } func TestNormalizeDocumentRejectsOutOfRangeNumbers(t *testing.T) { - raw := []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[{"x1":NaN,"y1":0,"x2":1,"y2":1}],"doors":[],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture"}}}`) + raw := []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[{"x1":NaN,"y1":0,"x2":1,"y2":1}],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`) if _, err := NormalizeDocument(raw); err == nil { t.Fatal("expected numeric validation error") } @@ -74,12 +107,12 @@ func TestNormalizeDocumentRejectsOutOfRangeNumbers(t *testing.T) { func TestNormalizeDocumentRejectsInvalidDurableFields(t *testing.T) { tests := map[string]string{ - "missing filename": `{"contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture"}}}`, - "unsupported mime": `{"filename":"a.bmp","contentType":"image/bmp","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture"}}}`, - "zero size": `{"filename":"a.png","contentType":"image/png","size":0,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture"}}}`, - "invalid opening": `{"filename":"a.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[{"x":1e309}],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture"}}}`, - "invalid scale": `{"filename":"a.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":""},"metadata":{"source":"fixture"}}}`, - "invalid metadata": `{"filename":"a.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"","image_width":-1}}}`, + "missing filename": `{"contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`, + "unsupported mime": `{"filename":"a.bmp","contentType":"image/bmp","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`, + "zero size": `{"filename":"a.png","contentType":"image/png","size":0,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`, + "invalid opening": `{"filename":"a.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[{"x":1e309}],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`, + "invalid scale": `{"filename":"a.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":""},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`, + "invalid metadata": `{"filename":"a.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"","image_width":-1}}}`, } for name, raw := range tests { t.Run(name, func(t *testing.T) { @@ -97,30 +130,77 @@ func TestNormalizeDocumentRejectsOversizedJSON(t *testing.T) { } } +func TestNormalizeDocumentRejectsGeometryOutsideSourceImage(t *testing.T) { + raw := []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[{"id":"wall-1","x1":0,"y1":0,"x2":101,"y2":0}],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`) + if _, err := NormalizeDocument(raw); err == nil || !strings.Contains(err.Error(), "image bounds") { + t.Fatalf("NormalizeDocument error = %v, want image bounds rejection", err) + } +} + +func TestNormalizeDocumentUsesCanonicalMinimumWallLength(t *testing.T) { + for name, x2 := range map[string]float64{"below": 0.0009, "equal": 0.001} { + t.Run(name, func(t *testing.T) { + raw := []byte(fmt.Sprintf(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[{"id":"wall-1","x1":0,"y1":0,"x2":%g,"y2":0}],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`, x2)) + if _, err := NormalizeDocument(raw); err == nil { + t.Fatalf("wall length %g should be rejected", x2) + } + }) + } + raw := []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[{"id":"wall-1","x1":0,"y1":0,"x2":0.0011,"y2":0}],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`) + if _, err := NormalizeDocument(raw); err != nil { + t.Fatalf("wall above canonical minimum rejected: %v", err) + } +} + +func TestNormalizeDocumentRejectsIDsTheFrontendCannotReload(t *testing.T) { + for name, raw := range map[string]string{ + "wall whitespace": `{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[{"id":"wall 1","x1":0,"y1":0,"x2":100,"y2":0}],"doors":[],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`, + "opening punctuation": `{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[{"id":"wall-1","x1":0,"y1":0,"x2":100,"y2":0}],"doors":[{"id":"door.1","kind":"door","wallId":"wall-1","position":0.5,"width":20}],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`, + } { + t.Run(name, func(t *testing.T) { + if _, err := NormalizeDocument([]byte(raw)); err == nil || !strings.Contains(err.Error(), "stable id") { + t.Fatalf("NormalizeDocument error = %v, want stable id rejection", err) + } + }) + } +} + func TestValidateSourceImageMetadata(t *testing.T) { doc, err := NormalizeDocument(validDocumentJSON()) if err != nil { t.Fatalf("NormalizeDocument returned error: %v", err) } - if err := ValidateSourceImageMetadata(doc, "plan.png", "image/png", 12); err != nil { + if err := ValidateSourceImageMetadata(doc, "plan.png", "image/png", 12, 100, 80); err != nil { t.Fatalf("valid source-image metadata rejected: %v", err) } - if err := ValidateSourceImageMetadata(doc, "other.png", "image/png", 12); err == nil { + if err := ValidateSourceImageMetadata(doc, "other.png", "image/png", 12, 100, 80); err == nil { t.Fatal("expected filename mismatch error") } - if err := ValidateSourceImageMetadata(doc, "plan.png", "image/jpeg", 12); err == nil { + if err := ValidateSourceImageMetadata(doc, "plan.png", "image/jpeg", 12, 100, 80); err == nil { t.Fatal("expected content type mismatch error") } - if err := ValidateSourceImageMetadata(doc, "plan.png", "image/png", 13); err == nil { + if err := ValidateSourceImageMetadata(doc, "plan.png", "image/png", 13, 100, 80); err == nil { t.Fatal("expected size mismatch error") } + if err := ValidateSourceImageMetadata(doc, "plan.png", "image/png", 12, 99, 80); err == nil { + t.Fatal("expected width mismatch error") + } + if err := ValidateSourceImageMetadata(doc, "plan.png", "image/png", 12, 100, 79); err == nil { + t.Fatal("expected height mismatch error") + } } func TestNormalizeDocumentBindsStableOpeningAndRejectsOverlap(t *testing.T) { - raw := []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[{"id":"wall-a","x1":0,"y1":0,"x2":100,"y2":0}],"doors":[{"id":"door-a","kind":"door","wallId":"wall-a","position":0.5,"width":20,"confirmed":false}],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture"}}}`) + raw := []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[{"id":"wall-a","x1":0,"y1":0,"x2":100,"y2":0}],"doors":[{"id":"door-a","kind":"door","wallId":"wall-a","position":0.5,"width":20,"confirmed":false}],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`) doc, err := NormalizeDocument(raw) - if err != nil { t.Fatalf("valid local opening rejected: %v", err) } - if doc.Result.Doors[0].WallID != "wall-a" || doc.Result.Doors[0].Position != 0.5 { t.Fatalf("opening was not preserved: %#v", doc.Result.Doors[0]) } - raw = []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[{"id":"wall-a","x1":0,"y1":0,"x2":100,"y2":0}],"doors":[{"id":"door-a","kind":"door","wallId":"wall-a","position":0.5,"width":40},{"id":"door-b","kind":"door","wallId":"wall-a","position":0.6,"width":40}],"windows":[],"scale":{"unit":"px"},"metadata":{"source":"fixture"}}}`) - if _, err := NormalizeDocument(raw); err == nil { t.Fatal("expected overlap to fail closed") } + if err != nil { + t.Fatalf("valid local opening rejected: %v", err) + } + if doc.Result.Doors[0].WallID != "wall-a" || doc.Result.Doors[0].Position != 0.5 { + t.Fatalf("opening was not preserved: %#v", doc.Result.Doors[0]) + } + raw = []byte(`{"filename":"plan.png","contentType":"image/png","size":12,"result":{"rooms":[],"walls":[{"id":"wall-a","x1":0,"y1":0,"x2":100,"y2":0}],"doors":[{"id":"door-a","kind":"door","wallId":"wall-a","position":0.5,"width":40},{"id":"door-b","kind":"door","wallId":"wall-a","position":0.6,"width":40}],"windows":[],"scale":{"unit":"px","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}`) + if _, err := NormalizeDocument(raw); err == nil { + t.Fatal("expected overlap to fail closed") + } } diff --git a/docs/issue-19-design-parity.md b/docs/issue-19-design-parity.md new file mode 100644 index 0000000..2890c87 --- /dev/null +++ b/docs/issue-19-design-parity.md @@ -0,0 +1,33 @@ +# Issue #19 Penpot design–implementation parity + +Authoritative source: File `7622c4ac-2f6b-802b-8008-5f15321d47e8`, Page `7622c4ac-2f6b-802b-8008-5f15321d47e9`, version **HomeVox · 主流程 · 自动判断与裁切 · 草稿 04 · 视觉验收** (July 25, 2026). Its eight live 1440 × 960 boards are the source of truth; the deprecated file `7622c4ac-2f6b-802b-8008-5dd03ffef589` was not used. + +Shared live facts: a 232px `#182033` rail; a 72px white top bar; Inter typography (20/700 title, 12/400 subtitle, 14/600 actions); `#f4f6fa` app background; 16px cards; 10px controls; violet `#5b5ce2`; success `#1f9d70`; and amber `#d98b21` for values that still need confirmation. `productDesign.ts`, `productPresentation.ts`, and the production browser geometry assertions are regression contracts. + +| Live Penpot board | Penpot fact | Current implementation / deliberate difference | Acceptance | +| --- | --- | --- | --- | +| 01 导入真实户型图 | Board `d862747a-c9b2-80e7-8008-5f1b098c88cd`: x=268/996 cards (700/396), y=112, h=780; source image x=318 y=188 600×560; one primary and one reselect control. | Exact card/image geometry is browser-gated. Selecting a file starts candidate analysis after original dimensions are available. | Fixed 1440×960 DOM boxes, no overflow, one reselect entry, real browser upload. | +| 02 自动判断图纸 | Board `2c96291e-0756-80ac-8008-61bf3d242ff8`: real source-pixel candidates and an explicit recommended selection. | `single` has exactly one candidate and auto-continues; `composite` has at least two non-duplicate candidates; `uncertain` has none. Transport/schema/content failure falls back to full-image manual crop. | Production verifies single auto-crop, full-image failure fallback, replacement-file request cancellation, and stale-result rejection. | +| 03 选择并裁切户型 | Board `2c96291e-0756-80ac-8008-61bf3d32d436`: original image, mask, crop frame, eight handles, candidates, reset controls and recovery copy. | Crop geometry remains in original pixels; the browser sends only the confirmed effective crop to parse. | Production verifies composite candidate 2 selection, keyboard adjustment, parse failure retention, and retry. | +| 04 AI 识别中 | Recognition is a distinct, honest in-progress state. | Parse only receives the effective source; its returned metadata dimensions must exactly match the effective image pixels. | Failure cannot unlock editing; re-crop invalidates older canonical 2D/3D state before retry. | +| 05 校正可编辑 2D | Board `d862747a-c9b2-80e7-8008-5f1b09a9f669`: x=256 y=92 870×800 editor, x=1146 246×800 inspector. | The exact effective crop is the source image beneath canonical coordinates. | Selection, drag, opening edit, undo/redo, invalid-geometry close and export remain production-tested. | +| 06 生成可编辑 3D | Board `d862747a-c9b2-80e7-8008-5f1b09ae9891`: x=268/1108 cards (812/284), y=112, h=780. | Completion is shown only after the real visible canvas is current. | Production verifies visible wall/floor/opening pixels and renderer admission. | +| 07 2D / 3D 联动编辑 | Board `d862747a-c9b2-80e7-8008-5f1b09b35e19`: x=256 y=92 1136×800 shell. | A third inspector column keeps real edit/export controls reachable. | Production checks linked selection/edit/undo/redo and stale-export rejection. | +| 08 保存为 HomeVox 项目 | Board `d862747a-c9b2-80e7-8008-5f1b09b852b0`: completion hierarchy and save/continue state. | Create/update/list/reload persist the canonical document and matching effective source only. | Production saves, restarts the Go process and reloads the same canonical geometry. | + +## Diagnostic and unknown-content policy + +Internal identities and rendering telemetry are absent from the ordinary DOM and accessibility tree. Actual invalid-geometry and unavailable-3D states remain fail-closed with actionable customer messages. Values that have not been measured or recognized are presented as **待确认**, **尚未测量**, or **尚未识别**; no value is invented. + +## Exact-head evidence command + +Run `npm --prefix frontend run test:e2e` on the PR exact head. The production Playwright suite performs controlled upload → candidate analysis → browser crop → OpenAI-compatible parse → canonical 2D → visible 3D → linked edit/Undo/Redo → effective-source project save → exact-PID restart → reload. It also covers composite/manual crop, analysis failure fallback, replacement request cancellation, parse failure retention, metadata/image dimension equality, invalid canonical identity, unavailable WASM, and stale-export rejection. +# Source-image persistence boundary + +The original selected image is transient UI state used only to re-crop or retry +candidate selection. Once a crop is confirmed (including automatic single +candidate selection), the cropped **effective source** is the only image sent +to parse, shown beneath canonical 2D coordinates, and uploaded when creating a +project. Its filename, content type, and byte size must match the durable parse +document. The original image is never substituted into this persistence +contract. diff --git a/frontend/e2e/production.spec.ts b/frontend/e2e/production.spec.ts index 3e93ad7..b17c041 100644 --- a/frontend/e2e/production.spec.ts +++ b/frontend/e2e/production.spec.ts @@ -1,225 +1,977 @@ -import { Buffer } from 'node:buffer' -import { expect, test } from '@playwright/test' +import { expect, test, type Page, type TestInfo } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { inflateSync } from 'node:zlib' +import { createHash } from 'node:crypto' const baseURL = process.env.HOMEVOX_E2E_BASE_URL ?? 'http://127.0.0.1:18088' const restartURL = process.env.HOMEVOX_E2E_RESTART_URL - +const fixturePNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAlgAAAG4CAIAAAAWqA6UAAAJQklEQVR4nO3VsY1kVRRF0cmCCMgBEQfhkBoh4Y8QBm4ZR8OMxOdWaS9pmW20Xv1z95evf/0NAFlfzv8DADgkhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkCSEAaUIIQJoQApAmhACkPRvCn37+jX91/hG8p/PfBXgrz10bIbx3npz3dP67AG/luWsjhPdeX+z3X/98dV6jQ+e/C/BWnrs2Qnjv9cWE0McDTM9dGyG89/piQujjAabnro0Q3nt9MSH08QDTc9dGCO+9vpgQ+niA6blrI4T3Xl9MCH08wPTctRHCe68vJoQ+HmB67tqchfD8zr4nIYwwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQIawwDZiEECGsMA2YhBAhrDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYQK04BJCKHCNGASQqgwDZiEECpMAyYhhArTgEkIocI0YBJCqDANmIQQKkwDJiGECtOASQihwjRgEkKoMA2YhBAqTAMmIYSKb0wDmJ7boxDCgfObAh/nuT0KIRw4vynwcZ7boxDCgfObAh/nuT0KIRw4vynwcZ7boxDCgfObAh/nuT0KIRw4vynwcZ7boxDCgfObAh/nuT0KIRw4vynwcZ7boxAC8C6EEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgB4P8mhACkCSEAaUIIQJoQAnDmlz++vvpP/vJHCSEAZ4QQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBCBNCAFIE0IA0oQQgDQhBIBjQghAmhACkCaEAKQJIQDv4qQaQgjAuxBCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANKEEIA0IQQgTQgBSBNCANJaIQSA7yeEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkPapIQSANyeEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKQJIQBpQghAmhACkCaEAKT9A4N4s+U669UwAAAAAElFTkSuQmCC', + 'base64', +) test.use({ baseURL, viewport: { width: 1440, height: 960 } }) -test('loads production Rust/WASM mesh, persists a fixture project, keeps the latest drag generation, exports PNG, and rebuilds from storage', async ({ page }) => { - const wasmResponses: string[] = [] - page.on('response', (response) => { - if (response.url().endsWith('.wasm')) wasmResponses.push(response.headers()['content-type'] ?? '') - }) +type E2EState = { + geometry: { positionCount: number; normalCount: number; finite: boolean; fingerprint: number; meshVertexCountByWall: Record } + wasm: { state: string; fallback: string | null } + threeD: { + canonicalRevision: string | null + geometryRevision: string | null + rendererRevision: string | null + frameRevision: string | null + currentFrame: boolean + } + selectedWallId: string | null + selectedOpeningId: string | null + walls: Array<{ id: string | null; x1: number; y1: number; x2: number; y2: number }> + openings: Array<{ id: string | null; wallId: string | null; position: number | null; width: number | null }> +} - await page.goto('/?e2e=wall-fixture') - await expect(page.getByTestId('wasm-engine-state')).toHaveText(/active/) - await expect(page.getByTestId('wasm-grid')).toHaveText(/17×17×17/) +type ParseFixture = { + filename: string + contentType: string + size: number + result: { + rooms: unknown[] + walls: Array<{ id: string; x1: number; y1: number; x2: number; y2: number }> + doors: Array<{ id: string; kind: 'door'; wallId: string; position: number; width: number; source: string; confirmed: boolean }> + windows: Array<{ id: string; kind: 'window'; wallId: string; position: number; width: number; source: string; confirmed: boolean }> + scale: { unit: string; pixel_to_unit: number | null } + metadata: { source: string; confidence: number; image_width: number; image_height: number } + } +} - const initial = await page.evaluate(() => window.__homevoxE2E) - expect(initial?.wasmCalls).toBeGreaterThan(0) - expect(initial?.geometry.positionCount).toBeGreaterThan(0) - expect(initial?.geometry.normalCount).toBeGreaterThan(0) - expect(initial?.geometry.finite).toBe(true) - expect(initial?.metrics.triangleCount).toBeGreaterThan(0) - expect(initial?.metrics.vertexCount).toBeGreaterThan(0) - expect(initial?.metrics.inputBytes).toBe(17 ** 3 * 4) - expect(initial?.metrics.outputBytes).toBeGreaterThan(0) +const canonicalFixture: ParseFixture = { + filename: 'controlled-production-floorplan.png', contentType: 'image/png', size: fixturePNG.length, + result: { + rooms: [], + walls: [ + { id: 'wall-1', x1: 80, y1: 80, x2: 520, y2: 80 }, + { id: 'wall-2', x1: 520, y1: 80, x2: 520, y2: 360 }, + { id: 'wall-3', x1: 520, y1: 360, x2: 80, y2: 360 }, + { id: 'wall-4', x1: 80, y1: 360, x2: 80, y2: 80 }, + ], + doors: [{ id: 'door-1', kind: 'door', wallId: 'wall-1', position: 0.5, width: 72, source: 'test-route', confirmed: false }], + windows: [{ id: 'window-1', kind: 'window', wallId: 'wall-2', position: 0.5, width: 64, source: 'test-route', confirmed: false }], + scale: { unit: 'px', pixel_to_unit: null }, metadata: { source: 'test-route', confidence: 1, image_width: 600, image_height: 440 }, + }, +} - await page.getByRole('button', { name: '创建项目' }).click() - await expect(page.getByText('项目已创建')).toBeVisible() - const persistedProjectID = await page.evaluate(() => window.__homevoxE2E?.currentProjectId) - expect(persistedProjectID).toMatch(/^[0-9a-f-]{36}$/i) +async function screenshot(page: Page, testInfo: TestInfo, name: string): Promise { + const path = testInfo.outputPath(name) + await page.screenshot({ path }) + const image = await readFile(path) + expect(image.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))).toBe(true) + expect(image.readUInt32BE(16)).toBe(1440) + expect(image.readUInt32BE(20)).toBe(960) + return path +} + +async function expectBox(page: Page, selector: string, expected: { x: number; y: number; width: number; height: number }): Promise { + const box = await page.locator(selector).boundingBox() + expect(box, `${selector} missing`).not.toBeNull() + if (!box) throw new Error(`${selector} missing`) + expect(Math.round(box.x), `${selector} x`).toBe(expected.x) + expect(Math.round(box.y), `${selector} y`).toBe(expected.y) + expect(Math.round(box.width), `${selector} width`).toBe(expected.width) + expect(Math.round(box.height), `${selector} height`).toBe(expected.height) +} + +async function expectCustomerFacingCopy(page: Page): Promise { + const text = await page.locator('body').innerText() + expect(text).not.toMatch(/\b(?:unknown|canonical|wasm|renderer|revision|fallback|review|gate|json|raw|door|window|undo|redo|calls|timing)\b|未持久化|内部 ID/i) +} + +/** Decode a Playwright PNG screenshot to assert what a person can see, rather + * than treating canvas existence or an instrumented geometry object as proof. */ +type PixelImage = { width: number; height: number; bytesPerPixel: number; pixels: Buffer } + +function decodePngPixels(png: Buffer): PixelImage { + let offset = 8 + let width = 0 + let height = 0 + let colorType = -1 + const chunks: Buffer[] = [] + while (offset < png.length) { + const length = png.readUInt32BE(offset) + const type = png.subarray(offset + 4, offset + 8).toString('ascii') + const data = png.subarray(offset + 8, offset + 8 + length) + if (type === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4); colorType = data[9] } + if (type === 'IDAT') chunks.push(data) + offset += length + 12 + } + expect([2, 6]).toContain(colorType) // RGB/RGBA 8-bit screenshots from Chromium. + const bytesPerPixel = colorType === 6 ? 4 : 3 + const stride = width * bytesPerPixel + const compressed = inflateSync(Buffer.concat(chunks)) + const pixels = Buffer.alloc(stride * height) + let input = 0 + for (let y = 0; y < height; y += 1) { + const filter = compressed[input++] + for (let x = 0; x < stride; x += 1) { + const raw = compressed[input++] + const left = x >= bytesPerPixel ? pixels[y * stride + x - bytesPerPixel] : 0 + const up = y > 0 ? pixels[(y - 1) * stride + x] : 0 + const upperLeft = y > 0 && x >= bytesPerPixel ? pixels[(y - 1) * stride + x - bytesPerPixel] : 0 + const paeth = () => { const p = left + up - upperLeft; const a = Math.abs(p - left); const b = Math.abs(p - up); const c = Math.abs(p - upperLeft); return a <= b && a <= c ? left : b <= c ? up : upperLeft } + pixels[y * stride + x] = (raw + (filter === 0 ? 0 : filter === 1 ? left : filter === 2 ? up : filter === 3 ? Math.floor((left + up) / 2) : paeth())) & 0xff + } + } + return { width, height, bytesPerPixel, pixels } +} + +type PixelFootprint = { + coverage: number + widthRatio: number + heightRatio: number + count: number +} + +function isWallRgb(red: number, green: number, blue: number): boolean { + // Screenshot evidence is intentionally limited to the bright visible wall + // faces/selected spans. Explicitly exclude section grid #90a4c6 and floor + // #536d9d before applying the review-oriented material thresholds. + if (blue - red >= 28 && green - red >= 5) return false + return (red > 150 && green > 150 && blue > 155) || + (red > 95 && blue > 145 && blue - green > 20) +} + +function wallFootprintFromImage({ width, height, bytesPerPixel, pixels }: PixelImage): PixelFootprint { + const stride = width * bytesPerPixel + let count = 0 + let minX = width + let maxX = -1 + let minY = height + let maxY = -1 + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = y * stride + x * bytesPerPixel + if (!isWallRgb(pixels[index], pixels[index + 1], pixels[index + 2])) continue + count += 1 + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minY = Math.min(minY, y) + maxY = Math.max(maxY, y) + } + } + return { + coverage: count / (width * height), + widthRatio: maxX >= minX ? (maxX - minX + 1) / width : 0, + heightRatio: maxY >= minY ? (maxY - minY + 1) / height : 0, + count, + } +} + +function openingVoidCoverage(image: PixelImage, centerX: number, centerY: number): number { + const outerRadius = 28 + const innerRadius = 11 // excludes the small stable-ID WebGL selection marker. + let samples = 0 + let voidPixels = 0 + for (let y = Math.max(0, Math.floor(centerY - outerRadius)); y <= Math.min(image.height - 1, Math.ceil(centerY + outerRadius)); y += 1) { + for (let x = Math.max(0, Math.floor(centerX - outerRadius)); x <= Math.min(image.width - 1, Math.ceil(centerX + outerRadius)); x += 1) { + const distance = Math.hypot(x - centerX, y - centerY) + if (distance < innerRadius || distance > outerRadius) continue + samples += 1 + const index = (y * image.width + x) * image.bytesPerPixel + if (!isWallRgb(image.pixels[index], image.pixels[index + 1], image.pixels[index + 2])) voidPixels += 1 + } + } + return samples === 0 ? 0 : voidPixels / samples +} + +function wallFootprint(png: Buffer): PixelFootprint { + return wallFootprintFromImage(decodePngPixels(png)) +} + +function changedFootprint(before: Buffer, after: Buffer): PixelFootprint { + const first = decodePngPixels(before) + const second = decodePngPixels(after) + expect(second.width).toBe(first.width) + expect(second.height).toBe(first.height) + expect(second.bytesPerPixel).toBe(first.bytesPerPixel) + let count = 0 + let minX = first.width + let maxX = -1 + let minY = first.height + let maxY = -1 + for (let y = 0; y < first.height; y += 1) { + for (let x = 0; x < first.width; x += 1) { + const index = (y * first.width + x) * first.bytesPerPixel + if ( + Math.abs(first.pixels[index] - second.pixels[index]) <= 8 && + Math.abs(first.pixels[index + 1] - second.pixels[index + 1]) <= 8 && + Math.abs(first.pixels[index + 2] - second.pixels[index + 2]) <= 8 + ) continue + count += 1 + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minY = Math.min(minY, y) + maxY = Math.max(maxY, y) + } + } + return { + coverage: count / (first.width * first.height), + widthRatio: maxX >= minX ? (maxX - minX + 1) / first.width : 0, + heightRatio: maxY >= minY ? (maxY - minY + 1) / first.height : 0, + count, + } +} + +function cropPageToCanvas(pageImage: Buffer, box: { x: number; y: number; width: number; height: number }): PixelImage { + const image = decodePngPixels(pageImage) + const left = Math.max(0, Math.floor(box.x)) + const top = Math.max(0, Math.floor(box.y)) + const right = Math.min(image.width, Math.ceil(box.x + box.width)) + const bottom = Math.min(image.height, Math.ceil(box.y + box.height)) + const width = right - left + const height = bottom - top + const stride = width * image.bytesPerPixel + const pixels = Buffer.alloc(stride * height) + for (let y = 0; y < height; y += 1) { + image.pixels.copy( + pixels, + y * stride, + ((top + y) * image.width + left) * image.bytesPerPixel, + ((top + y) * image.width + right) * image.bytesPerPixel, + ) + } + return { width, height, bytesPerPixel: image.bytesPerPixel, pixels } +} + +function expectReviewableFootprint(footprint: PixelFootprint, source: string): void { + expect(footprint.count, `${source}: no canonical wall pixels`).toBeGreaterThan(1_400) + expect(footprint.coverage, `${source}: wall coverage is too sparse to review`).toBeGreaterThan(0.018) + expect(footprint.widthRatio, `${source}: model is too narrow`).toBeGreaterThan(0.42) + expect(footprint.heightRatio, `${source}: model is too short`).toBeGreaterThan(0.34) +} + +async function waitForCurrentFrame(page: Page): Promise { + await page.waitForFunction(() => window.__homevoxE2E?.threeD?.currentFrame === true, undefined, { timeout: 5_000 }) +} + +async function assertReviewableThreeD(page: Page, testInfo: TestInfo, name: string, minimumWidth: number): Promise { + const canvas = page.getByTestId('three-render-surface') + await waitForCurrentFrame(page) + const box = await canvas.boundingBox() + expect(box).not.toBeNull() + if (!box) throw new Error('3D canvas is not laid out') + expect(box.width).toBeGreaterThan(minimumWidth) + expect(box.height).toBeGreaterThan(460) + + const canvasPath = testInfo.outputPath(`${name}-canvas.png`) + await expect.poll(async () => { + await canvas.screenshot({ path: canvasPath }) + return wallFootprint(await readFile(canvasPath)).coverage + }).toBeGreaterThan(0.018) + expectReviewableFootprint(wallFootprint(await readFile(canvasPath)), `${name} canvas`) + + const fullPath = await screenshot(page, testInfo, `${name}.png`) + expectReviewableFootprint(wallFootprintFromImage(cropPageToCanvas(await readFile(fullPath), box)), `${name} full page`) + return fullPath +} + +async function assertSelectingOpeningWallKeepsCanvasPixels(page: Page, testInfo: TestInfo): Promise { + const canvas = page.getByTestId('three-render-surface') + const beforePath = testInfo.outputPath('opening-wall-before-selection.png') + const afterPath = testInfo.outputPath('opening-wall-after-selection.png') + await canvas.screenshot({ path: beforePath }) + await page.getByTestId('three-wall-wall-2').click() + await expect(page.getByTestId('three-wall-wall-2')).toHaveAttribute('aria-pressed', 'true') + await canvas.screenshot({ path: afterPath }) + const before = await readFile(beforePath) + const after = await readFile(afterPath) + const changed = changedFootprint(before, after) + // wall-2 owns window-1. Selection must be materially visible along the + // actual wall spans while remaining far below a solid whole-canvas repaint. + expect(changed.coverage).toBeGreaterThan(0.008) + expect(changed.coverage).toBeLessThan(0.18) + expect(changed.widthRatio).toBeGreaterThan(0.2) + expect(changed.heightRatio).toBeGreaterThan(0.34) + expectReviewableFootprint(wallFootprint(after), 'selected wall canvas') + + // window-1's selector is now located at the actual physical opening center. + // Canvas screenshots omit the DOM selector, letting this verify that the + // surrounding WebGL pixels remain a real hole after wall highlighting. + const openingBox = await page.getByTestId('three-opening-button-window-1').boundingBox() + const canvasBox = await canvas.boundingBox() + expect(openingBox).not.toBeNull() + expect(canvasBox).not.toBeNull() + if (!openingBox || !canvasBox) throw new Error('window opening selector is not laid out') + const openingCenterX = openingBox.x + openingBox.width / 2 - canvasBox.x + const openingCenterY = openingBox.y + openingBox.height / 2 - canvasBox.y + expect(openingVoidCoverage(decodePngPixels(after), openingCenterX, openingCenterY), 'window-1 opening was painted over by selection').toBeGreaterThan(0.4) +} + +async function e2eState(page: Page): Promise { + return page.evaluate(() => window.__homevoxE2E as E2EState) +} + +async function selectFile(page: Page): Promise { + await page.locator('input[type="file"]').first().setInputFiles({ + name: 'controlled-production-floorplan.png', mimeType: 'image/png', buffer: fixturePNG, + }) + await expect(page.getByRole('heading', { name: '导入真实户型图', level: 2 })).toBeVisible() + await expect(page.getByLabel('已选择的户型图')).toBeVisible() + await expect(page.getByAltText('上传户型图预览')).toBeVisible() + await expect(page.getByText('controlled-production-floorplan.png')).toBeVisible() +} + +async function parseSelectedFile(page: Page): Promise { + const parse = page.waitForResponse((response) => + response.url().endsWith('/api/floorplans/parse') && response.request().method() === 'POST', + ) + expect((await parse).status()).toBe(200) + await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() +} - const endpoint = page.getByTestId('endpoint-handle-0-start') - const box = await endpoint.boundingBox() +async function uploadAndParse(page: Page): Promise { + const parse = page.waitForResponse((response) => response.url().endsWith('/api/floorplans/parse') && response.request().method() === 'POST') + await selectFile(page) + expect((await parse).status()).toBe(200) + await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() +} + +async function dragEndpoint(page: Page, testID: string, deltaX: number, deltaY: number): Promise { + const handle = page.getByTestId(testID) + const box = await handle.boundingBox() expect(box).not.toBeNull() - const x = (box?.x ?? 0) + (box?.width ?? 0) / 2 - const y = (box?.y ?? 0) + (box?.height ?? 0) / 2 + if (!box) throw new Error(`missing endpoint handle ${testID}`) + const x = box.x + box.width / 2 + const y = box.y + box.height / 2 await page.mouse.move(x, y) await page.mouse.down() - await expect(page.getByText('拖拽中')).toBeVisible() - await page.mouse.move(x + 80, y + 50, { steps: 4 }) - await page.mouse.move(x + 120, y + 70, { steps: 4 }) + await page.mouse.move(x + deltaX, y + deltaY, { steps: 4 }) await page.mouse.up() - await expect.poll(() => page.evaluate(() => window.__homevoxE2E?.generation ?? 0)) - .toBeGreaterThan(initial?.generation ?? 0) - await expect(page.getByTestId('wasm-engine-state')).toHaveText(/active/) - const dragged = await page.evaluate(() => window.__homevoxE2E) - expect(dragged?.geometry.finite).toBe(true) - - await page.getByRole('button', { name: /撤销/ }).click() - await expect.poll(() => page.evaluate(() => window.__homevoxE2E?.generation ?? 0)) - .toBeGreaterThan(dragged?.generation ?? 0) - await expect(page.getByTestId('wasm-engine-state')).toHaveText(/active/) - const undone = await page.evaluate(() => window.__homevoxE2E) - await page.getByRole('button', { name: /重做/ }).click() - await expect.poll(() => page.evaluate(() => window.__homevoxE2E?.generation ?? 0)) - .toBeGreaterThan(undone?.generation ?? 0) - await expect(page.getByTestId('wasm-engine-state')).toHaveText(/active/) - const redone = await page.evaluate(() => window.__homevoxE2E) - expect(redone?.generation).toBeGreaterThan(dragged?.generation ?? 0) - expect(redone?.geometry.finite).toBe(true) - - const download = page.waitForEvent('download') - await page.getByRole('button', { name: '导出3D白模PNG' }).click() - expect((await download).suggestedFilename()).toMatch(/\.png$/) - - await page.goto(`/?e2e=wall-fixture&project=${persistedProjectID}`) - await expect(page.getByText('项目已加载')).toBeVisible() - await expect(page.getByTestId('wasm-engine-state')).toHaveText(/active/) - const reloaded = await page.evaluate(() => window.__homevoxE2E) - expect(reloaded?.currentProjectId).toBe(persistedProjectID) - expect(reloaded?.wasmCalls).toBeGreaterThan(0) - expect(reloaded?.geometry.finite).toBe(true) - expect(wasmResponses.some((contentType) => contentType.includes('application/wasm'))).toBe(true) +} + +test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one production lifecycle', async ({ page, browser }, testInfo) => { + await page.goto('/?e2e=instrument') + await expect(page.getByTestId('product-topbar').getByRole('heading', { name: '导入真实户型图' })).toBeVisible() + await expect(page.getByTestId('product-sidebar')).toHaveCSS('width', '232px') + await expect(page.getByTestId('product-topbar')).toHaveCSS('height', '72px') + await selectFile(page) + await expectBox(page, '.import-workspace > div', { x: 268, y: 112, width: 700, height: 780 }) + await expectBox(page, '.import-workspace > aside', { x: 996, y: 112, width: 396, height: 780 }) + await expectBox(page, '.source-plan-image', { x: 318, y: 188, width: 600, height: 560 }) + await expectCustomerFacingCopy(page) + await expect(page.locator('.product-step[data-completed="true"]')).toHaveCount(1) + await expect(page.getByRole('button', { name: /导入户型图,当前步骤,已完成/ })).toBeVisible() + const captures = [await screenshot(page, testInfo, 'issue-19-import-ai.png')] + await parseSelectedFile(page) + const visionURL = new URL(baseURL); visionURL.port = '18089'; visionURL.pathname = '/e2e/requests' + const visionFacts = await (await page.request.get(visionURL.toString())).json() as Array<{ prompt: string; width: number; height: number; imageDiffers: boolean; cropMatches?: boolean }> + expect(visionFacts).toEqual(expect.arrayContaining([ + expect.objectContaining({ prompt: 'candidate', width: 600, height: 440, imageDiffers: false }), + expect.objectContaining({ prompt: 'parse', width: 560, height: 400, imageDiffers: true, cropMatches: true }), + ])) + await expect(page.getByRole('button', { name: /AI 识别,已完成/ })).toBeVisible() + await page.getByRole('button', { name: /AI 识别,已完成/ }).click() + await expect(page.getByLabel('户型裁切区域')).toHaveAttribute('viewBox', '0 0 600 440') + await page.route('**/api/floorplans/parse', (route) => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'controlled recrop outage' }) })) + await page.getByLabel('户型裁切区域').focus() + await page.keyboard.press('ArrowRight') + await page.getByRole('button', { name: '确认裁切并判断' }).click() + await expect(page.getByRole('alert')).toContainText('识别服务暂时不可用') + await expect(page.getByRole('button', { name: /校正 2D,未解锁/ })).toBeDisabled() + await page.unroute('**/api/floorplans/parse') + const recropRetry = page.waitForResponse((response) => response.url().endsWith('/api/floorplans/parse') && response.request().method() === 'POST') + await page.getByRole('button', { name: '确认裁切并判断' }).click() + expect((await recropRetry).status()).toBe(200) + await page.getByRole('button', { name: /校正 2D/ }).click() + await expect(page.getByRole('button', { name: /校正 2D,当前步骤/ })).toBeVisible() + await expectBox(page, '.two-d-editor-frame', { x: 256, y: 92, width: 870, height: 800 }) + await expectBox(page, '.two-d-product-workspace .inspector-card', { x: 1146, y: 92, width: 246, height: 800 }) + // The editor mounts only after import/recognition. Its hit targets must use + // the actual CSS-pixel canvas size, not the old 0×0 fallback. + const endpointHitTarget = await page.getByTestId('endpoint-handle-0-start').boundingBox() + expect(endpointHitTarget).not.toBeNull() + expect(Math.min(endpointHitTarget?.width ?? 0, endpointHitTarget?.height ?? 0)).toBeGreaterThanOrEqual(14) + await expectCustomerFacingCopy(page) + await page.getByTestId('wall-hit-wall-1').click({ position: { x: 80, y: 1 }, force: true }) + await expect.poll(async () => (await e2eState(page)).selectedWallId).toBe('wall-1') + await expect(page.getByLabel('起点 X')).toHaveValue('60') + await page.getByLabel('起点 X').fill('90') + await page.getByLabel('起点 Y').fill('90') + await page.getByRole('button', { name: '应用坐标' }).click() + await expect.poll(async () => (await e2eState(page)).walls.find((wall) => wall.id === 'wall-1')).toMatchObject({ x1: 90, y1: 90 }) + + const snapshotCreate = page.waitForResponse((response) => response.url().endsWith('/api/projects') && response.request().method() === 'POST') + await page.getByTestId('save-recognition-snapshot').click() + const createdSnapshotResponse = await snapshotCreate + expect(createdSnapshotResponse.status()).toBe(201) + expect(createdSnapshotResponse.headers()['cache-control']).toBe('no-store') + const createdSnapshot = await createdSnapshotResponse.json() as { id: string; revision: number; capability: string } + expect(createdSnapshot.revision).toBe(1) + expect(createdSnapshot.capability).toMatch(/^[A-Za-z0-9_-]{43}$/) + await expect(page.getByRole('button', { name: '复制编辑链接' })).toBeVisible() + const capabilityHeader = 'x-homevox-project-capability' + const visionFactsBeforeResume = await (await page.request.get(visionURL.toString())).json() as unknown[] + const visionRequestCountBeforeResume = visionFactsBeforeResume.length + const independentContext = await browser.newContext() + const independentPage = await independentContext.newPage() + let failFirstResume = true + await independentPage.route(`**/api/projects/${createdSnapshot.id}`, (route) => { + if (route.request().method() === 'GET' && failFirstResume) { + failFirstResume = false + return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { message: 'controlled resume outage' } }) }) + } + return route.continue() + }) + await independentPage.goto(`/?e2e=instrument#project=${createdSnapshot.id}&cap=${createdSnapshot.capability}`) + await expect(independentPage.getByRole('alert')).toContainText('项目加载失败') + await expect(independentPage.getByRole('button', { name: '重试加载项目' })).toBeVisible() + const independentProjectGet = independentPage.waitForRequest((request) => request.url().endsWith(`/api/projects/${createdSnapshot.id}`) && request.method() === 'GET') + const independentSourceGet = independentPage.waitForRequest((request) => request.url().endsWith(`/api/projects/${createdSnapshot.id}/source-image`) && request.method() === 'GET') + await independentPage.getByRole('button', { name: '重试加载项目' }).click() + await expect(independentPage.getByLabel('2D 墙体编辑器')).toBeVisible() + const independentProjectRequest = await independentProjectGet + const independentSourceRequest = await independentSourceGet + expect(independentProjectRequest.headers()[capabilityHeader] === createdSnapshot.capability).toBe(true) + expect(independentSourceRequest.headers()[capabilityHeader] === createdSnapshot.capability).toBe(true) + expect(new URL(independentPage.url()).hash).toBe('') + const visionFactsAfterResume = await (await independentPage.request.get(visionURL.toString())).json() as unknown[] + expect(visionFactsAfterResume).toHaveLength(visionRequestCountBeforeResume) + await independentContext.close() + const replacementContext = await browser.newContext() + const replacementPage = await replacementContext.newPage() + await replacementPage.route(`**/api/projects/${createdSnapshot.id}`, (route) => route.request().method() === 'GET' + ? route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { message: 'controlled replacement outage' } }) }) + : route.continue()) + await replacementPage.goto(`/?e2e=instrument#project=${createdSnapshot.id}&cap=${createdSnapshot.capability}`) + await expect(replacementPage.getByRole('alert')).toContainText('项目加载失败') + await expect(replacementPage.getByRole('button', { name: '重试加载项目' })).toBeVisible() + await replacementPage.locator('input[type="file"]').first().setInputFiles({ + name: 'replacement-floorplan.png', + mimeType: 'image/png', + buffer: fixturePNG, + }) + await expect(replacementPage.getByRole('button', { name: '重试加载项目' })).toHaveCount(0) + await expect(replacementPage.getByRole('alert')).toHaveCount(0) + let staleReloadRequests = 0 + await replacementPage.route(`**/api/projects/${createdSnapshot.id}`, (route) => { + if (route.request().method() === 'GET') staleReloadRequests += 1 + return route.continue() + }) + await replacementPage.reload() + await replacementPage.waitForTimeout(250) + expect(new URL(replacementPage.url()).hash).toBe('') + expect(staleReloadRequests).toBe(0) + await replacementContext.close() + await page.route(`**/api/projects/${createdSnapshot.id}`, (route) => route.request().method() === 'PUT' + ? (expect(route.request().headers()[capabilityHeader] === createdSnapshot.capability).toBe(true), route.fulfill({ status: 409, contentType: 'application/json', body: JSON.stringify({ error: { code: 'revision_conflict', message: 'project has changed' } }) })) + : route.continue()) + await page.getByTestId('save-recognition-snapshot').click() + const conflictAlert = page.getByRole('alert') + await expect(conflictAlert).toContainText('项目已在其他页面更新,请加载最新版本后再保存') + await expect(conflictAlert).not.toContainText(createdSnapshot.id) + await expect(conflictAlert).not.toContainText(/revision|HTTP 409/i) + await page.unroute(`**/api/projects/${createdSnapshot.id}`) + const reload = page.waitForResponse((response) => response.url().endsWith(`/api/projects/${createdSnapshot.id}`) && response.request().method() === 'GET') + await page.getByRole('button', { name: '加载最新版本' }).click() + const reloadResponse = await reload + expect(reloadResponse.status()).toBe(200) + expect(reloadResponse.request().headers()[capabilityHeader] === createdSnapshot.capability).toBe(true) + await expect(page.getByTestId('wall-hit-wall-1')).toHaveAttribute('x1', '90') + captures.push(await screenshot(page, testInfo, 'issue-19-2d-correction.png')) + + await page.getByTestId('complete-product-step').click() + await expect(page.getByRole('button', { name: /校正 2D,已完成/ })).toBeVisible() + await expect(page.getByLabel('3D 户型预览')).toBeVisible() + await expect(page.locator('canvas')).toBeVisible() + await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() + await expect(page.getByRole('heading', { name: '空间预览已准备好' })).toBeVisible() + await expectBox(page, '.three-generation > div', { x: 268, y: 112, width: 812, height: 780 }) + await expectBox(page, '.three-generation > aside', { x: 1108, y: 112, width: 284, height: 780 }) + await expectCustomerFacingCopy(page) + await expect(page.getByLabel('2D 墙体编辑器')).toHaveCount(0) + captures.push(await assertReviewableThreeD(page, testInfo, 'issue-19-3d-confirm', 700)) + + await page.getByRole('button', { name: '完成并打开 3D' }).click() + await expect(page.getByRole('button', { name: /生成 3D,已完成/ })).toBeVisible() + await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() + await expect(page.getByLabel('3D 户型预览')).toBeVisible() + await expectBox(page, '.linked-product-workspace', { x: 256, y: 92, width: 1136, height: 800 }) + await expectCustomerFacingCopy(page) + await assertReviewableThreeD(page, testInfo, 'issue-19-linked-workspace-initial', 500) + await assertSelectingOpeningWallKeepsCanvasPixels(page, testInfo) + await expect.poll(async () => (await e2eState(page)).selectedWallId).toBe('wall-2') + await expect(page.getByTestId('wall-hit-wall-2')).toHaveAttribute('data-selected', 'true') + await expect(page.getByTestId('three-wall-wall-2')).toHaveAttribute('aria-pressed', 'true') + await page.getByTestId('wall-hit-wall-3').click({ position: { x: 80, y: 1 }, force: true }) + await expect.poll(async () => (await e2eState(page)).selectedWallId).toBe('wall-3') + await expect(page.getByTestId('three-wall-wall-3')).toHaveAttribute('aria-pressed', 'true') + + await page.getByRole('button', { name: '选择窗洞' }).click() + await expect.poll(async () => (await e2eState(page)).selectedOpeningId).toBe('window-1') + await expect.poll(async () => (await e2eState(page)).selectedWallId).toBe('wall-2') + await expect(page.getByTestId('wall-hit-wall-2')).toHaveAttribute('data-selected', 'true') + await expect(page.getByTestId('opening-width')).toHaveValue('64') + await page.getByTestId('opening-width').fill('60') + await expect(page.getByLabel('导出3D白模PNG')).toBeDisabled() + await waitForCurrentFrame(page) + await expect(page.getByLabel('导出3D白模PNG')).toBeEnabled() + await page.getByRole('button', { name: '撤销' }).click() + await expect(page.getByTestId('opening-width')).toHaveValue('64') + await expect(page.getByLabel('导出3D白模PNG')).toBeDisabled() + await waitForCurrentFrame(page) + await page.getByRole('button', { name: '重做' }).click() + await expect(page.getByTestId('opening-width')).toHaveValue('60') + await expect(page.getByLabel('导出3D白模PNG')).toBeDisabled() + await waitForCurrentFrame(page) + await expect(page.getByLabel('导出3D白模PNG')).toBeEnabled() + + const geometryBeforeEndpointEdit = await e2eState(page) + expect(geometryBeforeEndpointEdit.geometry.finite).toBe(true) + expect(geometryBeforeEndpointEdit.geometry.positionCount).toBeGreaterThan(0) + await dragEndpoint(page, 'endpoint-handle-0-start', 30, 20) + await expect(page.getByLabel('导出3D白模PNG')).toBeDisabled() + await page.waitForFunction((before) => { + const current = window.__homevoxE2E + return Boolean(current?.geometry.finite && current.geometry.fingerprint !== before) + }, geometryBeforeEndpointEdit.geometry.fingerprint) + await waitForCurrentFrame(page) + await expect(page.getByLabel('导出3D白模PNG')).toBeEnabled() + const geometryAfterEndpointEdit = await e2eState(page) + const editedWall = geometryAfterEndpointEdit.walls.find((wall) => wall.id === 'wall-1') + expect(editedWall).toBeDefined() + expect(editedWall).not.toEqual({ id: 'wall-1', x1: 80, y1: 80, x2: 520, y2: 80 }) + await page.getByRole('button', { name: '撤销' }).click() + await expect(page.getByLabel('导出3D白模PNG')).toBeDisabled() + await page.waitForFunction((before) => window.__homevoxE2E?.geometry.fingerprint === before, geometryBeforeEndpointEdit.geometry.fingerprint) + await waitForCurrentFrame(page) + await page.getByRole('button', { name: '重做' }).click() + await expect(page.getByLabel('导出3D白模PNG')).toBeDisabled() + await page.waitForFunction((after) => window.__homevoxE2E?.geometry.fingerprint === after, geometryAfterEndpointEdit.geometry.fingerprint) + await waitForCurrentFrame(page) + await expect(page.getByRole('button', { name: /2D\/3D 联动,当前步骤/ })).toBeVisible() + captures.push(await assertReviewableThreeD(page, testInfo, 'issue-19-linked-workspace', 500)) + await page.getByTestId('complete-product-step').click() + await expect(page.getByRole('button', { name: /2D\/3D 联动,已完成/ })).toBeVisible() + await expect(page.getByRole('button', { name: /保存项目,当前步骤/ })).toBeVisible() + // A completed linked-review is tied to the exact canonical revision. Editing + // after it must revoke final-save access until the rebuilt 3D view is reviewed. + await page.getByRole('button', { name: /校正 2D,已完成/ }).click() + await page.getByTestId('opening-width').fill('61') + await expect(page.getByRole('button', { name: /保存项目,未解锁/ })).toBeDisabled() + await page.getByTestId('complete-product-step').click() + await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() + await page.getByRole('button', { name: '完成并打开 3D' }).click() + await waitForCurrentFrame(page) + await page.getByTestId('complete-product-step').click() + await expect(page.getByRole('button', { name: /保存项目,当前步骤/ })).toBeVisible() + const hashes = await Promise.all(captures.map(async (path) => createHash('sha256').update(await readFile(path)).digest('hex'))) + expect(new Set(hashes).size).toBe(4) + + await page.getByLabel('项目名称').fill('Production lifecycle project') + const save = page.waitForResponse((response) => response.url().endsWith(`/api/projects/${createdSnapshot.id}`) && response.request().method() === 'PUT') + await page.getByRole('button', { name: '保存项目', exact: true }).click() + const saved = await save + expect(saved.status()).toBe(200) + expect(saved.request().headers()[capabilityHeader] === createdSnapshot.capability).toBe(true) + const savedProject = await saved.json() as { id: string; revision: number; document: { result: { walls: Array<{ id: string; x1: number; y1: number; x2: number; y2: number }>; windows: Array<{ id: string; wallId: string; position: number; width: number }> } } } + expect(savedProject.id).toMatch(/^[0-9a-f-]{36}$/i) + expect(savedProject.revision).toBe(2) + await expect(page.getByRole('button', { name: /保存项目.*已完成/ })).toBeVisible() + expect(savedProject.document.result.walls.find((wall) => wall.id === 'wall-1')).toEqual(editedWall) + expect(savedProject.document.result.windows).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'window-1', wallId: 'wall-2', width: 61 })])) + + expect(restartURL).toBeTruthy() + const restarted = await page.request.post(restartURL!) + expect(restarted.status()).toBe(200) + const pids = await restarted.json() as { oldPid: number; newPid: number } + expect(pids.newPid).not.toBe(pids.oldPid) + + const directReload = await page.request.get(`/api/projects/${savedProject.id}`, { + headers: { 'X-HomeVox-Project-Capability': createdSnapshot.capability }, + }) + expect(directReload.status(), await directReload.text()).toBe(200) + const restartedContext = await browser.newContext() + const restartedPage = await restartedContext.newPage() + const restartedProjectGet = restartedPage.waitForResponse((response) => response.url().endsWith(`/api/projects/${savedProject.id}`) && response.request().method() === 'GET') + const restartedSourceGet = restartedPage.waitForResponse((response) => response.url().endsWith(`/api/projects/${savedProject.id}/source-image`) && response.request().method() === 'GET') + await restartedPage.goto(`/?e2e=instrument#project=${savedProject.id}&cap=${createdSnapshot.capability}`) + const restartedProjectResponse = await restartedProjectGet + expect(restartedProjectResponse.status(), await restartedProjectResponse.text()).toBe(200) + const restartedSourceResponse = await restartedSourceGet + expect(restartedSourceResponse.status(), await restartedSourceResponse.text()).toBe(200) + await expect(restartedPage.getByRole('button', { name: /校正 2D,当前步骤/ })).toBeEnabled() + expect(new URL(restartedPage.url()).hash).toBe('') + await expect(restartedPage.getByRole('button', { name: /导入户型图,已完成/ })).toBeVisible() + await expect(restartedPage.getByRole('button', { name: /AI 识别,已完成/ })).toBeVisible() + await expect(restartedPage.getByRole('button', { name: /保存项目,未解锁/ })).toBeDisabled() + await restartedPage.getByRole('button', { name: /校正 2D,当前步骤/ }).click() + await expect(restartedPage.getByLabel('2D 墙体编辑器')).toBeVisible() + await restartedPage.getByTestId('opening-handle-window-1').click({ force: true }) + await expect(restartedPage.getByTestId('opening-width')).toHaveValue('61') + await restartedPage.getByTestId('complete-product-step').click() + await expect(restartedPage.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() + const reloadedGeometry = await e2eState(restartedPage) + expect(reloadedGeometry.geometry.finite).toBe(true) + expect(reloadedGeometry.geometry.fingerprint).toBe(geometryAfterEndpointEdit.geometry.fingerprint) + expect(reloadedGeometry.walls.find((wall) => wall.id === 'wall-1')).toEqual(editedWall) + const accessibility = await restartedPage.locator('body').ariaSnapshot() + expect(accessibility).not.toMatch(/(?:WASM|Grid|triangles|fallback|结构化 JSON)/i) + await expect(restartedPage.locator('pre')).toHaveCount(0) + await restartedContext.close() }) -test('uses the adapter load failure branch to fall back while retaining the wall shell and 2D editor', async ({ page }) => { - await page.goto('/?e2e=wall-fixture&wasm=load-failure') - await expect(page.getByTestId('wasm-engine-state')).toHaveText(/fallback/) - await expect(page.getByTestId('wasm-resource-metrics')).toHaveText(/fallback: load-failed/) - await expect(page.getByTestId('wasm-resource-metrics')).toContainText(/wall-shell [1-9]/) - await expect(page.getByRole('img', { name: '户型图墙体端点编辑区' })).toBeVisible() +test('meshes the short parallel wall through the compiled Rust/WASM path', async ({ page }) => { + const mixedOrientation: ParseFixture = { + ...canonicalFixture, + result: { + ...canonicalFixture.result, + walls: [ + ...canonicalFixture.result.walls, + { id: 'long-interior', x1: 80, y1: 140, x2: 520, y2: 140 }, + { id: 'short-parallel', x1: 240, y1: 260, x2: 360, y2: 260 }, + { id: 'diagonal', x1: 360, y1: 200, x2: 450, y2: 290 }, + ], + doors: [], windows: [], + metadata: canonicalFixture.result.metadata, + }, + } + await page.route('**/api/floorplans/parse', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(mixedOrientation) })) + await page.goto('/?e2e=instrument') + await uploadAndParse(page) + await expect.poll(async () => (await e2eState(page)).wasm.state).toBe('active') + const state = await e2eState(page) + expect(state.geometry.finite).toBe(true) + expect(state.geometry.meshVertexCountByWall['short-parallel']).toBeGreaterThan(0) }) -test('edits wall-local openings atomically and preserves stable opening identity through project reload', async ({ page }) => { - await page.goto('/?e2e=wall-fixture') - await expect(page.getByTestId('wasm-engine-state')).toHaveText(/active/) - - await page.getByTestId('three-opening-button-window-1').click() - await expect.poll(() => page.evaluate(() => window.__homevoxE2E?.selectedOpeningId)).toBe('window-1') - await expect(page.getByTestId('window-preview-disclosure')).toHaveText(/confirmed=false(未知)/) - await expect(page.getByTestId('window-preview-disclosure')).toHaveText(/非持久化默认值/) - - // A 2D selection must highlight the same opening in the live R3F scene. - await page.getByTestId('opening-handle-window-1').click() - await expect(page.getByTestId('three-opening-button-window-1')).toHaveAttribute('aria-pressed', 'true') - - await page.getByTestId('opening-handle-door-1').click() - await expect.poll(() => page.evaluate(() => window.__homevoxE2E?.selectedOpeningId)).toBe('door-1') - await expect(page.getByTestId('door-preview-disclosure')).toHaveText(/confirmed=false(未知)/) - await expect(page.getByTestId('door-preview-disclosure')).toHaveText(/全高门洞仅为非持久化预览假设/) - const beforeOpeningEdit = await page.evaluate(() => window.__homevoxE2E?.generation ?? 0) - await page.getByTestId('opening-width').fill('80') - await expect(page.locator('pre')).toContainText('"width": 80') - await expect.poll(() => page.evaluate(() => window.__homevoxE2E?.generation ?? 0)) - .toBeGreaterThan(beforeOpeningEdit) - expect((await page.evaluate(() => window.__homevoxE2E?.geometry.finite))).toBe(true) - - const editor = page.getByRole('img', { name: '户型图墙体端点编辑区' }) - const editorBox = await editor.boundingBox() - expect(editorBox).not.toBeNull() - const scale = Math.min((editorBox?.width ?? 0) / 600, (editorBox?.height ?? 0) / 440) - await page.mouse.click((editorBox?.x ?? 0) + 150 * scale, (editorBox?.y ?? 0) + 80 * scale) - await page.getByRole('button', { name: '添加门' }).click() - await expect(page.getByTestId('opening-handle-door-manual-1')).toBeVisible() - await page.getByRole('button', { name: '删除' }).click() - await expect(page.getByTestId('opening-handle-door-manual-1')).toHaveCount(0) - await page.getByRole('button', { name: /撤销/ }).click() - await expect(page.getByTestId('opening-handle-door-manual-1')).toBeVisible() - await page.getByRole('button', { name: /重做/ }).click() - await expect(page.getByTestId('opening-handle-door-manual-1')).toHaveCount(0) - - const export2D = page.waitForEvent('download') - await page.getByRole('button', { name: '导出2D平面图PNG' }).click() - expect((await export2D).suggestedFilename()).toMatch(/\.png$/) - const export3D = page.waitForEvent('download') - await page.getByRole('button', { name: '导出3D白模PNG' }).click() - expect((await export3D).suggestedFilename()).toMatch(/\.png$/) +test('keeps a selected composite candidate and adjusted crop after parse failure', async ({ page }) => { + const visionURL = new URL(baseURL) + visionURL.port = '18089' + visionURL.pathname = '/e2e/candidate-mode' + const mode = await page.request.post(visionURL.toString(), { data: { mode: 'composite' } }) + expect(mode.status()).toBe(204) + try { + await page.goto('/?e2e=instrument') + await selectFile(page) + await expect(page.getByRole('button', { name: /候选 1/ })).toBeVisible() + await expect(page.getByRole('button', { name: /候选 2/ })).toBeVisible() + await page.getByRole('button', { name: /候选 2/ }).click() + await expect(page.getByTestId('crop-selection')).toHaveAttribute('x', '320') + await page.getByLabel('户型裁切区域').focus() + await page.keyboard.press('ArrowRight') + await expect(page.getByTestId('crop-selection')).toHaveAttribute('x', '321') + await page.route('**/api/floorplans/parse', (route) => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'controlled composite outage' }) })) + await page.getByRole('button', { name: '确认裁切并判断' }).click() + await expect(page.getByRole('alert')).toContainText('识别服务暂时不可用') + await expect(page.getByRole('button', { name: /候选 2/ })).toBeVisible() + await expect(page.getByTestId('crop-selection')).toHaveAttribute('x', '321') + const retainedOriginal = page.getByLabel('户型裁切区域') + await expect(retainedOriginal).toHaveAttribute('viewBox', '0 0 600 440') + await expect(retainedOriginal.locator('image')).toHaveAttribute('href', /^blob:/) + await expect(page.getByRole('button', { name: '确认裁切并判断' })).toBeEnabled() + } finally { + await page.request.post(visionURL.toString(), { data: { mode: 'single' } }) + } +}) - await page.getByRole('button', { name: '创建项目' }).click() - await expect(page.getByText('项目已创建')).toBeVisible() - const projectID = await page.evaluate(() => window.__homevoxE2E?.currentProjectId) - expect(projectID).toMatch(/^[0-9a-f-]{36}$/i) - const persistedOpeningGeometry = await page.evaluate(() => window.__homevoxE2E?.geometry.fingerprint) - await page.goto(`/?e2e=wall-fixture&project=${projectID}`) - await expect(page.getByText('项目已加载')).toBeVisible() - await page.getByTestId('opening-handle-door-1').click() - await expect(page.getByTestId('opening-width')).toHaveValue('80') - await expect.poll(() => page.evaluate(() => window.__homevoxE2E?.geometry.fingerprint)) - .toBe(persistedOpeningGeometry) - await page.getByTestId('opening-handle-door-1').click() - await expect(page.getByTestId('door-preview-disclosure')).toHaveText(/confirmed=false(未知)/) - await expect(page.getByTestId('door-preview-disclosure')).toHaveText(/不会保存为建筑参数/) - await page.getByTestId('opening-handle-window-1').click() - await expect(page.getByTestId('window-preview-disclosure')).toHaveText(/confirmed=false(未知)/) - await expect(page.getByTestId('window-preview-disclosure')).toHaveText(/非持久化默认值/) +test('shows an explicit judging process while a confirmed crop is being parsed', async ({ page }) => { + let releaseParse!: () => void + const parseReleased = new Promise((resolve) => { releaseParse = resolve }) + await page.route('**/api/floorplans/candidates', (route) => route.fulfill({ + status: 422, + contentType: 'application/json', + body: JSON.stringify({ code: 'ai_content_unreliable' }), + })) + await page.route('**/api/floorplans/parse', async (route) => { + await parseReleased + await route.fulfill({ + status: 422, + contentType: 'application/json', + body: JSON.stringify({ code: 'ai_content_unreliable' }), + }) + }) + await page.goto('/?e2e=instrument') + await page.locator('input[type="file"]').first().setInputFiles({ + name: 'controlled-production-floorplan.png', mimeType: 'image/png', buffer: fixturePNG, + }) + await expect(page.getByRole('alert')).toContainText('手动全图裁切') + await expect(page.getByLabel('户型裁切区域')).toBeVisible() + await page.getByRole('button', { name: '确认裁切并判断' }).click() + + await expect(page.getByRole('status')).toContainText('正在判断当前裁切区域') + await expect(page.getByRole('alert')).toHaveCount(0) + await expect(page.getByText('无法可靠判断户型区域')).toHaveCount(0) + await expect(page.getByLabel('户型裁切区域')).toHaveAttribute('aria-disabled', 'true') + await expect(page.getByText('当前裁切区域已锁定,判断完成后可继续调整。')).toBeVisible() + await expect(page.getByTestId('crop-selection')).toHaveCSS('opacity', '0.35') + await expect(page.locator('[data-crop-handle]')).toHaveCount(0) + await expect(page.getByRole('button', { name: '全图裁切' })).toBeDisabled() + const judgingButton = page.getByRole('button', { name: '正在判断…' }) + await expect(judgingButton).toBeDisabled() + await expect(judgingButton).toHaveCSS('background-color', 'rgb(203, 213, 225)') + + releaseParse() + await expect(page.getByRole('alert')).toContainText('当前裁切区域') + await expect(page.getByRole('alert')).toContainText('完整、无遮挡') }) -test('runs the real browser image-selection to Vision parse to persisted workspace loop', async ({ page }) => { - expect(restartURL, 'production runner must provide its test-only restart checkpoint').toBeTruthy() - await page.goto('/') - await page.locator('input[type="file"]').setInputFiles({ - name: 'controlled-plan.png', - mimeType: 'image/png', - buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAYAAAC56t6BAAAAF0lEQVR4nGL6////fwZkwARjAAIAAP//YgEEAT/f/TcAAAAASUVORK5CYII=', 'base64'), +test('does not describe an automatic single crop as user-confirmed after parse failure', async ({ page }) => { + await page.route('**/api/floorplans/parse', (route) => route.fulfill({ + status: 422, + contentType: 'application/json', + body: JSON.stringify({ code: 'ai_content_unreliable' }), + })) + await page.goto('/?e2e=instrument') + await page.locator('input[type="file"]').first().setInputFiles({ + name: 'controlled-production-floorplan.png', mimeType: 'image/png', buffer: fixturePNG, }) - await expect(page.getByAltText('上传户型图预览')).toBeVisible() - await page.getByRole('button', { name: '上传并解析' }).click() - await expect(page.getByText('解析完成')).toBeVisible() - await expect(page.getByRole('img', { name: '户型图墙体端点编辑区' })).toBeVisible() - await page.getByLabel('项目名称').fill('controlled Vision loop') - await page.getByRole('button', { name: '创建项目' }).click() - await expect(page.getByText('项目已创建')).toBeVisible() - const projects = await page.request.get('/api/projects') - expect(projects.ok()).toBe(true) - const listed = await projects.json() as Array<{ id: string; name: string }> - const projectID = listed.find((project) => project.name === 'controlled Vision loop')?.id - expect(projectID).toMatch(/^[0-9a-f-]{36}$/i) - const detailBefore = await page.request.get(`/api/projects/${projectID}`) - expect(detailBefore.ok()).toBe(true) - const beforeDocument = (await detailBefore.json() as { document: { result: { walls: unknown; doors: unknown; windows: unknown } } }).document - const image = await page.request.get(`/api/projects/${projectID}/source-image`) - expect(image.ok()).toBe(true) - expect(image.headers()['content-type']).toContain('image/png') - const imageBytes = await image.body() - const beforeFingerprint = await page.evaluate(() => window.__homevoxE2E?.geometry.fingerprint) - const restart = await page.request.post(restartURL!) - expect(restart.ok()).toBe(true) - const restartResult = await restart.json() as { oldPid: number; newPid: number } - expect(restartResult.oldPid).toBeGreaterThan(0) - expect(restartResult.newPid).toBeGreaterThan(0) - expect(restartResult.newPid).not.toBe(restartResult.oldPid) - await expect.poll(async () => { - try { - const health = await page.request.get('/api/health') - const status = health.status() - await health.dispose() - return status - } catch { - return 0 + + await expect(page.getByRole('alert')).toContainText('裁切到单个户型') + await expect(page.getByRole('alert')).not.toContainText('当前裁切区域') + await expect(page.getByLabel('户型裁切区域')).toBeVisible() +}) + +test('falls back to an adjustable full-image crop when candidate analysis fails', async ({ page }) => { + await page.route('**/api/floorplans/candidates', (route) => route.fulfill({ status: 502, contentType: 'application/json', body: JSON.stringify({ code: 'ai_transport_unavailable', error: 'controlled analysis outage' }) })) + await page.goto('/?e2e=instrument') + await page.locator('input[type="file"]').first().setInputFiles({ name: 'controlled-production-floorplan.png', mimeType: 'image/png', buffer: fixturePNG }) + await expect(page.getByRole('alert')).toContainText('手动全图裁切') + await expect(page.getByRole('button', { name: /候选/ })).toHaveCount(0) + await expect(page.getByTestId('crop-selection')).toHaveAttribute('x', '0') + await expect(page.getByTestId('crop-selection')).toHaveAttribute('y', '0') + await expect(page.getByTestId('crop-selection')).toHaveAttribute('width', '600') + await expect(page.getByTestId('crop-selection')).toHaveAttribute('height', '440') + await expect(page.getByRole('button', { name: '确认裁切并判断' })).toBeEnabled() +}) + +test('does not publish stale candidate analysis after a replacement file is selected', async ({ page }) => { + let firstRequestStarted!: () => void + let releaseFirst!: () => void + const started = new Promise((resolve) => { firstRequestStarted = resolve }) + const release = new Promise((resolve) => { releaseFirst = resolve }) + let requests = 0 + await page.route('**/api/floorplans/candidates', async (route) => { + requests += 1 + if (requests === 1) { + firstRequestStarted() + await release + try { + await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ mode: 'composite', candidates: [{ x: 20, y: 20, width: 260, height: 400 }, { x: 320, y: 20, width: 260, height: 400 }] }) }) + } catch { + // The first browser request is expected to be aborted by replacement. + } + return } - }).toBe(200) - await page.goto(`/?project=${projectID}`) - await expect(page.getByText('项目已加载')).toBeVisible() - await expect(page.getByTestId('opening-handle-door-1')).toBeVisible() - await expect(page.getByTestId('opening-handle-window-1')).toBeVisible() - const detailAfter = await page.request.get(`/api/projects/${projectID}`) - expect(detailAfter.ok()).toBe(true) - const afterDocument = (await detailAfter.json() as { document: { result: { walls: unknown; doors: unknown; windows: unknown } } }).document - expect(afterDocument).toEqual(beforeDocument) - expect(afterDocument.result.walls).toEqual(beforeDocument.result.walls) - expect(afterDocument.result.doors).toEqual(beforeDocument.result.doors) - expect(afterDocument.result.windows).toEqual(beforeDocument.result.windows) - const imageAfter = await page.request.get(`/api/projects/${projectID}/source-image`) - expect(imageAfter.ok()).toBe(true) - expect(imageAfter.headers()['content-type']).toContain('image/png') - expect(await imageAfter.body()).toEqual(imageBytes) - await expect.poll(() => page.evaluate(() => window.__homevoxE2E?.geometry.fingerprint)).toBe(beforeFingerprint) + await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ mode: 'uncertain', candidates: [] }) }) + }) + await page.goto('/?e2e=instrument') + await page.locator('input[type="file"]').first().setInputFiles({ name: 'first.png', mimeType: 'image/png', buffer: fixturePNG }) + await started + await page.locator('input[type="file"]').first().setInputFiles({ name: 'replacement.png', mimeType: 'image/png', buffer: fixturePNG }) + await expect(page.getByTestId('crop-selection')).toHaveAttribute('width', '600') + releaseFirst() + await page.waitForTimeout(100) + await expect(page.getByRole('button', { name: /候选/ })).toHaveCount(0) + await expect(page.getByTestId('crop-selection')).toHaveAttribute('x', '0') + await expect(page.getByTestId('crop-selection')).toHaveAttribute('width', '600') }) -for (const [fixture, validationError] of [ - ['invalid-opening', /missing or degenerate wall/], - ['duplicate-wall-id', /wall id must be unique/], -] as const) { - test(`fails closed before voxel or WASM geometry for ${fixture} documents`, async ({ page }) => { - await page.goto(`/?e2e=${fixture}`) - await expect(page.getByTestId('geometry-validation-error')).toContainText(validationError) - await expect(page.getByTestId('wasm-engine-state')).toHaveText(/fallback/) - await expect(page.getByTestId('wasm-resource-metrics')).toContainText(/fallback: invalid-input/) - const state = await page.evaluate(() => window.__homevoxE2E) - expect(state?.wasmCalls).toBe(0) - expect(state?.geometry.positionCount).toBe(0) +test('keeps invalid and duplicate canonical identity failures closed', async ({ page }) => { + const invalid: ParseFixture = { ...canonicalFixture, result: { ...canonicalFixture.result, doors: [{ id: 'door-invalid', kind: 'door', wallId: 'missing-wall', position: 0.5, width: 72, source: 'test-route', confirmed: false }], windows: [] } } + await page.route('**/api/floorplans/parse', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(invalid) })) + await page.goto('/?e2e=instrument') + await uploadAndParse(page) + await expect(page.getByText('这个户型有一处需要调整', { exact: false })).toBeVisible() + await expect(page.getByTestId('complete-product-step')).toBeDisabled() + await expect(page.getByLabel('3D 户型预览')).toHaveCount(0) + await expect(page.getByRole('button', { name: '完成并打开 3D' })).toHaveCount(0) + await expect(page.getByRole('button', { name: '2D/3D 联动' })).toBeDisabled() + await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() + await page.unroute('**/api/floorplans/parse') + + const duplicate: ParseFixture = { ...canonicalFixture, result: { ...canonicalFixture.result, walls: [{ id: 'wall-duplicate', x1: 80, y1: 80, x2: 520, y2: 80 }, { id: 'wall-duplicate', x1: 520, y1: 80, x2: 520, y2: 360 }], doors: [{ id: 'door-duplicate', kind: 'door', wallId: 'wall-duplicate', position: 0.5, width: 72, source: 'test-route', confirmed: false }], windows: [] } } + await page.route('**/api/floorplans/parse', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(duplicate) })) + await page.goto('/?e2e=instrument') + await uploadAndParse(page) + await expect(page.getByText('这个户型有一处需要调整', { exact: false })).toBeVisible() + await expect(page.getByTestId('complete-product-step')).toBeDisabled() + await expect(page.getByLabel('3D 户型预览')).toHaveCount(0) + await expect(page.getByRole('button', { name: '完成并打开 3D' })).toHaveCount(0) + await expect(page.getByRole('button', { name: '2D/3D 联动' })).toBeDisabled() +}) + +test('rejects a 2D endpoint drag that would create a zero-length wall', async ({ page }) => { + const withoutOpenings: ParseFixture = { ...canonicalFixture, result: { ...canonicalFixture.result, doors: [], windows: [] } } + await page.route('**/api/floorplans/parse', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(withoutOpenings) })) + await page.goto('/?e2e=instrument') + await uploadAndParse(page) + const start = await page.getByTestId('endpoint-handle-0-start').boundingBox() + const end = await page.getByTestId('endpoint-handle-0-end').boundingBox() + expect(start).not.toBeNull() + expect(end).not.toBeNull() + if (!start || !end) throw new Error('missing wall endpoint handles') + await dragEndpoint(page, 'endpoint-handle-0-start', end.x - start.x, end.y - start.y) + const afterDrag = await e2eState(page) + expect(afterDrag.walls.find((wall) => wall.id === 'wall-1')).toEqual({ id: 'wall-1', x1: 80, y1: 80, x2: 520, y2: 80 }) + await expect(page.getByRole('alert')).toContainText('暂时无法这样调整') + await expect(page.getByTestId('complete-product-step')).toBeEnabled() + await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() +}) + +test('keeps an unavailable Rust/WASM geometry result fail-closed', async ({ page }) => { + await page.goto('/?e2e=instrument&wasm=load-failure') + await uploadAndParse(page) + await page.getByTestId('complete-product-step').click() + await expect(page.getByRole('alert')).toContainText('当前无法显示空间预览') + await expect(page.getByLabel('3D 户型预览')).toHaveCount(0) + await expect(page.getByRole('button', { name: '完成并打开 3D' })).toHaveCount(0) + await expect(page.getByRole('button', { name: '2D/3D 联动' })).toBeDisabled() + await page.getByRole('button', { name: '返回 2D 校正' }).first().click() + await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() +}) + +test('makes parse retry and persistence-unavailable states actionable', async ({ page }) => { + await page.goto('/?e2e=instrument') + await page.route('**/api/floorplans/parse', (route) => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'controlled parse outage' }) })) + await selectFile(page) + await expect(page.getByRole('alert')).toContainText('识别服务暂时不可用') + await expect(page.getByRole('button', { name: '确认裁切并判断' })).toBeVisible() + await expect(page.getByRole('button', { name: /AI 识别,已完成/ })).toHaveCount(0) + await page.unroute('**/api/floorplans/parse') + const retry = page.waitForResponse((response) => response.url().endsWith('/api/floorplans/parse') && response.request().method() === 'POST') + await page.getByRole('button', { name: '确认裁切并判断' }).click() + expect((await retry).status()).toBe(200) + await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() + await page.getByTestId('complete-product-step').click() + await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() + await page.getByRole('button', { name: '完成并打开 3D' }).click() + await page.getByTestId('complete-product-step').click() + await page.route('**/api/projects', (route) => route.request().method() === 'POST' + ? route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { message: 'controlled persistence outage' } }) }) + : route.continue()) + await page.getByRole('button', { name: '保存项目' }).click() + await page.getByLabel('项目名称').fill('Unavailable persistence') + await page.getByRole('button', { name: '创建项目' }).click() + await expect(page.getByRole('alert')).toContainText('项目保存失败') +}) + +test('keeps the narrow-screen workflow keyboard reachable', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/?e2e=instrument') + await uploadAndParse(page) + const correctionStep = page.getByRole('button', { name: '校正 2D' }) + await correctionStep.focus() + await expect(correctionStep).toBeFocused() + await page.keyboard.press('Enter') + await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() + const continueButton = page.getByTestId('complete-product-step') + await continueButton.focus() + await expect(continueButton).toBeFocused() + await page.keyboard.press('Enter') + await expect(page.getByRole('heading', { name: '空间预览已准备好' })).toBeVisible() + await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() +}) + +test('keeps 3D export unavailable in the pure 2D view after an invalid drag is rejected', async ({ page }) => { + const withoutOpenings: ParseFixture = { ...canonicalFixture, result: { ...canonicalFixture.result, doors: [], windows: [] } } + await page.route('**/api/floorplans/parse', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(withoutOpenings) })) + await page.goto('/?e2e=instrument') + await uploadAndParse(page) + await page.getByTestId('complete-product-step').click() + await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() + await page.getByRole('button', { name: '完成并打开 3D' }).click() + await expect(page.getByLabel('导出3D白模PNG')).toBeEnabled() + await page.getByRole('button', { name: /校正 2D/ }).click() + const start = await page.getByTestId('endpoint-handle-0-start').boundingBox() + const end = await page.getByTestId('endpoint-handle-0-end').boundingBox() + expect(start).not.toBeNull() + expect(end).not.toBeNull() + if (!start || !end) throw new Error('missing wall endpoint handles') + await dragEndpoint(page, 'endpoint-handle-0-start', end.x - start.x, end.y - start.y) + await expect(page.getByRole('alert')).toContainText('暂时无法这样调整') + await expect(page.getByLabel('导出3D白模PNG')).toBeDisabled() + let downloads = 0 + page.on('download', () => { downloads += 1 }) + await page.getByLabel('导出3D白模PNG').evaluate((element) => (element as HTMLButtonElement).click()) + expect(downloads).toBe(0) +}) + +test('disables export for a valid canonical edit until the matching WASM, renderer, and visible frame arrive', async ({ page }) => { + await page.goto('/?e2e=instrument') + await uploadAndParse(page) + await page.getByTestId('complete-product-step').click() + await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() + await page.getByRole('button', { name: '完成并打开 3D' }).click() + await waitForCurrentFrame(page) + const exportButton = page.getByLabel('导出3D白模PNG') + await expect(exportButton).toBeEnabled() + const before = await e2eState(page) + + await page.getByRole('button', { name: '选择窗洞' }).click() + await page.getByTestId('opening-width').fill('60') + const during = await e2eState(page) + expect(during.threeD.canonicalRevision).not.toBe(before.threeD.canonicalRevision) + expect(during.threeD.currentFrame).toBe(false) + await expect(exportButton).toBeDisabled() + let downloads = 0 + page.on('download', () => { downloads += 1 }) + await exportButton.evaluate((element) => (element as HTMLButtonElement).click()) + expect(downloads).toBe(0) + + await page.waitForFunction((revision) => { + const current = window.__homevoxE2E + return current?.threeD.canonicalRevision === revision && + current.threeD.geometryRevision === revision && + current.threeD.rendererRevision === revision && + current.threeD.frameRevision === revision && + current.threeD.currentFrame === true + }, during.threeD.canonicalRevision) + await expect(exportButton).toBeEnabled() +}) + +test('drops an in-flight old 3D canvas blob after a legal canonical edit', async ({ page }) => { + await page.goto('/?e2e=instrument') + await uploadAndParse(page) + await page.getByTestId('complete-product-step').click() + await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() + await page.getByRole('button', { name: '完成并打开 3D' }).click() + await waitForCurrentFrame(page) + const exportButton = page.getByLabel('导出3D白模PNG') + await expect(exportButton).toBeEnabled() + + await page.evaluate(() => { + type PendingBlobGate = { started: boolean; release: (() => void) | null } + const target = window as typeof window & { __homevoxPendingBlobGate?: PendingBlobGate } + const surface = document.querySelector('[data-testid="three-render-surface"]') + const canvas = surface instanceof HTMLCanvasElement + ? surface + : surface?.querySelector('canvas') ?? null + if (!canvas) throw new Error('missing 3D canvas') + const originalToBlob = canvas.toBlob.bind(canvas) + const gate: PendingBlobGate = { started: false, release: null } + target.__homevoxPendingBlobGate = gate + canvas.toBlob = (callback, type, quality) => { + gate.started = true + gate.release = () => originalToBlob(callback, type, quality) + } }) -} + let downloads = 0 + page.on('download', () => { downloads += 1 }) + await exportButton.click() + await page.waitForFunction(() => { + const target = window as typeof window & { __homevoxPendingBlobGate?: { started: boolean } } + return target.__homevoxPendingBlobGate?.started === true + }) + + const beforeEdit = await e2eState(page) + await page.getByRole('button', { name: '选择窗洞' }).click() + await page.getByTestId('opening-width').fill('60') + const duringEdit = await e2eState(page) + expect(duringEdit.threeD.canonicalRevision).not.toBe(beforeEdit.threeD.canonicalRevision) + await expect(exportButton).toBeDisabled() + + await page.evaluate(() => { + const target = window as typeof window & { __homevoxPendingBlobGate?: { release: (() => void) | null } } + const release = target.__homevoxPendingBlobGate?.release + if (!release) throw new Error('3D canvas blob export did not start') + release() + }) + await expect(page.getByRole('alert')).toContainText('导出未完成,请稍后再试。') + await expect(exportButton).toHaveText('导出空间图') + expect(downloads).toBe(0) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 85b577a..e5686e4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2083,9 +2083,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -2245,9 +2245,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -2264,7 +2264,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/frontend/src/App.css b/frontend/src/App.css index f90339d..9d5078f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,184 +1,364 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } +.homevox-app { + min-height: 100%; + color: #182033; + background: #f4f6fa; } -.hero { - position: relative; +.product-sidebar { + background: #182033; + color: #fff; +} - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } +.product-sidebar button:focus-visible, +.product-topbar button:focus-visible, +.workspace-card button:focus-visible { + outline: 3px solid #8a7aff; + outline-offset: 2px; +} - .base { - width: 170px; - position: relative; - z-index: 0; - } +.product-step { + border: 1px solid transparent; + color: #aeb7c8; +} - .framework, - .vite { - position: absolute; - } +.product-step[data-active='true'] { + border-color: transparent; + color: #fff; + background: #2a3454; +} - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } +.product-step[data-completed='true'] { + color: #d7f4e8; +} - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } +.product-step[data-completed='true'] .step-dot { + border-color: #1f9d70; + background: #1f9d70; + color: #fff; } -#center { +.product-step[data-active='true'][data-completed='true'] .step-dot { + border-color: #5b5ce2; + background: #5b5ce2; +} + +.product-step[data-locked='true'] { + opacity: .48; +} + +.step-dot { + display: grid; + width: 12px; + height: 12px; + flex: 0 0 auto; + place-items: center; + border: 0; + border-radius: 999px; + background: #566078; + color: transparent; + font-size: 0; +} + +.product-step[data-active='true'] .step-dot { + border-color: #5b5ce2; + background: #5b5ce2; +} + +.brand-mark { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border-radius: 9px; + background: #5b5ce2; + color: #fff; + font-size: 18px; +} + +.workspace-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 246px; + gap: 16px; + min-height: 0; +} + +.product-workspace-linked { + grid-template-columns: 270px minmax(500px, 1fr) 260px; + min-height: 696px; +} + +.workspace-card { + border: 1px solid #e1e7f2; + border-radius: 16px; + background: #fff; + box-shadow: 0 10px 35px rgb(31 48 88 / 6%); +} + +.canvas-card { display: flex; + min-height: 0; flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; + box-shadow: none; +} - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } +.three-card { + height: 620px; + min-height: 620px; + background: #0c1325; +} + +.inspector-card { + min-height: 0; } -#next-steps { +.status-chip { + border: 0; + border-radius: 999px; + background: #eef0ff; + color: #6251d8; +} + +.autosave-chip { + min-width: 104px; + border-radius: 999px; + background: #eaf8f2; + color: #1f9d70; + text-align: center; +} + +.unknown-note { + border: 1px solid #f4cf8a; + border-radius: 10px; + background: #fffbeb; + color: #845d19; +} + +.import-workspace .workspace-card { + min-height: 0; +} + +.source-plan-image { + width: 600px; + height: 560px; + max-width: 100%; + align-self: center; + margin-top: 19px; +} + +.import-workspace, +.three-generation { + margin: 20px 0 0 16px; +} + +.import-fact { display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 46px; + padding: 0 16px; + border-radius: 9px; + background: #eaf8f2; +} - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } +.import-fact dt { + font-weight: 600; + color: #182033; +} - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } +.import-fact dd { + font-size: 12px; + font-weight: 500; + text-align: right; } -#docs { - border-right: 1px solid var(--border); +.two-d-product-workspace { + display: grid; + width: 1136px; + min-height: 800px; + grid-template-columns: 870px 246px; + gap: 20px; + margin-left: 4px; +} - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } +.two-d-editor-frame { + display: flex; + height: 800px; + min-height: 800px; + flex-direction: column; + padding: 20px; +} + +.two-d-editor-frame .canvas-card { + min-height: 0; + flex: 1; +} + +.two-d-product-workspace .inspector-card { + min-height: 800px; +} + +.linked-product-workspace { + width: 1136px; + height: 800px; + min-height: 800px; + margin-left: 4px; + padding: 20px; +} + +.linked-product-workspace .product-workspace { + height: 694px; +} + +.linked-product-workspace .canvas-card, +.linked-product-workspace .three-card, +.linked-product-workspace .inspector-card { + min-height: 0; + height: 694px; +} + +.three-generation .three-card { + height: 570px; + min-height: 570px; +} + +.workspace-toolbar { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + padding: 0 12px 0 16px; + border-radius: 10px; + background: #f8f9fc; +} + +.floorplan-surface { + display: flex; } -#next-steps ul { - list-style: none; - padding: 0; +.floorplan-source-controls { display: flex; - gap: 8px; - margin: 32px 0 0; + min-height: 64px; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 16px; + padding: 0 16px; + border-radius: 10px; + background: #f8f9fc; + color: #667085; + font-size: 12px; + font-weight: 600; +} + +.toolbar-chip { + border-radius: 999px; + background: #eef0ff; + padding: 7px 12px; + color: #5b5ce2; + font-size: 12px; + font-weight: 600; +} + +.toolbar-button, +.toolbar-primary { + border-radius: 10px; + padding: 9px 14px; + font-size: 14px; + font-weight: 600; +} + +.toolbar-button { + border: 1px solid #dde2ea; + background: #fff; + color: #5b5ce2; +} + +.toolbar-primary { + border: 1px solid #5b5ce2; + background: #5b5ce2; + color: #fff; +} + +.toolbar-button:disabled, +.toolbar-primary:disabled { + cursor: not-allowed; + opacity: .45; +} + +@media (max-width: 1280px) { + .homevox-app { + min-height: 100%; + } - .logo { - height: 18px; + .homevox-layout { + display: block; } - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); + .product-sidebar { + width: 100%; + } + + .product-sidebar nav { display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } + overflow-x: auto; } - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; + .product-step { + min-width: 155px; + } - li { - flex: 1 1 calc(50% - 8px); - } + .workspace-grid { + grid-template-columns: 1fr; + } - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } + .two-d-product-workspace, + .linked-product-workspace { + width: auto; + margin-left: 0; } -} -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; + .two-d-product-workspace { + grid-template-columns: 1fr; + } + + .two-d-editor-frame, + .two-d-product-workspace .inspector-card, + .linked-product-workspace .product-workspace, + .linked-product-workspace .canvas-card, + .linked-product-workspace .three-card, + .linked-product-workspace .inspector-card { + height: auto; + min-height: 0; } -} -.ticks { - position: relative; - width: 100%; + .product-workspace-linked { + grid-template-columns: 1fr; + } - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; + .import-workspace { + grid-template-columns: 1fr; } - &::before { - left: 0; - border-left-color: var(--border); + .three-generation { + grid-template-columns: 1fr; } - &::after { - right: 0; - border-right-color: var(--border); + + .workspace-toolbar { + align-items: flex-start; + flex-direction: column; + padding: 12px; } } +.crop-canvas { width: 100%; height: 640px; background: #f1f5f9; border-radius: 12px; touch-action: none; } +.crop-canvas[aria-disabled="true"] { cursor: progress; } +.crop-canvas[aria-disabled="true"] .crop-selection-outline, +.crop-canvas[aria-disabled="true"] .crop-selection-handle { opacity: .35; } +.crop-canvas[aria-disabled="true"] .crop-selection-outline { stroke: #64748b; stroke-dasharray: 24 16; } +.crop-confirm-button:disabled { cursor: progress; opacity: 1; background: #cbd5e1; color: #475569; } +.crop-candidate { width: 100%; border: 1px solid #dfe3ff; border-radius: 10px; padding: 10px 12px; color: #4338ca; text-align: left; font-size: 13px; font-weight: 600; background: #fafaff; } +.crop-candidate:hover { border-color: #5b5ce2; background: #f0f0ff; } +.crop-judging-spinner { width: 16px; height: 16px; flex: 0 0 auto; border: 2px solid #c4b5fd; border-top-color: #5b5ce2; border-radius: 999px; animation: crop-judging-spin .8s linear infinite; } +@keyframes crop-judging-spin { to { transform: rotate(360deg); } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ecd18c3..4b1d92d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,4 @@ -import { Suspense, useEffect, useMemo, useRef, useState, type PointerEvent } from 'react' -import { Canvas, type RootState } from '@react-three/fiber' -import { Grid, Html, OrbitControls } from '@react-three/drei' +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from 'react' import type { BufferGeometry } from 'three' import { type EndpointRef, @@ -9,6 +7,7 @@ import { canUndo, createWallEditorState, addOpening, + editWallCoordinates, moveEndpoint, pushWallSnapshot, removeOpening, @@ -21,18 +20,16 @@ import { canvasScale, canvasUnitsForCssPixels, isParseResponse, - openingLabel, - openingPoint, - validateOpenings, + validateCanonicalFloorplan, MIN_OPENING_WIDTH, type ParsedOpening, type ParseResponse, type ParseResult, type Viewport, } from './floorplanUi' -import { buildWallShellModel, type WallShellModel } from './wallShell' +import { buildWallShellModel } from './wallShell' import { buildWallVoxelModel, type WallVoxelModel } from './wallVoxel' -import { runMarchingCubes, type MarchingCubesMetrics, type WasmBindings, type WasmFallbackReason } from './wasmMarchingCubes' +import { runMarchingCubes, type MarchingCubesMetrics, type WasmFallbackReason } from './wasmMarchingCubes' import { buildWasmWallGeometry, disposeWasmWallGeometry } from './wasmGeometry' import { buildExportFileName, @@ -42,106 +39,43 @@ import { validateCanvasSize, } from './export' import { - createProject, - getProject, - listProjects, - updateProject, type ProjectDetail, - type ProjectSummary, } from './projects' +import { canApplyProductFlowEvent, initialCompletedSteps, type ProductStep, type ProductFlowContext } from './productFlow' +import { ProductShell } from './ProductShell' +import type { FloorplanEditorPanelProps } from './FloorplanEditorPanel' +import type { InspectorPanelProps } from './InspectorPanel' +import { ProjectSaveView } from './ProjectSaveView' +import { LinkedWorkspace, ThreeDConfirmation, TwoDWorkspace } from './ProductViews' +import { SourceImportView, AIParseView, CropConfirmView, type ParseViewStatus } from './SourceImportView' +import { clampCrop, createCroppedFile, fullImageCrop, isCandidateDetection, selectCandidate, type CandidateDetection, type CropRect } from './cropFlow' +import { LatestOperation, type OperationToken } from './latestOperation' +import type { ThreeDPreviewPanelProps } from './ThreeDPreviewPanel' +import type { ThreeDRenderer } from './ThreeDPreview' +import { useProductFlowController } from './useProductFlowController' +import { useProjectSession } from './useProjectSession' +import { useThreeDGenerationController } from './useThreeDGenerationController' +import { canExportCurrentThreeD } from './threeDExport' +import { exportCurrentThreeDRevision, type ThreeDExportRevision } from './threeDExportSession' +import { canonicalRevisionToken } from './floorplanSession' +import { completesFinalSave } from './projectSaveCompletion' +import { clearInitialProjectAccess } from './projectAccess' +import { parseFailureMessage, parseNetworkFailureMessage, parseRetryFailureMessage } from './parseFeedback' +import { e2EWasmLoader, isE2EInstrumentationEnabled, publishE2EState } from '@homevox-e2e' +import './App.css' const API_PARSE_URL = '/api/floorplans/parse' +const API_CANDIDATES_URL = '/api/floorplans/candidates' const EMPTY_WALLS: WallSegment[] = [] -const E2E_WALL_FIXTURE: ParseResponse = { - filename: 'e2e-wall-fixture.png', - contentType: 'image/png', - size: 80, - result: { - rooms: [], - walls: [ - { id: 'wall-1', x1: 80, y1: 80, x2: 520, y2: 80 }, - { id: 'wall-2', x1: 520, y1: 80, x2: 520, y2: 360 }, - { id: 'wall-3', x1: 520, y1: 360, x2: 80, y2: 360 }, - { id: 'wall-4', x1: 80, y1: 360, x2: 80, y2: 80 }, - ], - doors: [{ id: 'door-1', kind: 'door', wallId: 'wall-1', position: 0.5, width: 72, source: 'fixture', confirmed: false }], - windows: [{ id: 'window-1', kind: 'window', wallId: 'wall-2', position: 0.5, width: 64, source: 'fixture', confirmed: false }], - scale: { unit: 'px' }, - metadata: { source: 'production-e2e-fixture', image_width: 600, image_height: 440 }, - }, -} - -const E2E_INVALID_OPENING_FIXTURE: ParseResponse = { - ...E2E_WALL_FIXTURE, - result: { - ...E2E_WALL_FIXTURE.result, - doors: [{ id: 'door-invalid', kind: 'door', wallId: 'missing-wall', position: 0.5, width: 72, source: 'fixture', confirmed: false }], - windows: [], - }, -} -const E2E_DUPLICATE_WALL_ID_FIXTURE: ParseResponse = { - ...E2E_WALL_FIXTURE, - result: { - ...E2E_WALL_FIXTURE.result, - walls: [ - { id: 'wall-duplicate', x1: 80, y1: 80, x2: 520, y2: 80 }, - { id: 'wall-duplicate', x1: 520, y1: 80, x2: 520, y2: 360 }, - ], - doors: [{ id: 'door-duplicate', kind: 'door', wallId: 'wall-duplicate', position: 0.5, width: 72, source: 'fixture', confirmed: false }], - windows: [], - }, -} -type ParseState = 'idle' | 'uploading' | 'ready' | 'error' +type ParseState = ParseViewStatus type ScenePoint = { x: number y: number } -declare global { - interface Window { - __homevoxE2E?: { - generation: number - wasmCalls: number - metrics: MarchingCubesMetrics | null - geometry: { positionCount: number; normalCount: number; finite: boolean; fingerprint: number } - currentProjectId: string | null - selectedOpeningId: string | null - } - } -} - -function e2EFixture(): ParseResponse | null { - if (typeof window === 'undefined') return null - switch (new URLSearchParams(window.location.search).get('e2e')) { - case 'wall-fixture': return E2E_WALL_FIXTURE - case 'invalid-opening': return E2E_INVALID_OPENING_FIXTURE - case 'duplicate-wall-id': return E2E_DUPLICATE_WALL_ID_FIXTURE - default: return null - } -} - -function isE2EFixtureEnabled(): boolean { - return e2EFixture() !== null -} - -function e2EProjectID(): string | null { - if (typeof window === 'undefined') return null - const value = new URLSearchParams(window.location.search).get('project') - return value && /^[0-9a-f-]{36}$/i.test(value) ? value : null -} - -function e2EWasmFailureEnabled(): boolean { - return typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('wasm') === 'load-failure' -} - -const failingE2EWasmLoader = async (): Promise => { - throw new Error('E2E fixture rejected WASM loader') -} - - function isFinitePositiveInteger(value: number | undefined | null): value is number { return typeof value === 'number' && Number.isFinite(value) && value > 0 } @@ -160,190 +94,35 @@ function finiteNumber(value: number | undefined | null): value is number { function collectViewportFromParse(result: ParseResult | null): Viewport | null { if (!result) return null - const coords: number[] = [] for (const room of result.rooms) { const b = room.approximate_bounds - if (finiteNumber(b.x1) && finiteNumber(b.y1) && finiteNumber(b.x2) && finiteNumber(b.y2)) { - coords.push(b.x1, b.y1, b.x2, b.y2) - } + if (finiteNumber(b.x1) && finiteNumber(b.y1) && finiteNumber(b.x2) && finiteNumber(b.y2)) coords.push(b.x1, b.y1, b.x2, b.y2) } - for (const wall of result.walls) { - if (finiteNumber(wall.x1) && finiteNumber(wall.y1) && finiteNumber(wall.x2) && finiteNumber(wall.y2)) { - coords.push(wall.x1, wall.y1, wall.x2, wall.y2) - } + if (finiteNumber(wall.x1) && finiteNumber(wall.y1) && finiteNumber(wall.x2) && finiteNumber(wall.y2)) coords.push(wall.x1, wall.y1, wall.x2, wall.y2) } - for (const opening of [...result.doors, ...result.windows]) { - if (finiteNumber(opening.x) && finiteNumber(opening.y)) { - coords.push(opening.x, opening.y) - } - } - - if (coords.length < 2) { - return null + if (finiteNumber(opening.x) && finiteNumber(opening.y)) coords.push(opening.x, opening.y) } - + if (coords.length < 2) return null const xs = coords.filter((_, index) => index % 2 === 0) const ys = coords.filter((_, index) => index % 2 === 1) - const minX = Math.min(...xs) - const maxX = Math.max(...xs) - const minY = Math.min(...ys) - const maxY = Math.max(...ys) - const rawWidth = maxX - minX - const rawHeight = maxY - minY - - if (rawWidth <= 0 || rawHeight <= 0) { - return null - } - + const minX = Math.min(...xs); const maxX = Math.max(...xs) + const minY = Math.min(...ys); const maxY = Math.max(...ys) + const rawWidth = maxX - minX; const rawHeight = maxY - minY + if (rawWidth <= 0 || rawHeight <= 0) return null const padding = Math.max(rawWidth, rawHeight) * 0.1 - - return { - minX: minX - padding, - minY: minY - padding, - width: rawWidth + padding * 2, - height: rawHeight + padding * 2, - } + return { minX: minX - padding, minY: minY - padding, width: rawWidth + padding * 2, height: rawHeight + padding * 2 } } function chooseViewport(result: ParseResult | null, fallbackImageSize: { width: number; height: number } | null): Viewport { const imageWidth = safeNumber(result?.metadata.image_width, Number.NaN) const imageHeight = safeNumber(result?.metadata.image_height, Number.NaN) - - if (isFinitePositiveInteger(imageWidth) && isFinitePositiveInteger(imageHeight)) { - return { - minX: 0, - minY: 0, - width: imageWidth, - height: imageHeight, - } - } - - if (fallbackImageSize) { - if (isFinitePositiveInteger(fallbackImageSize.width) && isFinitePositiveInteger(fallbackImageSize.height)) { - return { - minX: 0, - minY: 0, - width: fallbackImageSize.width, - height: fallbackImageSize.height, - } - } - } - + if (isFinitePositiveInteger(imageWidth) && isFinitePositiveInteger(imageHeight)) return { minX: 0, minY: 0, width: imageWidth, height: imageHeight } + if (fallbackImageSize && isFinitePositiveInteger(fallbackImageSize.width) && isFinitePositiveInteger(fallbackImageSize.height)) return { minX: 0, minY: 0, width: fallbackImageSize.width, height: fallbackImageSize.height } const inferred = collectViewportFromParse(result) - if (inferred) { - return inferred - } - - return { - minX: 0, - minY: 0, - width: 1024, - height: 768, - } -} - -type FloorplanSceneProps = { - model: WallShellModel - wasmGeometry: BufferGeometry | null - wasmActive: boolean - selectedOpeningID: string | null - onSelectOpening: (openingID: string) => void -} - -function Scene({ model, wasmGeometry, wasmActive, selectedOpeningID, onSelectOpening }: FloorplanSceneProps) { - - return ( - <> - - - - {model.floor && ( - - - - - )} - - - - {wasmActive && wasmGeometry && ( - - - - )} - - {!wasmActive && model.walls.map((wall) => ( - - - - - ))} - - {model.openings.map((opening) => { - const isDoor = opening.kind === 'door' - const markerHeight = isDoor ? 3.2 : 3.7 - const markerRadius = isDoor ? 0.28 : 0.24 - return ( - - { event.stopPropagation(); onSelectOpening(opening.id) }} - onClick={(event) => { event.stopPropagation(); onSelectOpening(opening.id) }} - > - - - - - - - - - - -
-
- 状态 - - {status === 'ready' - ? '解析完成' - : status === 'uploading' - ? '解析中' - : status === 'error' - ? '失败' - : '等待上传'} - -
- {error &&

{error}

} - {exportError &&

{exportError}

} -
- - -
-
-

项目保存与加载

- -
- setProjectName(event.target.value)} - /> - - {projectMessage &&

{projectMessage}

} -
    - {projects.map((item) => ( -
  • - {item.name} r{item.revision} - -
  • - ))} - {projects.length === 0 &&
  • 暂无已保存项目
  • } -
-
- -
-

导出

-
- - -
-

- {canExportModel ? '基于当前编辑后的墙体、门窗 marker 与当前视口/镜头生成。' : '请先完成解析后再导出。'} -

-
- -
-
-

门窗开口(选择墙体后添加;拖拽移动)

-
- - -
- {selectedOpening && ( -
- - - {selectedOpening.kind === 'window' && ( -

- 窗台高和窗高:confirmed={selectedOpening.confirmed === true ? 'true' : 'false(未知)'}。3D 预览使用非持久化默认值;这些尺寸不会保存为建筑参数。 -

- )} - {selectedOpening.kind === 'door' && selectedOpening.confirmed !== true && ( -

- 门高:confirmed=false(未知)。3D 全高门洞仅为非持久化预览假设;不会保存为建筑参数。 -

- )} -
- )} - {openingError &&

{openingError}

} -
-
-

历史与历史同步

-
- - -
-

快捷键:Ctrl/Cmd + Z(Undo),Ctrl/Cmd + Shift+Z / Ctrl/Cmd + Y(Redo)

-
- -
-
-

Rooms

-

{editableResult?.rooms.length ?? 0}

-
-
-

Walls

-

{walls.length}

-
-
-
- - {editableResult && ( -
-
-

解析摘要

-
- {editableResult.rooms.slice(0, 6).map((room) => ( -
- {room.name} - {room.type} -
- ))} -
-
- -
-

结构化 JSON

-
-                {JSON.stringify(editableResult, null, 2)}
-              
-
-
- )} - - -
-
-
- 2D 墙体端点校正(原图叠加) -
- - - {draggedEndpoint - ? '拖拽中' - : selectedWallIndex !== null - ? `已选择墙体 ${selectedWallIndex + 1}` - : hoveredEndpoint - ? '可拖拽端点(鼠标悬停)' - : '选择墙体或拖拽端点'} - -
-
- -
- - - - {showSourceImage && previewURL && ( - - )} - - - {walls.map((wall, wallIndex) => { - const endpointActive = - hoveredEndpoint?.wallIndex === wallIndex || draggedEndpoint?.wallIndex === wallIndex - const selected = selectedWallIndex === wallIndex - const active = endpointActive || selected - const color = draggedEndpoint?.wallIndex === wallIndex - ? '#22d3ee' - : selected - ? '#fbbf24' - : endpointActive - ? '#38bdf8' - : '#e2e8f0' - - return ( - - ) - })} - - - - {walls.map((wall, wallIndex) => ( - handleWallPointerDown(event, wallIndex)} - style={{ cursor: 'pointer' }} - /> - ))} - - - - {walls.flatMap((wall, wallIndex) => { - const startSelected = - (hoveredEndpoint?.wallIndex === wallIndex && hoveredEndpoint.endpoint === 'start') || - (draggedEndpoint?.wallIndex === wallIndex && draggedEndpoint.endpoint === 'start') - const endSelected = - (hoveredEndpoint?.wallIndex === wallIndex && hoveredEndpoint.endpoint === 'end') || - (draggedEndpoint?.wallIndex === wallIndex && draggedEndpoint.endpoint === 'end') - return [ - { endpoint: 'start' as const, x: wall.x1, y: wall.y1, selected: startSelected }, - { endpoint: 'end' as const, x: wall.x2, y: wall.y2, selected: endSelected }, - ].map((handle) => ( - - )) - })} - - - - {walls.flatMap((wall, wallIndex) => - [ - { endpoint: 'start' as const, x: wall.x1, y: wall.y1 }, - { endpoint: 'end' as const, x: wall.x2, y: wall.y2 }, - ].map((handle) => ( - - handleCanvasPointerDown(event, { - wallIndex, - endpoint: handle.endpoint, - }) - } - style={{ cursor: 'grab' }} - /> - )), - )} - - - {openings.map((opening) => { - const wall = opening.wallId ? walls.find((item) => item.id === opening.wallId) : undefined - const point = wall ? openingPoint(wall, opening) : null - if (!point || !opening.id) return null - const selected = selectedOpeningID === opening.id - const label = openingLabel(opening) - const color = selected ? '#facc15' : opening.kind === 'door' ? '#f97316' : '#38bdf8' - return ( - - handleOpeningPointerDown(event, opening.id!)} - style={{ cursor: 'grab' }} - /> - {label && ( - - {label} - - )} - - ) - })} - -
-
- -
-
-
3D 墙体 WASM Marching Cubes
-
- 引擎 {wasmState} - Grid {wasmMetrics ? wasmMetrics.grid.join('×') : '—'} - 三角 {wasmMetrics?.triangleCount ?? 0} - 墙体 {wallShellModel.walls.length} -
- {geometryValidationError && ( -

- 开口校验失败,已阻止体素和 WASM 开洞:{geometryValidationError}。2D 编辑与保存仍可用。 -

- )} -
- {wasmMetrics - ? `顶点 ${wasmMetrics.vertexCount} · ${wasmMetrics.elapsedMs.toFixed(1)}ms · 输入 ${wasmMetrics.inputBytes}B · 输出 ${wasmMetrics.outputBytes}B` - : wasmFallback - ? `fallback: ${wasmFallback} · wall-shell ${wallShellTotalLength.toFixed(2)}` - : '正在准备受控 17³ 标量场'} -
-
-
- {webGLAvailable ? ( - { - setThreeRenderer(state) - }} - > - - { setSelectedOpeningID(openingID); setOpeningError('') }} /> - - - - ) : ( -
-
- 当前浏览器未提供 WebGL,无法显示 3D 墙体白模。请在启用 WebGL 的浏览器中打开;2D 编辑和白模几何指标仍可使用。 -
-
- )} -
-
- WASM 网格实时跟随墙体及其局部开口;仅在引擎 active 时声明真实开洞。失败时显示 wall-shell 降级与可观察错误。 -
-
-
- + applyProductTransition({ type: 'open', step })} primaryAction={primaryAction}> + {activeStep === 1 && { void reloadProject() }} />} + {activeStep === 2 && (crop ? originalImageSize && setCrop(clampCrop(next, originalImageSize))} onSelectCandidate={(index) => { if (originalImageSize) { const next = selectCandidate(candidateDetection?.candidates ?? [], index, originalImageSize); if (next) setCrop(next) } }} onRestoreRecommended={() => { if (originalImageSize) { const next = selectCandidate(candidateDetection?.candidates ?? [], 0, originalImageSize); setCrop(next ?? fullImageCrop(originalImageSize)) } }} onResetFullImage={() => originalImageSize && setCrop(fullImageCrop(originalImageSize))} onConfirm={() => { void confirmCrop() }} /> : )} + {activeStep === 3 && } + {activeStep === 4 && applyProductTransition({ type: 'open', step: 3 })} onComplete={() => completeAndAdvance(4, 5)} />} + {activeStep === 5 && applyProductTransition({ type: 'open', step: 3 })} />} + {activeStep === 6 && { if (hasCurrentLinkedReview) void saveProject({ stage: 'final', canonicalRevision }) }} onCopyResumeLink={() => { void copyProjectResumeLink(window.location.href, (value) => navigator.clipboard.writeText(value)) }} />} + ) } diff --git a/frontend/src/FloorplanEditorPanel.tsx b/frontend/src/FloorplanEditorPanel.tsx new file mode 100644 index 0000000..e3b6d64 --- /dev/null +++ b/frontend/src/FloorplanEditorPanel.tsx @@ -0,0 +1,92 @@ +import type { PointerEvent, RefObject } from 'react' +import type { EndpointRef, WallSegment } from './floorplanEditor' +import { openingPoint, type ParsedOpening, type Viewport } from './floorplanUi' + +export type FloorplanEditorPanelProps = { + editorRef: RefObject + viewport: Viewport + walls: readonly WallSegment[] + openings: readonly ParsedOpening[] + showSourceImage: boolean + previewURL: string + geometryValidationError: string | null + selectedWallID: string | null + selectedWallLabel: string | null + selectedOpeningID: string | null + hoveredEndpoint: EndpointRef | null + draggedEndpoint: EndpointRef | null + hitRadius: number + handleRadius: number + activeHandleRadius: number + wallHitStroke: number + wallStroke: number + activeWallStroke: number + openingRadius: number + openingStroke: number + labelOffset: number + labelSize: number + embedded?: boolean + onShowSourceImageChange: (show: boolean) => void + onCanvasPointerMove: (event: PointerEvent) => void + onCanvasPointerUp: (event: PointerEvent) => void + onCanvasPointerCancel: (event: PointerEvent) => void + onEndpointPointerDown: (event: PointerEvent, endpoint: EndpointRef) => void + onWallPointerDown: (event: PointerEvent, wallID: string) => void + onOpeningPointerDown: (event: PointerEvent, openingID: string) => void +} + +/** Canonical 2D editor surface with an adaptive, centered SVG viewport. */ +export function FloorplanEditorPanel({ + editorRef, viewport, walls, openings, showSourceImage, previewURL, geometryValidationError, + selectedWallID, selectedWallLabel, selectedOpeningID, hoveredEndpoint, draggedEndpoint, + hitRadius, handleRadius, activeHandleRadius, wallHitStroke, wallStroke, activeWallStroke, + openingRadius, openingStroke, labelOffset, labelSize, embedded = false, + onShowSourceImageChange, onCanvasPointerMove, onCanvasPointerUp, onCanvasPointerCancel, + onEndpointPointerDown, onWallPointerDown, onOpeningPointerDown, +}: FloorplanEditorPanelProps) { + return ( +
+ {geometryValidationError &&

这个户型有一处需要调整,请先在平面图中修正后再继续。

} +
+ + + {showSourceImage && previewURL && } + + {walls.map((wall, wallIndex) => { + const endpointActive = hoveredEndpoint?.wallIndex === wallIndex || draggedEndpoint?.wallIndex === wallIndex + const selected = selectedWallID === wall.id + const active = endpointActive || selected + const color = draggedEndpoint?.wallIndex === wallIndex ? '#22d3ee' : selected ? '#7c3aed' : endpointActive ? '#38bdf8' : '#475467' + return + })} + + + {walls.map((wall, wallIndex) => wall.id && onWallPointerDown(event, wall.id)} style={{ cursor: 'pointer' }} />)} + + + {walls.flatMap((wall, wallIndex) => ([{ endpoint: 'start' as const, x: wall.x1, y: wall.y1 }, { endpoint: 'end' as const, x: wall.x2, y: wall.y2 }].map((handle) => { + const highlighted = hoveredEndpoint?.wallIndex === wallIndex && hoveredEndpoint.endpoint === handle.endpoint || draggedEndpoint?.wallIndex === wallIndex && draggedEndpoint.endpoint === handle.endpoint + return + })))} + + + {walls.flatMap((wall, wallIndex) => ([{ endpoint: 'start' as const, x: wall.x1, y: wall.y1 }, { endpoint: 'end' as const, x: wall.x2, y: wall.y2 }].map((handle) => onEndpointPointerDown(event, { wallIndex, endpoint: handle.endpoint })} style={{ cursor: 'grab' }} />)))} + + {openings.map((opening) => { + const wall = opening.wallId ? walls.find((item) => item.id === opening.wallId) : undefined + const point = wall ? openingPoint(wall, opening) : null + if (!point || !opening.id) return null + const selected = selectedOpeningID === opening.id + const color = selected ? '#7c3aed' : opening.kind === 'door' ? '#f97316' : '#38bdf8' + const label = opening.kind === 'door' ? '门洞' : opening.kind === 'window' ? '窗洞' : null + return onOpeningPointerDown(event, opening.id!)} style={{ cursor: 'grab' }} />{label && {label}} + })} + +
+
+ + {draggedEndpoint ? '正在调整' : selectedWallLabel ? '已选择墙体' : hoveredEndpoint ? '可调整端点' : '选择要调整的位置'} +
+
+ ) +} diff --git a/frontend/src/InspectorPanel.tsx b/frontend/src/InspectorPanel.tsx new file mode 100644 index 0000000..14724e6 --- /dev/null +++ b/frontend/src/InspectorPanel.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react' +import type { WallSegment } from './floorplanEditor' +import { MIN_OPENING_WIDTH, type ParsedOpening } from './floorplanUi' +import { PRODUCT_LANGUAGE } from './productPresentation' + +export type InspectorPanelProps = { + selectedWallID: string | null + selectedWall?: WallSegment | null + selectedOpening: ParsedOpening | null + openingError: string + canExport2D: boolean + canExport3D: boolean + exportingScope: '2d' | '3d' | null + exportError: string + canUndo: boolean + canRedo: boolean + onAddOpening: (kind: 'door' | 'window') => void + onWallCoordinatesChange?: (coordinates: Pick) => void + onOpeningWidthChange: (width: number) => void + onDeleteOpening: () => void + onExport2D: () => void + onExport3D: () => void + onUndo: () => void + onRedo: () => void +} + +function WallCoordinateEditor({ wall, onApply }: { + wall: WallSegment + onApply: (coordinates: Pick) => void +}) { + const [draft, setDraft] = useState(() => ({ x1: wall.x1, y1: wall.y1, x2: wall.x2, y2: wall.y2 })) + useEffect(() => setDraft({ x1: wall.x1, y1: wall.y1, x2: wall.x2, y2: wall.y2 }), [wall.id, wall.x1, wall.x2, wall.y1, wall.y2]) + const fields = [ + ['x1', '起点 X'], + ['y1', '起点 Y'], + ['x2', '终点 X'], + ['y2', '终点 Y'], + ] as const + + return ( +
+

图纸像素坐标

+
+ {fields.map(([key, label]) => ( + + ))} +
+ +
+ ) +} + +/** Customer-facing inspector; stable IDs remain internal and test-addressable only. */ +export function InspectorPanel({ + selectedWallID, + selectedWall, + selectedOpening, + openingError, + canExport2D, + canExport3D, + exportingScope, + exportError, + onAddOpening, + onWallCoordinatesChange, + onOpeningWidthChange, + onDeleteOpening, + onExport2D, + onExport3D, +}: InspectorPanelProps) { + const currentObject = selectedOpening + ? selectedOpening.kind === 'door' ? PRODUCT_LANGUAGE.object.door : PRODUCT_LANGUAGE.object.window + : selectedWallID ? PRODUCT_LANGUAGE.object.wall : PRODUCT_LANGUAGE.object.none + return ( + + ) +} diff --git a/frontend/src/ProductShell.tsx b/frontend/src/ProductShell.tsx new file mode 100644 index 0000000..91175bd --- /dev/null +++ b/frontend/src/ProductShell.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' +import { PRODUCT_STEPS, canOpenStep, type ProductFlowContext, type ProductStep } from './productFlow' +import { PRODUCT_STATE_COPY } from './productDesign' + +type Props = { + activeStep: ProductStep + completedSteps: readonly ProductStep[] + flow: ProductFlowContext + hasDocument: boolean + onOpenStep: (step: ProductStep) => void + primaryAction?: ReactNode + children?: ReactNode +} + +export function ProductShell({ activeStep, completedSteps, flow, hasDocument, onOpenStep, primaryAction, children }: Props) { + const copy = PRODUCT_STATE_COPY[activeStep] + return
+ +

{copy.title}

{copy.subtitle}

{hasDocument && 当前户型可编辑}修改需手动保存{primaryAction}
{children}
+
+} diff --git a/frontend/src/ProductViews.test.ts b/frontend/src/ProductViews.test.ts new file mode 100644 index 0000000..b52d595 --- /dev/null +++ b/frontend/src/ProductViews.test.ts @@ -0,0 +1,97 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { WorkspaceToolbar } from './ProductViews' +import { InspectorPanel, type InspectorPanelProps } from './InspectorPanel' +import { ProductShell } from './ProductShell' +import { ProjectSaveView } from './ProjectSaveView' + +const inspector: InspectorPanelProps = { + selectedWallID: null, + selectedOpening: null, + openingError: '', + canExport2D: false, + canExport3D: false, + exportingScope: null, + exportError: '', + canUndo: false, + canRedo: false, + onAddOpening: vi.fn(), + onOpeningWidthChange: vi.fn(), + onDeleteOpening: vi.fn(), + onExport2D: vi.fn(), + onExport3D: vi.fn(), + onUndo: vi.fn(), + onRedo: vi.fn(), +} + +describe('2D recognition snapshot controls', () => { + it('offers creation before 3D and explicit updates after the snapshot exists', () => { + const create = renderToStaticMarkup(createElement(WorkspaceToolbar, { inspector, canAdvance: true, onAdvance: vi.fn(), snapshot: { exists: false, busy: false, message: '', messageTone: 'success', onSave: vi.fn(), onReload: vi.fn(), onCopyResumeLink: vi.fn() } })) + expect(create).toContain('创建识别快照') + expect(create).not.toContain('复制编辑链接') + + const update = renderToStaticMarkup(createElement(WorkspaceToolbar, { inspector, canAdvance: true, onAdvance: vi.fn(), snapshot: { exists: true, busy: false, message: '识别快照已保存', messageTone: 'success', onSave: vi.fn(), onReload: vi.fn(), onCopyResumeLink: vi.fn() } })) + expect(update).toContain('保存当前修改') + expect(update).toContain('识别快照已保存') + expect(update).toContain('复制编辑链接') + }) + + it('renders revision conflict as an error with an explicit latest-version recovery', () => { + const markup = renderToStaticMarkup(createElement(WorkspaceToolbar, { inspector, canAdvance: true, onAdvance: vi.fn(), snapshot: { exists: true, busy: false, message: '项目已在其他页面更新,请加载最新版本后再保存', messageTone: 'error', onSave: vi.fn(), onReload: vi.fn(), onCopyResumeLink: vi.fn() } })) + expect(markup).toContain('text-red-700') + expect(markup).toContain('加载最新版本') + expect(markup).not.toContain('00000000-') + }) +}) + +describe('selected wall coordinate editor', () => { + it('shows four exact source-pixel coordinates and an explicit apply action', () => { + const markup = renderToStaticMarkup(createElement(InspectorPanel, { + ...inspector, + selectedWallID: 'wall-a', + selectedWall: { id: 'wall-a', x1: 10, y1: 20, x2: 30, y2: 40 }, + onWallCoordinatesChange: vi.fn(), + })) + + expect(markup).toContain('起点 X') + expect(markup).toContain('起点 Y') + expect(markup).toContain('终点 X') + expect(markup).toContain('终点 Y') + expect(markup).toContain('应用坐标') + }) +}) + +describe('product save disclosure', () => { + it('does not claim automatic persistence for a browser-only document', () => { + const markup = renderToStaticMarkup(createElement(ProductShell, { + activeStep: 3, + completedSteps: [1, 2], + flow: { completed: [1, 2], hasDocument: true, hasCanonicalGeometry: true, hasThreeDGeometry: false }, + hasDocument: true, + onOpenStep: vi.fn(), + }, 'editor')) + expect(markup).not.toContain('自动保存') + expect(markup).not.toContain('已同步') + expect(markup).toContain('修改需手动保存') + }) + + it('offers explicit secure resume-link copying without a global project list', () => { + const markup = renderToStaticMarkup(createElement(ProjectSaveView, { + projectName: 'Home', + currentProject: { + id: '00000000-0000-0000-0000-000000000001', name: 'Home', revision: 1, + createdAt: '2026-07-20T00:00:00Z', updatedAt: '2026-07-20T00:00:00Z', + sourceImageURL: '/api/projects/00000000-0000-0000-0000-000000000001/source-image', + sourceImageContentType: 'image/png', sourceImageSize: 3, + document: { filename: 'plan.png', contentType: 'image/png', size: 3, result: { rooms: [], walls: [], doors: [], windows: [], scale: { unit: 'px', pixel_to_unit: null }, metadata: { source: 'fixture', confidence: 0.5, image_width: 100, image_height: 80 } } }, + }, + projectMessage: '', projectMessageTone: 'success', projectBusy: null, canSave: true, + onProjectNameChange: vi.fn(), onSave: vi.fn(), onCopyResumeLink: vi.fn(), + })) + expect(markup).toContain('复制继续编辑链接') + expect(markup).not.toContain('已保存项目') + expect(markup).not.toContain('刷新') + expect(markup).not.toContain('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') + }) +}) diff --git a/frontend/src/ProductViews.tsx b/frontend/src/ProductViews.tsx new file mode 100644 index 0000000..dbbfcdc --- /dev/null +++ b/frontend/src/ProductViews.tsx @@ -0,0 +1,106 @@ +import type { FloorplanEditorPanelProps } from './FloorplanEditorPanel' +import { FloorplanEditorPanel } from './FloorplanEditorPanel' +import type { InspectorPanelProps } from './InspectorPanel' +import { InspectorPanel } from './InspectorPanel' +import type { ThreeDPreviewPanelProps } from './ThreeDPreviewPanel' +import { ThreeDPreviewPanel } from './ThreeDPreviewPanel' + +export type TwoDWorkspaceProps = { + editor: FloorplanEditorPanelProps + inspector: InspectorPanelProps + snapshot: { + exists: boolean + busy: boolean + canSave?: boolean + message: string + messageTone: 'success' | 'error' + onSave: () => void + onReload: () => void + onCopyResumeLink: () => void + } + canAdvance: boolean + onAdvance: () => void +} + +export function WorkspaceToolbar({ inspector, snapshot, linked = false, onAdvance, canAdvance }: Pick & { linked?: boolean }) { + return
+
+ {linked ? '并排视图' : '2D 编辑'} + {linked ? 平面图与空间预览已同步 : 调整完成后可查看 3D} +
+
+ {snapshot.message && {snapshot.message}} + {snapshot.exists && snapshot.messageTone === 'error' && } + {snapshot.exists && } + + + + {linked + ? + : } +
+
+} + +export function TwoDWorkspace({ editor, inspector, snapshot, canAdvance, onAdvance }: TwoDWorkspaceProps) { + return
+} + +type ThreeDUnavailableProps = { + geometryValidationError: string | null + wasmState: 'idle' | 'loading' | 'active' | 'fallback' + onBack: () => void +} + +function ThreeDUnavailablePanel({ geometryValidationError, wasmState, onBack }: ThreeDUnavailableProps) { + return ( +
+ {geometryValidationError ? ( + <>

这张图暂时无法生成空间预览

请返回平面图校正后调整相关位置,再重新生成。

+ ) : wasmState === 'loading' ? ( + <>

正在准备空间预览

准备完成后,你可以继续查看并调整。

+ ) : ( + <>

当前无法显示空间预览

请返回平面图校正后重试,或换用支持 3D 显示的浏览器。

+ )} + +
+ ) +} + +export type ThreeDConfirmationProps = { + preview: ThreeDPreviewPanelProps + previewAvailable: boolean + canOpenLinkedWorkspace: boolean + wasmState: ThreeDUnavailableProps['wasmState'] + onBack: () => void + onComplete: () => void +} + +export function ThreeDConfirmation({ preview, previewAvailable, canOpenLinkedWorkspace, wasmState, onBack, onComplete }: ThreeDConfirmationProps) { + const ready = previewAvailable && canOpenLinkedWorkspace + return ( +
+
+

{ready ? '空间预览已准备好' : '正在准备空间预览'}

{ready ? '可继续编辑' : '正在处理'}
+
{previewAvailable ? : }
+

空间轮廓、房间关系和门窗位置来自当前平面图。层高、墙高和结构属性尚未测量,当前用于帮助你查看空间。

+
+ +
+ ) +} + +export type LinkedWorkspaceProps = TwoDWorkspaceProps & { + preview: ThreeDPreviewPanelProps + previewAvailable: boolean + onBack: () => void +} + +export function LinkedWorkspace({ editor, preview, inspector, snapshot, previewAvailable, canAdvance, onAdvance }: LinkedWorkspaceProps) { + return
{previewAvailable ? :
正在更新空间预览;你可以继续校正 2D。
}
+} diff --git a/frontend/src/ProjectSaveView.tsx b/frontend/src/ProjectSaveView.tsx new file mode 100644 index 0000000..2c38cd0 --- /dev/null +++ b/frontend/src/ProjectSaveView.tsx @@ -0,0 +1,39 @@ +import type { ProjectDetail } from './projects' + +export type ProjectSaveViewProps = { + projectName: string + currentProject: ProjectDetail | null + projectMessage: string + projectMessageTone: 'success' | 'error' + projectBusy: 'save' | 'load' | null + canSave: boolean + onProjectNameChange: (name: string) => void + onSave: () => void + onCopyResumeLink: () => void +} + +export function ProjectSaveView({ + projectName, + currentProject, + projectMessage, + projectMessageTone, + projectBusy, + canSave, + onProjectNameChange, + onSave, + onCopyResumeLink, +}: ProjectSaveViewProps) { + return ( +
+
✓
+

保存为 HomeVox 项目

+

真实户型图、可编辑 2D 与可编辑 3D 会归入同一个 HomeVox 项目。请复制继续编辑链接并妥善保管,获得链接的人可以访问和修改此项目。

+
+ onProjectNameChange(event.target.value)} /> + + {currentProject && } + {projectMessage &&

{projectMessage}

} +
+
+ ) +} diff --git a/frontend/src/SourceImportView.tsx b/frontend/src/SourceImportView.tsx new file mode 100644 index 0000000..378c708 --- /dev/null +++ b/frontend/src/SourceImportView.tsx @@ -0,0 +1,153 @@ +import { useRef, type PointerEvent } from 'react' +import { cropDisplayMetrics, cropKeyboardNudge, cropPointerToImage, moveCrop, resizeCrop } from './cropFlow' + +export type ParseViewStatus = 'idle' | 'uploading' | 'ready' | 'error' + +type SourceFileProps = { + selectedFile: File | null + previewURL: string + onFileChange: (file: File | null) => void +} + +function SourcePreview({ selectedFile, previewURL }: SourceFileProps) { + if (!selectedFile || !previewURL) return null + return ( +
+
+

已选择的户型图

+ 真实输入 +
+ 上传户型图预览 +

{selectedFile.name}

+

图纸中的房间、门窗和尺寸标注会保留,识别后可逐项调整。

+
+ ) +} + +export type SourceImportViewProps = SourceFileProps & { + status: ParseViewStatus + error: string + projectMessage: string + projectMessageTone: 'success' | 'error' + projectBusy: 'save' | 'load' | null + onRetryProjectLoad: () => void +} + +function ImportFacts() { + return
+

本次识别内容

+
+
房间与边界
将在图纸中查找
+
门窗位置
待你校正
+
图中尺寸
保留图中标注
+
朝向
尚未识别
+
层高 / 墙高
尚未测量
+
+
+} + +export function SourceImportView({ selectedFile, previewURL, onFileChange, status, error, projectMessage, projectMessageTone, projectBusy, onRetryProjectLoad }: SourceImportViewProps) { + if (!selectedFile || !previewURL) { + return ( +
+

导入真实户型图

+

从你的图纸开始,不套用示意户型。

+ {projectMessageTone === 'error' && projectMessage &&

{projectMessage}

} + +
+ ) + } + return ( +
+
+ +
+ ) +} + +export type AIParseViewProps = SourceFileProps & { + status: ParseViewStatus + error: string +} + +export function AIParseView({ selectedFile, previewURL, onFileChange, status, error }: AIParseViewProps) { + return ( +
+

正在判断

+

正在根据原图判断可识别的户型区域。

+ +
判断状态:{status === 'ready' ? '判断完成' : status === 'uploading' ? '正在判断' : status === 'error' ? '失败' : '等待图片尺寸'}{error &&

{error}

}
+
+ ) +} + +import type { CandidateDetection, CropHandle, CropRect } from './cropFlow' + +export type CropConfirmViewProps = SourceFileProps & { + imageSize: { width: number; height: number } | null + detection: CandidateDetection | null + crop: CropRect | null + status: ParseViewStatus + error: string + onCropChange: (crop: CropRect) => void + onSelectCandidate: (index: number) => void + onRestoreRecommended: () => void + onResetFullImage: () => void + onConfirm: () => void +} + +export function CropConfirmView({ selectedFile, previewURL, imageSize, detection, crop, status, error, onCropChange, onSelectCandidate, onRestoreRecommended, onResetFullImage, onConfirm }: CropConfirmViewProps) { + const gesture = useRef<{ x: number; y: number; crop: CropRect; handle: CropHandle | 'move' } | null>(null) + const display = imageSize ? cropDisplayMetrics(imageSize) : { handleSize: 14, hitDistance: 18, strokeWidth: 3 } + const handleAt = (x: number, y: number): CropHandle | 'move' => { + const distance = display.hitDistance + const positions: Array<[CropHandle, number, number]> = [['nw', crop!.x, crop!.y], ['n', crop!.x + crop!.width / 2, crop!.y], ['ne', crop!.x + crop!.width, crop!.y], ['e', crop!.x + crop!.width, crop!.y + crop!.height / 2], ['se', crop!.x + crop!.width, crop!.y + crop!.height], ['s', crop!.x + crop!.width / 2, crop!.y + crop!.height], ['sw', crop!.x, crop!.y + crop!.height], ['w', crop!.x, crop!.y + crop!.height / 2]] + return positions.find(([, px, py]) => Math.abs(px - x) <= distance && Math.abs(py - y) <= distance)?.[0] ?? 'move' + } + const pointer = (event: PointerEvent) => { + if (status === 'uploading' || !crop || !imageSize) return + const { x, y } = cropPointerToImage(event, event.currentTarget.getBoundingClientRect(), imageSize) + if (event.type === 'pointerdown') { gesture.current = { x, y, crop, handle: handleAt(x, y) }; event.currentTarget.setPointerCapture(event.pointerId); return } + const active = gesture.current + if (event.type === 'pointermove' && active && event.currentTarget.hasPointerCapture(event.pointerId)) { + const dx = x - active.x; const dy = y - active.y + onCropChange(active.handle === 'move' + ? moveCrop(active.crop, dx, dy, imageSize) + : resizeCrop(active.crop, active.handle, dx, dy, imageSize)) + } + if (event.type === 'pointerup' || event.type === 'pointercancel') gesture.current = null + } + if (!selectedFile || !previewURL || !imageSize || !crop) return null + return
+
+

确认户型区域

{status === 'uploading' ? '当前裁切区域已锁定,判断完成后可继续调整。' : '拖动裁切框或拖动四角和边缘,按方向键可微调。'}

原始像素
+ { if (status === 'uploading') return; const next = cropKeyboardNudge(crop, event.key, imageSize, event.shiftKey ? 10 : 1); if (next) { event.preventDefault(); onCropChange(next) } }}> + + + + {status !== 'uploading' && (['nw','n','ne','e','se','s','sw','w'] as CropHandle[]).map((handle) => { const x = handle.includes('w') ? crop.x : handle.includes('e') ? crop.x + crop.width : crop.x + crop.width / 2; const y = handle.includes('n') ? crop.y : handle.includes('s') ? crop.y + crop.height : crop.y + crop.height / 2; return })} + +
+ +
+} diff --git a/frontend/src/ThreeDPreview.tsx b/frontend/src/ThreeDPreview.tsx new file mode 100644 index 0000000..5a93a6b --- /dev/null +++ b/frontend/src/ThreeDPreview.tsx @@ -0,0 +1,231 @@ +import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { Canvas, useThree, type RootState } from '@react-three/fiber' +import { Grid, Html, OrbitControls } from '@react-three/drei' +import type { BufferGeometry } from 'three' +import { buildWallShellPieces, frameWallShellModel, WINDOW_OPENING_HEIGHT, WINDOW_SILL_HEIGHT, type WallShellModel } from './wallShell' +import { wasmWallMeshPresentation } from './wasmThreeDScene' +import { analyzeCurrentThreeDFrame } from './threeDFrameAnalysis' + +export type ThreeDRenderer = { state: RootState; generation: string } + +type RendererLifecycleProps = { + generation: string + onMount: (renderer: ThreeDRenderer) => void + onUnmount: (generation: string) => void +} + +function RendererLifecycle({ generation, onMount, onUnmount }: RendererLifecycleProps) { + const state = useThree() + const stateRef = useRef(state) + stateRef.current = state + useEffect(() => { + // useThree's root state can publish size/frame updates. Renderer admission + // is a generation lifecycle, not a per-frame lifecycle: remounting the + // same renderer must never clear an already acknowledged pixel frame. + onMount({ state: stateRef.current, generation }) + return () => onUnmount(generation) + }, [generation, onMount, onUnmount]) + return null +} + +function RenderedFrameLifecycle({ generation, onFrameRendered }: { + generation: string + onFrameRendered: (generation: string) => void +}) { + const { gl, invalidate } = useThree() + useEffect(() => { + let firstFrame = 0 + let secondFrame = 0 + // A keyed Canvas has already mounted the current scene, but its mount is + // not evidence that a user-visible pixel buffer exists. Ask R3F for a + // draw, cross two browser frame boundaries, then synchronously read a + // WebGL pixel. A successful read is an actual completed render pass for + // this canvas—not a renderer/effect lifecycle proxy or a wall-clock delay. + invalidate() + firstFrame = requestAnimationFrame(() => { + secondFrame = requestAnimationFrame(() => { + if (gl.domElement.width < 2 || gl.domElement.height < 2) return + const width = gl.domElement.width + const height = gl.domElement.height + const pixels = new Uint8Array(width * height * 4) + const context = gl.getContext() + context.readPixels( + 0, + 0, + width, + height, + context.RGBA, + context.UNSIGNED_BYTE, + pixels, + ) + if (context.getError() !== context.NO_ERROR) return + // Do not admit a background-only or grid-only canvas. The analyzer + // requires structurally sized, distributed canonical wall spans (or + // their selected-violet state), not a small marker or bright grid. + if (analyzeCurrentThreeDFrame(width, height, pixels).accepted) onFrameRendered(generation) + }) + }) + return () => { + cancelAnimationFrame(firstFrame) + cancelAnimationFrame(secondFrame) + } + }, [generation, gl, invalidate, onFrameRendered]) + return null +} + +function CameraFramer({ model }: { model: WallShellModel }) { + const { camera, size, invalidate } = useThree() + const frame = useMemo( + () => frameWallShellModel(model, size.width > 0 && size.height > 0 ? size.width / size.height : undefined), + [model, size.height, size.width], + ) + useLayoutEffect(() => { + camera.position.set(...frame.position) + camera.lookAt(...frame.target) + camera.updateProjectionMatrix() + invalidate() + }, [camera, frame, invalidate]) + return null +} + +export type ThreeDPreviewProps = { + canonicalRevision: string | null + model: WallShellModel + wasmGeometry: BufferGeometry | null + wasmActive: boolean + webGLAvailable: boolean + selectedWallID: string | null + selectedOpeningID: string | null + onSelectWall: (wallID: string) => void + onSelectOpening: (openingID: string) => void + onRendererMount: (renderer: ThreeDRenderer) => void + onRendererUnmount: (generation: string) => void + onFrameRendered: (generation: string) => void +} + +function CanonicalScene({ + model, + wasmGeometry, + wasmActive, + selectedWallID, + selectedOpeningID, + onSelectWall, + onSelectOpening, +}: Omit) { + const wasmWallMesh = wasmWallMeshPresentation(wasmActive, wasmGeometry, model, onSelectWall) + const selectedWallPieces = useMemo( + () => wasmActive && wasmGeometry && selectedWallID ? buildWallShellPieces(model).filter((piece) => piece.wallId === selectedWallID) : [], + [model, selectedWallID, wasmActive, wasmGeometry], + ) + return ( + <> + + + {model.floor && ( + + + + + )} + + + {/* Selection affordance only: success-path walls remain the visible WASM mesh. */} + {wasmWallMesh && selectedWallPieces.map((piece) => ( + + + + + ))} + {wasmWallMesh && ( + { event.stopPropagation(); wasmWallMesh.onSelectAt(event.point.x, event.point.z) }} + onClick={(event) => { event.stopPropagation(); wasmWallMesh.onSelectAt(event.point.x, event.point.z) }} + > + + + )} + + {model.walls.map((wall) => ( + +