From 8d62cfdae63bbfe47c5f37025d5d74b5d5b7ec4d Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 24 Jul 2026 17:17:12 +0800 Subject: [PATCH 01/39] feat: align HomeVox product flow with Penpot parity --- docs/issue-19-design-parity.md | 21 +++ frontend/e2e/production.spec.ts | 15 +- frontend/src/App.css | 235 +++++++++++-------------------- frontend/src/App.tsx | 126 ++++++++++++----- frontend/src/index.css | 7 +- frontend/src/productFlow.test.ts | 17 +++ frontend/src/productFlow.ts | 20 +++ 7 files changed, 252 insertions(+), 189 deletions(-) create mode 100644 docs/issue-19-design-parity.md create mode 100644 frontend/src/productFlow.test.ts create mode 100644 frontend/src/productFlow.ts diff --git a/docs/issue-19-design-parity.md b/docs/issue-19-design-parity.md new file mode 100644 index 0000000..2b01b3a --- /dev/null +++ b/docs/issue-19-design-parity.md @@ -0,0 +1,21 @@ +# Issue #19 design–implementation parity + +Frozen design evidence: Issue #19 comment `5068143912`, 1440 × 960 representative states. +This implementation intentionally uses a controlled fixture only for automated evidence; no user floorplan is stored in this repository. + +| Frozen state | Implementation evidence | Deliberate difference and reason | +| --- | --- | --- | +| 导入 / AI 识别 | Step 1/2 sidebar, browser file picker, image facts, `开始 AI 识别`, loading/error/retry backed by `POST /api/floorplans/parse` | Uses native file input for keyboard and screen-reader support. | +| 校正可编辑 2D | Step 3, original-image overlay, stable wall/opening controls, shared Undo/Redo, Inspector | “补画墙体”和“对象确认” are absent: no verified canonical contract exists. | +| 生成可编辑 3D | Step 4, real R3F/WASM panel and same-document statement | Wall height remains an explicit preview; geometry engine telemetry is test-only, not ordinary UI. | +| 2D / 3D 联动 | Step 5, equal workspace panels, shared selection and one Undo/Redo history | The responsive implementation stacks panels below 1024px for usable zoom and focus order. | +| 保存项目 | Step 6 and Inspector project card use verified create/update/load endpoints | Manual save is shown instead of automatic save because autosave/conflict semantics have no accepted contract. | + +## Exact-head evidence command + +Run `npm --prefix frontend run test:e2e` on the PR head. The production Playwright +suite uses a fixed 1440 × 960 viewport and captures `issue-19-import-ai.png`, +`issue-19-2d-correction.png`, `issue-19-3d-confirm.png`, and +`issue-19-linked-workspace.png` as test artifacts. It also verifies keyboard-visible +native controls, ARIA labels, retry, invalid geometry, WebGL/WASM fallback, and the +same canonical project across save/reload. diff --git a/frontend/e2e/production.spec.ts b/frontend/e2e/production.spec.ts index 3e93ad7..642461c 100644 --- a/frontend/e2e/production.spec.ts +++ b/frontend/e2e/production.spec.ts @@ -6,6 +6,19 @@ const restartURL = process.env.HOMEVOX_E2E_RESTART_URL test.use({ baseURL, viewport: { width: 1440, height: 960 } }) +test('captures the four frozen-design representative states from the production shell', async ({ page }, testInfo) => { + await page.goto('/') + await page.screenshot({ path: testInfo.outputPath('issue-19-import-ai.png'), fullPage: true }) + + await page.goto('/?e2e=wall-fixture') + await page.getByRole('button', { name: '校正 2D' }).click() + await page.screenshot({ path: testInfo.outputPath('issue-19-2d-correction.png'), fullPage: true }) + await page.getByRole('button', { name: '生成 3D' }).click() + await page.screenshot({ path: testInfo.outputPath('issue-19-3d-confirm.png'), fullPage: true }) + await page.getByRole('button', { name: '2D/3D 联动' }).click() + await page.screenshot({ path: testInfo.outputPath('issue-19-linked-workspace.png'), fullPage: true }) +}) + 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) => { @@ -156,7 +169,7 @@ test('runs the real browser image-selection to Vision parse to persisted workspa buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAYAAAC56t6BAAAAF0lEQVR4nGL6////fwZkwARjAAIAAP//YgEEAT/f/TcAAAAASUVORK5CYII=', 'base64'), }) await expect(page.getByAltText('上传户型图预览')).toBeVisible() - await page.getByRole('button', { name: '上传并解析' }).click() + await page.getByRole('button', { name: '开始 AI 识别' }).click() await expect(page.getByText('解析完成')).toBeVisible() await expect(page.getByRole('img', { name: '户型图墙体端点编辑区' })).toBeVisible() await page.getByLabel('项目名称').fill('controlled Vision loop') diff --git a/frontend/src/App.css b/frontend/src/App.css index f90339d..5cbe02f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,184 +1,117 @@ -.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: #16213d; + background: #f4f7fc; } -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } +.product-sidebar { + background: linear-gradient(180deg, #14285d 0%, #1c2461 100%); + color: #fff; +} - .base { - width: 170px; - position: relative; - z-index: 0; - } +.product-sidebar button:focus-visible, +.product-topbar button:focus-visible, +.workspace-card button:focus-visible { + outline: 3px solid #8a7aff; + outline-offset: 2px; +} - .framework, - .vite { - position: absolute; - } +.product-step { + border: 1px solid transparent; + color: #cbd6ff; +} - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } +.product-step[data-active='true'] { + border-color: rgb(255 255 255 / 18%); + color: #fff; + background: rgb(255 255 255 / 13%); +} - .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-locked='true'] { + opacity: .48; } -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; +.step-dot { + display: grid; + width: 25px; + height: 25px; + flex: 0 0 auto; place-items: center; - flex-grow: 1; + border: 1px solid rgb(255 255 255 / 30%); + border-radius: 999px; + font-size: 11px; + font-weight: 700; +} - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } +.product-step[data-active='true'] .step-dot { + border-color: #a99cff; + background: #7767ef; } -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; +.workspace-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 16px; + min-height: 0; +} - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } +.workspace-card { + border: 1px solid #e1e7f2; + border-radius: 16px; + background: #fff; + box-shadow: 0 10px 35px rgb(31 48 88 / 6%); +} - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } +.canvas-card { + min-height: 480px; +} - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } +.three-card { + min-height: 480px; + background: #18213e; } -#docs { - border-right: 1px solid var(--border); +.inspector-card { + min-height: 0; +} - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } +.status-chip { + border: 1px solid #e1e5f3; + border-radius: 999px; + background: #f6f4ff; + color: #6251d8; } -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; +.unknown-note { + border: 1px solid #f4cf8a; + border-radius: 10px; + background: #fffbeb; + color: #845d19; +} - .logo { - height: 18px; +@media (max-width: 1024px) { + .homevox-app { + min-height: 100%; } - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - 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; - } + .homevox-layout { + display: block; } - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } + .product-sidebar { + width: 100%; } -} -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; + .product-sidebar nav { + display: flex; + overflow-x: auto; } -} - -.ticks { - position: relative; - width: 100%; - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; + .product-step { + min-width: 155px; } - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); + .workspace-grid { + grid-template-columns: 1fr; } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ecd18c3..8c82fc1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -49,6 +49,8 @@ import { type ProjectDetail, type ProjectSummary, } from './projects' +import { PRODUCT_STEPS, canOpenStep, nextProductStep, type ProductStep } from './productFlow' +import './App.css' const API_PARSE_URL = '/api/floorplans/parse' const EMPTY_WALLS: WallSegment[] = [] @@ -420,6 +422,7 @@ function hasWebGLSupport(): boolean { } export default function App() { + const [activeStep, setActiveStep] = useState(1) const [selectedFile, setSelectedFile] = useState(null) const [previewURL, setPreviewURL] = useState('') const [parseResponse, setParseResponse] = useState(null) @@ -777,6 +780,7 @@ export default function App() { if (parseRequestRef.current?.id !== requestId) return setParseResponse(body) + setActiveStep(3) setCurrentProject(null) setProjectName((current) => current || body.filename) setStatus('ready') @@ -821,6 +825,7 @@ export default function App() { setStatus('idle') setImageDimFallback(null) setCurrentProject(null) + setActiveStep(file ? 2 : 1) setPreviewURL((currentURL) => { if (currentURL) URL.revokeObjectURL(currentURL) @@ -1167,18 +1172,65 @@ export default function App() { } return ( -
-
- 筑居 HomeVox — 2D 校正、3D 墙体白模与 PNG 导出 - 0.0.0.0:18088 API · React/R3F Viewport -
+
+
+ - ) + const sourcePreview = previewURL && selectedFile && ( +
+

原图预览

{selectedFile.name} · {selectedFile.type || '未知类型'} · {selectedFile.size.toLocaleString()} bytes

+ 上传户型图预览 +
+ ) + const savePanel = (

保存项目

手动保存当前同源 2D 与 3D 数据。保存不可用时会保留当前编辑,不会假装成功。

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

{projectMessage}

}

已保存项目

    {projects.map((item) =>
  • {item.name} r{item.revision}
  • )}{projects.length === 0 &&
  • 暂无已保存项目
  • }
) @@ -1327,8 +1239,8 @@ export default function App() {

步骤 {activeStep} / 6

{PRODUCT_STEPS[activeStep - 1].label}

{durableDocument && 同一份空间数据}{(activeStep === 1 || activeStep === 3 || activeStep === 5) && }
- {activeStep === 1 &&

导入真实户型图

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

{previewURL && 上传户型图预览}
} - {activeStep === 2 &&

AI 识别

识别完成后才会打开可校正的同源 2D 数据。

识别状态:{status === 'ready' ? '解析完成' : status === 'uploading' ? '解析中' : status === 'error' ? '失败' : '等待开始'}{error &&

{error}

}
{status === 'error' && selectedFile && }
} + {activeStep === 1 &&

导入真实户型图

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

{sourcePreview}
} + {activeStep === 2 &&

AI 识别

识别前请核对这张原图;识别完成后才会打开可校正的同源 2D 数据。

{sourcePreview}
识别状态:{status === 'ready' ? '解析完成' : status === 'uploading' ? '解析中' : status === 'error' ? '失败' : '等待开始'}{error &&

{error}

}
{status === 'error' && selectedFile && }
} {activeStep === 3 &&
{twoDPanel}{editorInspector}
} {activeStep === 4 &&

确认 3D 空间

这是同一份已校正 2D 数据生成的真实 3D 预览。

{canOpenLinkedWorkspace && }
{canOpenLinkedWorkspace ? threeDPanel : threeDUnavailablePanel}
} {activeStep === 5 && (canOpenLinkedWorkspace ?
{twoDPanel}{threeDPanel}{editorInspector}
:

当前 3D 预览不可用

联动工作台已关闭,请先返回 2D 校正。

)} diff --git a/frontend/src/e2eBridge.disabled.ts b/frontend/src/e2eBridge.disabled.ts new file mode 100644 index 0000000..40c4654 --- /dev/null +++ b/frontend/src/e2eBridge.disabled.ts @@ -0,0 +1,6 @@ +import type { WasmBindings } from './wasmMarchingCubes' + +export function isE2EInstrumentationEnabled(): boolean { return false } +export function e2EProjectID(): string | null { return null } +export function e2EWasmLoader(): (() => Promise) | undefined { return undefined } +export function publishE2EState(_state: unknown): void {} diff --git a/frontend/src/e2eBridge.ts b/frontend/src/e2eBridge.ts new file mode 100644 index 0000000..7e90c17 --- /dev/null +++ b/frontend/src/e2eBridge.ts @@ -0,0 +1,32 @@ +import type { WasmBindings } from './wasmMarchingCubes' + +declare global { + interface Window { + __homevoxE2E?: unknown + } +} + +function enabled(): boolean { + return typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('e2e') +} + +export function isE2EInstrumentationEnabled(): boolean { + return enabled() +} + +export function e2EProjectID(): string | null { + if (!enabled()) return null + const value = new URLSearchParams(window.location.search).get('project') + return value && /^[0-9a-f-]{36}$/i.test(value) ? value : null +} + +export function e2EWasmLoader(): (() => Promise) | undefined { + if (enabled() && new URLSearchParams(window.location.search).get('wasm') === 'load-failure') { + return async () => { throw new Error('test-only WASM loader failure') } + } + return undefined +} + +export function publishE2EState(state: unknown): void { + if (enabled()) window.__homevoxE2E = state +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 6830b6f..d804fe1 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -5,6 +5,9 @@ "lib": ["ES2023", "DOM"], "module": "esnext", "types": ["vite/client"], + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { "@homevox-e2e": ["src/e2eBridge.disabled.ts"] }, "allowArbitraryExtensions": true, "skipLibCheck": true, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 21762d9..f8ab372 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,10 +3,13 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { resolve } from 'node:path' +const e2eBridge = process.env.VITE_HOMEVOX_E2E === '1' ? 'e2eBridge.ts' : 'e2eBridge.disabled.ts' + export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { + '@homevox-e2e': resolve(__dirname, `src/${e2eBridge}`), '@homevox-wasm': resolve(__dirname, '../wasm/pkg/homevox_wasm.js'), }, }, diff --git a/scripts/run-production-browser-e2e.sh b/scripts/run-production-browser-e2e.sh index 563f037..aae1d68 100755 --- a/scripts/run-production-browser-e2e.sh +++ b/scripts/run-production-browser-e2e.sh @@ -57,6 +57,14 @@ done ! docker network inspect "$NET" >/dev/null 2>&1 || { echo "refusing existing network $NET" >&2; exit 1; } npm --prefix "$ROOT/frontend" run build +for test_only_marker in '__homevoxE2E' 'wasm=load-failure' 'test-only WASM loader failure'; do + if grep -RIl --include='*.js' "$test_only_marker" "$ROOT/frontend/dist" >/dev/null; then + echo "production_bundle_test_only_leak=FAIL marker=$test_only_marker" >&2 + exit 1 + fi +done +echo production_bundle_test_only_leak=PASS +VITE_HOMEVOX_E2E=1 npm --prefix "$ROOT/frontend" run build ( cd "$ROOT/backend" CGO_ENABLED=0 go build -o "$OUT/homevox-server" ./cmd/server From b43889809a8326c83e9c5a0da2b2f7c3186f7360 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 25 Jul 2026 12:19:00 +0800 Subject: [PATCH 07/39] fix: fail closed on degenerate canonical walls --- frontend/e2e/production.spec.ts | 83 ++++++++++++++++++++++++++++++-- frontend/src/App.tsx | 38 +++++++-------- frontend/src/floorplanUi.test.ts | 10 ++++ frontend/src/floorplanUi.ts | 20 ++++++++ frontend/src/wallShell.test.ts | 40 +++++++-------- frontend/src/wallShell.ts | 17 +++---- frontend/src/wallVoxel.test.ts | 2 +- 7 files changed, 153 insertions(+), 57 deletions(-) diff --git a/frontend/e2e/production.spec.ts b/frontend/e2e/production.spec.ts index 7673d49..b49be3b 100644 --- a/frontend/e2e/production.spec.ts +++ b/frontend/e2e/production.spec.ts @@ -1,5 +1,6 @@ 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' @@ -56,6 +57,55 @@ async function screenshot(page: Page, testInfo: TestInfo, name: string): Promise return path } +/** Decode a Playwright PNG screenshot to assert what a person can see, rather + * than treating canvas existence or an instrumented geometry object as proof. */ +function visibleLightPixels(png: Buffer): number { + 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 + } + } + let visible = 0 + for (let y = Math.floor(height * 0.2); y < Math.floor(height * 0.8); y += 1) { + for (let x = Math.floor(width * 0.2); x < Math.floor(width * 0.8); x += 1) { + const index = y * stride + x * bytesPerPixel + if (pixels[index] > 175 && pixels[index + 1] > 175 && pixels[index + 2] > 175 && (bytesPerPixel === 3 || pixels[index + 3] > 0)) visible += 1 + } + } + return visible +} + +async function assertThreeDRenderIsVisible(page: Page, testInfo: TestInfo, name: string): Promise { + const path = testInfo.outputPath(name) + await page.getByTestId('three-render-surface').screenshot({ path }) + expect(visibleLightPixels(await readFile(path))).toBeGreaterThan(120) +} + async function e2eState(page: Page): Promise { return page.evaluate(() => window.__homevoxE2E as E2EState) } @@ -114,6 +164,7 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod await expect(page.getByText('已生成可审阅的同源 3D 几何')).toBeVisible() await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() await expect(page.getByLabel('2D 墙体编辑器')).toHaveCount(0) + await assertThreeDRenderIsVisible(page, testInfo, 'issue-19-3d-user-visible.png') captures.push(await screenshot(page, testInfo, 'issue-19-3d-confirm.png')) await page.getByRole('button', { name: '完成并打开 3D' }).click() @@ -199,12 +250,11 @@ test('keeps invalid and duplicate canonical identity failures closed', async ({ 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 page.getByRole('button', { name: '继续' }).click() - await expect(page.getByRole('alert')).toContainText('当前开口数据无法生成 3D') + await expect(page.getByRole('alert')).toContainText('opening references a missing or degenerate wall') + await expect(page.getByRole('button', { name: '继续' })).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 page.getByRole('button', { name: '返回 2D 校正' }).first().click() await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() await page.unroute('**/api/floorplans/parse') @@ -212,13 +262,36 @@ test('keeps invalid and duplicate canonical identity failures closed', async ({ 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 page.getByRole('button', { name: '继续' }).click() - await expect(page.getByRole('alert')).toContainText('当前开口数据无法生成 3D') + await expect(page.getByRole('alert')).toContainText('wall id must be unique') + await expect(page.getByRole('button', { name: '继续' })).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('fails closed after a 2D endpoint is dragged into 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) + const zeroLengthWall = afterDrag.walls.find((wall) => wall.id === 'wall-1') + expect(zeroLengthWall).toBeDefined() + expect(Math.hypot((zeroLengthWall?.x2 ?? 0) - (zeroLengthWall?.x1 ?? 0), (zeroLengthWall?.y2 ?? 0) - (zeroLengthWall?.y1 ?? 0))).toBeLessThan(1e-3) + await expect(page.getByText('wall length must be strictly greater than zero', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '继续' })).toBeDisabled() + await expect(page.getByRole('button', { name: '生成 3D' })).toBeDisabled() + await expect(page.getByRole('button', { name: '2D/3D 联动' })).toBeDisabled() + await expect(page.getByRole('button', { name: '保存项目' })).toBeDisabled() + 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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c168e46..54026a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,7 +23,7 @@ import { isParseResponse, openingLabel, openingPoint, - validateOpenings, + validateCanonicalFloorplan, MIN_OPENING_WIDTH, type ParsedOpening, type ParseResponse, @@ -417,7 +417,7 @@ export default function App() { parseResponse ? { ...parseResponse, result: { ...parseResponse.result, walls, doors, windows } } : null ), [parseResponse, walls, doors, windows]) const geometryValidationError = useMemo( - () => validateOpenings(walls, openings), + () => validateCanonicalFloorplan(walls, openings), [walls, openings], ) const hasCanonicalGeometry = Boolean(durableDocument) && !geometryValidationError @@ -798,6 +798,10 @@ export default function App() { setProjectMessage('请先完成户型解析后再创建项目') return } + if (geometryValidationError) { + setProjectMessage('当前户型几何无效,请返回 2D 校正后再保存') + return + } if (!projectName.trim()) { setProjectMessage('请输入项目名称') return @@ -918,15 +922,10 @@ export default function App() { if (draggedEndpoint) { const moveResult = moveEndpoint(wallEditor, draggedEndpoint, cursor) const openingValidationError = moveResult.changed - ? validateOpenings(moveResult.walls, wallEditor.openings) + ? validateCanonicalFloorplan(moveResult.walls, wallEditor.openings) : null - if (openingValidationError) { - setDragPreviewWalls(null) - setOpeningError(openingValidationError) - return - } setDragPreviewWalls(moveResult.changed ? moveResult.walls : null) - setOpeningError('') + setOpeningError(openingValidationError ?? '') return } @@ -1034,13 +1033,9 @@ export default function App() { return } if (draggedEndpoint && dragPreviewWalls) { - const openingValidationError = validateOpenings(dragPreviewWalls, wallEditor.openings) - if (openingValidationError) { - setOpeningError(openingValidationError) - } else { - setWallEditor(pushWallSnapshot(wallEditor, dragPreviewWalls, wallEditor.openings)) - setOpeningError('') - } + const openingValidationError = validateCanonicalFloorplan(dragPreviewWalls, wallEditor.openings) + setWallEditor(pushWallSnapshot(wallEditor, dragPreviewWalls, wallEditor.openings)) + setOpeningError(openingValidationError ?? '') } if (draggedOpeningID && dragPreviewOpenings) setWallEditor(pushWallSnapshot(wallEditor, wallEditor.walls, dragPreviewOpenings)) setDraggedEndpoint(null) @@ -1129,6 +1124,7 @@ export default function App() { {draggedEndpoint ? '拖拽中' : selectedWall ? `已选择 ${selectedWall.id}` : hoveredEndpoint ? '可拖拽端点(鼠标悬停)' : '选择墙体或拖拽端点'}
+ {geometryValidationError &&

当前户型几何无法生成 3D 或保存:{geometryValidationError}。请继续在 2D 校正。

}
@@ -1175,8 +1171,8 @@ export default function App() { {geometryValidationError &&

当前开口数据无法生成 3D,请返回 2D 校正后重试。

}
- {webGLAvailable ? - + {webGLAvailable ? + :
当前浏览器无法显示 3D 预览。请在启用 WebGL 的浏览器中打开;2D 校正仍可继续。
}
墙体高度为示意;精确高度、承重属性、墙厚与窗台高度需实测。
@@ -1225,7 +1221,7 @@ export default function App() { ) const savePanel = ( -

保存项目

手动保存当前同源 2D 与 3D 数据。保存不可用时会保留当前编辑,不会假装成功。

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

{projectMessage}

}

已保存项目

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

保存项目

手动保存当前同源 2D 与 3D 数据。保存不可用时会保留当前编辑,不会假装成功。

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

{projectMessage}

}

已保存项目

    {projects.map((item) =>
  • {item.name} r{item.revision}
  • )}{projects.length === 0 &&
  • 暂无已保存项目
  • }
) const goNext = () => { @@ -1236,8 +1232,8 @@ export default function App() { return (
- -

步骤 {activeStep} / 6

{PRODUCT_STEPS[activeStep - 1].label}

{durableDocument && 同一份空间数据}{(activeStep === 1 || activeStep === 3 || activeStep === 5) && }
+ +

步骤 {activeStep} / 6

{PRODUCT_STEPS[activeStep - 1].label}

{durableDocument && 同一份空间数据}{(activeStep === 1 || activeStep === 3 || activeStep === 5) && }
{activeStep === 1 &&

导入真实户型图

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

{sourcePreview}
} {activeStep === 2 &&

AI 识别

识别前请核对这张原图;识别完成后才会打开可校正的同源 2D 数据。

{sourcePreview}
识别状态:{status === 'ready' ? '解析完成' : status === 'uploading' ? '解析中' : status === 'error' ? '失败' : '等待开始'}{error &&

{error}

}
{status === 'error' && selectedFile && }
} diff --git a/frontend/src/floorplanUi.test.ts b/frontend/src/floorplanUi.test.ts index f253c71..23e13ea 100644 --- a/frontend/src/floorplanUi.test.ts +++ b/frontend/src/floorplanUi.test.ts @@ -4,6 +4,7 @@ import { canvasUnitsForCssPixels, isParseResponse, openingLabel, + validateCanonicalFloorplan, } from './floorplanUi' const validResponse = { @@ -104,3 +105,12 @@ describe('durable local wall openings', () => { expect(validateOpenings(loadedDocument.result.walls, [...loadedDocument.result.doors, ...loadedDocument.result.windows])).toContain('wall id must be unique') }) }) + + +describe('canonical floorplan admission', () => { + it('rejects any missing stable ID, non-finite coordinate, or zero-length wall before 3D or persistence', () => { + expect(validateCanonicalFloorplan([{ id: 'wall-a', x1: 0, y1: 0, x2: 0, y2: 0 }], [])).toContain('strictly greater than zero') + expect(validateCanonicalFloorplan([{ x1: 0, y1: 0, x2: 100, y2: 0 }], [])).toContain('stable id') + expect(validateCanonicalFloorplan([{ id: 'wall-a', x1: 0, y1: 0, x2: Number.NaN, y2: 0 }], [])).toContain('finite') + }) +}) diff --git a/frontend/src/floorplanUi.ts b/frontend/src/floorplanUi.ts index d9e74dd..0bdf941 100644 --- a/frontend/src/floorplanUi.ts +++ b/frontend/src/floorplanUi.ts @@ -19,6 +19,8 @@ export type ParseResponse = { filename: string; contentType: string; size: numbe export type Viewport = { minX: number; minY: number; width: number; height: number } export type CanvasSize = { width: number; height: number } export const MIN_OPENING_WIDTH = 8 +/** Reject numerically collapsed walls before geometry and persistence diverge. */ +export const MIN_CANONICAL_WALL_LENGTH = 1e-3 function record(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v) } function finite(v: unknown): v is number { return typeof v === 'number' && Number.isFinite(v) } @@ -41,6 +43,24 @@ export function wallLength(wall: WallSegment): number { return Math.hypot(wall.x function finiteWallGeometry(wall: WallSegment): boolean { return [wall.x1, wall.y1, wall.x2, wall.y2].every(finite) } export function openingPoint(wall: WallSegment, item: ParsedOpening): {x:number;y:number}|null { if (!finite(item.position) || !finite(wall.x1) || !finite(wall.y1) || !finite(wall.x2) || !finite(wall.y2)) return null; return {x: wall.x1+(wall.x2-wall.x1)*item.position, y:wall.y1+(wall.y2-wall.y1)*item.position} } /** Reject invalid state before it enters persistence or geometry. */ +/** + * The sole admission gate for a durable canonical floorplan. 2D may retain + * an invalid edit so a user can correct it, but 3D and persistence must never + * consume a partial, anonymous, or degenerate document. + */ +export function validateCanonicalFloorplan(walls: readonly WallSegment[], openings: readonly ParsedOpening[]): string | null { + if (walls.length === 0) return 'floorplan must contain at least one wall' + const wallIDs = new Set() + for (const item of walls) { + if (!id(item.id)) return 'wall must have a stable id' + if (wallIDs.has(item.id)) return 'wall id must be unique' + wallIDs.add(item.id) + if (!finiteWallGeometry(item)) return 'wall coordinates must be finite' + if (!(wallLength(item) > MIN_CANONICAL_WALL_LENGTH)) return 'wall length must be strictly greater than zero' + } + return validateOpenings(walls, openings) +} + export function validateOpenings(walls: readonly WallSegment[], openings: readonly ParsedOpening[]): string | null { const explicitWallIDs=new Set() for(const wall of walls) { diff --git a/frontend/src/wallShell.test.ts b/frontend/src/wallShell.test.ts index 71b520c..3ee305d 100644 --- a/frontend/src/wallShell.test.ts +++ b/frontend/src/wallShell.test.ts @@ -3,10 +3,10 @@ import { buildWallShellModel } from './wallShell' import type { ParsedOpening } from './floorplanUi' const rectangleWalls = [ - { x1: 0, y1: 0, x2: 100, y2: 0 }, - { x1: 100, y1: 0, x2: 100, y2: 80 }, - { x1: 100, y1: 80, x2: 0, y2: 80 }, - { x1: 0, y1: 80, x2: 0, y2: 0 }, + { id: 'wall-1', x1: 0, y1: 0, x2: 100, y2: 0 }, + { id: 'wall-2', x1: 100, y1: 0, x2: 100, y2: 80 }, + { id: 'wall-3', x1: 100, y1: 80, x2: 0, y2: 80 }, + { id: 'wall-4', x1: 0, y1: 80, x2: 0, y2: 0 }, ] describe('3D wall shell model', () => { @@ -23,7 +23,7 @@ describe('3D wall shell model', () => { }) it('calculates diagonal wall length, center, and rotation from the edited segment', () => { - const model = buildWallShellModel([{ x1: 10, y1: 20, x2: 40, y2: 60 }], [], []) + const model = buildWallShellModel([{ id: 'wall-diagonal', x1: 10, y1: 20, x2: 40, y2: 60 }], [], []) expect(model.walls).toHaveLength(1) expect(model.walls[0].x).toBeCloseTo(0) @@ -32,20 +32,19 @@ describe('3D wall shell model', () => { expect(model.walls[0].rotationY).toBeCloseTo(-Math.atan2(40, 30)) }) - it('ignores invalid and zero-length walls without producing non-finite geometry', () => { + it('fails closed instead of silently omitting invalid or zero-length walls', () => { const model = buildWallShellModel( [ - { x1: 0, y1: 0, x2: 0, y2: 0 }, - { x1: 0, y1: 0, x2: Number.NaN, y2: 10 }, - { x1: 0, y1: 0, x2: 10, y2: 0 }, + { id: 'wall-zero', x1: 0, y1: 0, x2: 0, y2: 0 }, + { id: 'wall-nan', x1: 0, y1: 0, x2: Number.NaN, y2: 10 }, + { id: 'wall-valid', x1: 0, y1: 0, x2: 10, y2: 0 }, ], [], [], ) - expect(model.walls).toHaveLength(1) - expect(model.walls[0].sourceIndex).toBe(2) - expect(Object.values(model.walls[0]).filter((value) => typeof value === 'number').every(Number.isFinite)).toBe(true) + expect(model.walls).toEqual([]) + expect(model.validationError).toContain('strictly greater than zero') }) it('keeps legacy parse-only markers out of durable opening geometry', () => { @@ -55,7 +54,7 @@ describe('3D wall shell model', () => { [{ type: 'window', x: 100, y: 40 }], ) - expect(model.walls).toHaveLength(4) + expect(model.walls).toEqual([]) expect(model.openings).toEqual([]) expect(model.validationError).toContain('opening id') }) @@ -72,7 +71,7 @@ describe('3D wall shell model', () => { ])('fails closed for finite but illegal %s openings', (_caseName, openings, error) => { const model = buildWallShellModel([{ id: 'wall-a', x1: 0, y1: 0, x2: 100, y2: 0 }], openings, []) - expect(model.walls).toHaveLength(1) + expect(model.walls).toEqual([]) expect(model.openings).toEqual([]) expect(model.validationError).toContain(error) }) @@ -87,27 +86,27 @@ describe('3D wall shell model', () => { [], ) - expect(model.walls).toHaveLength(2) + expect(model.walls).toEqual([]) expect(model.openings).toEqual([]) expect(model.validationError).toContain('wall id must be unique') }) it('rejects finite inputs whose normalization would overflow', () => { const model = buildWallShellModel( - [{ x1: 0, y1: 0, x2: Number.MIN_VALUE, y2: 0 }], - [{ type: 'door', x: Number.MIN_VALUE, y: 0 }], + [{ id: 'wall-tiny', x1: 0, y1: 0, x2: Number.MIN_VALUE, y2: 0 }], + [], [], ) - expect(model).toEqual({ walls: [], openings: [], floor: null, scale: null, validationError: null }) + expect(model).toEqual({ walls: [], openings: [], floor: null, scale: null, validationError: 'wall length must be strictly greater than zero' }) }) it('keeps all outputs finite for very large finite coordinates', () => { const start = Number.MAX_VALUE / 4 const delta = 1e292 const model = buildWallShellModel( - [{ x1: start, y1: start, x2: start + delta, y2: start }], - [{ type: 'door', x: start, y: start }], + [{ id: 'wall-large', x1: start, y1: start, x2: start + delta, y2: start }], + [], [], ) @@ -127,5 +126,6 @@ describe('3D wall shell model', () => { expect(model.walls).toEqual([]) expect(model.openings).toEqual([]) expect(model.floor).toBeNull() + expect(model.validationError).toContain('at least one wall') }) }) diff --git a/frontend/src/wallShell.ts b/frontend/src/wallShell.ts index cd2c0a7..a7fdcfb 100644 --- a/frontend/src/wallShell.ts +++ b/frontend/src/wallShell.ts @@ -1,5 +1,5 @@ import type { WallSegment } from './floorplanEditor' -import { openingLabel, validateOpenings, type ParsedOpening } from './floorplanUi' +import { openingLabel, validateCanonicalFloorplan, type ParsedOpening } from './floorplanUi' export const WALL_SHELL_HEIGHT = 2.8 export const WALL_SHELL_THICKNESS = 0.18 @@ -78,9 +78,14 @@ export function buildWallShellModel( doors: readonly ParsedOpening[], windows: readonly ParsedOpening[], ): WallShellModel { + const validationError = validateCanonicalFloorplan(walls, [...doors, ...windows]) + if (validationError) { + return { ...emptyWallShellModel(), validationError } + } + const valid = validWalls(walls) if (valid.length === 0) { - return emptyWallShellModel() + return { ...emptyWallShellModel(), validationError: 'floorplan must contain at least one wall' } } const xs = valid.flatMap((wall) => [wall.x1, wall.x2]) @@ -130,14 +135,6 @@ export function buildWallShellModel( return emptyWallShellModel() } - // This is the single geometry admission gate. Never normalize, voxelize, or - // pass an opening to WASM unless the same durable document accepted by editing - // and persistence passes the canonical validator. - const validationError = validateOpenings(walls, [...doors, ...windows]) - if (validationError) { - return { walls: normalizedWalls, openings: [], floor, scale, validationError } - } - const normalizedOpenings: WallShellOpening[] = [] const wallByID = new Map(valid.map((wall) => [wall.id ?? `wall-${wall.sourceIndex + 1}`, wall])) const normalizedWallBySourceIndex = new Map(normalizedWalls.map((wall) => [wall.sourceIndex, wall])) diff --git a/frontend/src/wallVoxel.test.ts b/frontend/src/wallVoxel.test.ts index a6bb60d..538b668 100644 --- a/frontend/src/wallVoxel.test.ts +++ b/frontend/src/wallVoxel.test.ts @@ -9,7 +9,7 @@ function voxelAt(model: NonNullable>, xIn describe('buildWallVoxelModel', () => { it('creates a finite, controlled 17³ field from editable walls', () => { - const model = buildWallVoxelModel([{ x1: 0, y1: 0, x2: 300, y2: 0 }]) + const model = buildWallVoxelModel([{ id: 'wall-a', x1: 0, y1: 0, x2: 300, y2: 0 }]) expect(model).not.toBeNull() expect(model?.dimensions).toEqual([WALL_VOXEL_GRID_SIZE, WALL_VOXEL_GRID_SIZE, WALL_VOXEL_GRID_SIZE]) From a91f597b9eaafb0de366c05517cad02eee249378 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 25 Jul 2026 12:34:28 +0800 Subject: [PATCH 08/39] fix: preserve wasm openings and show completed steps --- frontend/e2e/production.spec.ts | 50 +++++++++++++++++++++++++++++--- frontend/src/App.css | 15 ++++++++++ frontend/src/App.tsx | 43 +++++++++++++++++---------- frontend/src/productFlow.test.ts | 16 +++++++++- frontend/src/productFlow.ts | 20 +++++++++++++ 5 files changed, 124 insertions(+), 20 deletions(-) diff --git a/frontend/e2e/production.spec.ts b/frontend/e2e/production.spec.ts index b49be3b..cc5814f 100644 --- a/frontend/e2e/production.spec.ts +++ b/frontend/e2e/production.spec.ts @@ -59,7 +59,7 @@ async function screenshot(page: Page, testInfo: TestInfo, name: string): Promise /** Decode a Playwright PNG screenshot to assert what a person can see, rather * than treating canvas existence or an instrumented geometry object as proof. */ -function visibleLightPixels(png: Buffer): number { +function decodePngPixels(png: Buffer): { width: number; height: number; bytesPerPixel: number; pixels: Buffer } { let offset = 8 let width = 0 let height = 0 @@ -90,6 +90,12 @@ function visibleLightPixels(png: Buffer): number { 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 } +} + +function visibleLightPixels(png: Buffer): number { + const { width, height, bytesPerPixel, pixels } = decodePngPixels(png) + const stride = width * bytesPerPixel let visible = 0 for (let y = Math.floor(height * 0.2); y < Math.floor(height * 0.8); y += 1) { for (let x = Math.floor(width * 0.2); x < Math.floor(width * 0.8); x += 1) { @@ -106,6 +112,28 @@ async function assertThreeDRenderIsVisible(page: Page, testInfo: TestInfo, name: expect(visibleLightPixels(await readFile(path))).toBeGreaterThan(120) } +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 = decodePngPixels(await readFile(beforePath)) + const after = decodePngPixels(await readFile(afterPath)) + expect(after.width).toBe(before.width) + expect(after.height).toBe(before.height) + expect(after.bytesPerPixel).toBe(before.bytesPerPixel) + let changed = 0 + for (let index = 0; index < before.pixels.length; index += before.bytesPerPixel) { + if (Math.abs(before.pixels[index] - after.pixels[index]) > 8 || Math.abs(before.pixels[index + 1] - after.pixels[index + 1]) > 8 || Math.abs(before.pixels[index + 2] - after.pixels[index + 2]) > 8) changed += 1 + } + // wall-2 owns window-1. Selecting its stable wallId must not repaint the + // WASM canvas with an uncut shell over the real opening. + expect(changed / (before.width * before.height)).toBeLessThan(0.01) +} + async function e2eState(page: Page): Promise { return page.evaluate(() => window.__homevoxE2E as E2EState) } @@ -151,13 +179,18 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod await page.goto('/?e2e=instrument') await expect(page.getByRole('heading', { name: '导入真实户型图' })).toBeVisible() await selectFile(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) + await expect(page.getByRole('button', { name: /AI 识别,已完成/ })).toBeVisible() + await expect(page.getByRole('button', { name: /校正 2D,当前步骤/ })).toBeVisible() await page.getByTestId('wall-hit-wall-1').click({ position: { x: 80, y: 1 }, force: true }) await expect(page.getByTestId('selected-wall-id')).toHaveText('wall-1') captures.push(await screenshot(page, testInfo, 'issue-19-2d-correction.png')) await page.getByRole('button', { name: '继续' }).click() + await expect(page.getByRole('button', { name: /校正 2D,已完成/ })).toBeVisible() await expect(page.getByRole('heading', { name: '确认 3D 空间' })).toBeVisible() await expect(page.getByLabel('3D 户型预览')).toBeVisible() await expect(page.locator('canvas')).toBeVisible() @@ -168,9 +201,10 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod captures.push(await screenshot(page, testInfo, 'issue-19-3d-confirm.png')) 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 page.getByTestId('three-wall-wall-2').click() + await assertSelectingOpeningWallKeepsCanvasPixels(page, testInfo) await expect(page.getByTestId('selected-wall-id')).toHaveText('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') @@ -206,6 +240,9 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod await page.waitForFunction((before) => window.__homevoxE2E?.geometry.fingerprint === before, geometryBeforeEndpointEdit.geometry.fingerprint) await page.getByRole('button', { name: '重做(Ctrl/Cmd + Shift+Z 或 Ctrl/Cmd + Y)' }).click() await page.waitForFunction((after) => window.__homevoxE2E?.geometry.fingerprint === after, geometryAfterEndpointEdit.geometry.fingerprint) + await page.getByRole('button', { name: '继续' }).click() + await expect(page.getByRole('button', { name: /2D\/3D 联动,已完成/ })).toBeVisible() + await expect(page.getByRole('button', { name: /保存项目,当前步骤/ })).toBeVisible() captures.push(await screenshot(page, testInfo, 'issue-19-linked-workspace.png')) const hashes = await Promise.all(captures.map(async (path) => createHash('sha256').update(await readFile(path)).digest('hex'))) expect(new Set(hashes).size).toBe(4) @@ -219,6 +256,7 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod 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(1) + 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: 60 })])) @@ -229,8 +267,11 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod expect(pids.newPid).not.toBe(pids.oldPid) await page.goto(`/?e2e=instrument&project=${savedProject.id}`) - await expect(page.getByRole('button', { name: '校正 2D' })).toBeEnabled() - await page.getByRole('button', { name: '校正 2D' }).click() + await expect(page.getByRole('button', { name: /校正 2D,当前步骤/ })).toBeEnabled() + await expect(page.getByRole('button', { name: /导入户型图,已完成/ })).toBeVisible() + await expect(page.getByRole('button', { name: /AI 识别,已完成/ })).toBeVisible() + await expect(page.getByRole('button', { name: /保存项目,已完成/ })).toBeVisible() + await page.getByRole('button', { name: /校正 2D,当前步骤/ }).click() await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() await page.getByTestId('opening-handle-window-1').click({ force: true }) await expect(page.getByTestId('opening-width')).toHaveValue('60') @@ -311,6 +352,7 @@ test('makes parse retry and persistence-unavailable states actionable', async ({ await page.getByRole('button', { name: '开始 AI 识别' }).click() await expect(page.getByRole('alert')).toContainText('HTTP 503') await expect(page.getByRole('button', { name: '重试 AI 识别' })).toBeVisible() + await expect(page.getByRole('button', { name: /AI 识别,已完成/ })).toHaveCount(0) await page.unroute('**/api/floorplans/parse') await parseSelectedFile(page) await page.route('**/api/projects', (route) => route.request().method() === 'POST' diff --git a/frontend/src/App.css b/frontend/src/App.css index 11c3b82..75039cc 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -27,6 +27,21 @@ background: rgb(255 255 255 / 13%); } +.product-step[data-completed='true'] { + color: #f0edff; +} + +.product-step[data-completed='true'] .step-dot { + border-color: #7ee7bf; + background: #16775b; + color: #fff; +} + +.product-step[data-active='true'][data-completed='true'] .step-dot { + border-color: #c6bdff; + background: #7767ef; +} + .product-step[data-locked='true'] { opacity: .48; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54026a6..40fb3f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -49,7 +49,7 @@ import { type ProjectDetail, type ProjectSummary, } from './projects' -import { PRODUCT_STEPS, canOpenStep, type ProductStep } from './productFlow' +import { PRODUCT_STEPS, canOpenStep, completeProductStep, initialCompletedSteps, type ProductStep } from './productFlow' import { e2EProjectID, e2EWasmLoader, isE2EInstrumentationEnabled, publishE2EState } from '@homevox-e2e' import './App.css' @@ -219,19 +219,21 @@ function Scene({ model, wasmGeometry, wasmActive, selectedWallID, selectedOpenin { event.stopPropagation(); onSelectWall(wall.id) }} onClick={(event) => { event.stopPropagation(); onSelectWall(wall.id) }} > - + {wasmActive ? ( + + ) : ( + + )} {projectMessage &&

{projectMessage}

}

已保存项目

    {projects.map((item) =>
  • {item.name} r{item.revision}
  • )}{projects.length === 0 &&
  • 暂无已保存项目
  • }
) + const completeAndAdvance = (step: ProductStep, next: ProductStep) => { + setCompletedSteps((current) => completeProductStep(current, step)) + setActiveStep(next) + } + const goNext = () => { - if (activeStep === 1 && selectedFile) setActiveStep(2) - else if (activeStep === 3) setActiveStep(4) - else if (activeStep === 5) setActiveStep(6) + if (activeStep === 1 && selectedFile) completeAndAdvance(1, 2) + else if (activeStep === 3) completeAndAdvance(3, 4) + else if (activeStep === 5) completeAndAdvance(5, 6) } return (
- +

步骤 {activeStep} / 6

{PRODUCT_STEPS[activeStep - 1].label}

{durableDocument && 同一份空间数据}{(activeStep === 1 || activeStep === 3 || activeStep === 5) && }
{activeStep === 1 &&

导入真实户型图

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

{sourcePreview}
} {activeStep === 2 &&

AI 识别

识别前请核对这张原图;识别完成后才会打开可校正的同源 2D 数据。

{sourcePreview}
识别状态:{status === 'ready' ? '解析完成' : status === 'uploading' ? '解析中' : status === 'error' ? '失败' : '等待开始'}{error &&

{error}

}
{status === 'error' && selectedFile && }
} {activeStep === 3 &&
{twoDPanel}{editorInspector}
} - {activeStep === 4 &&

确认 3D 空间

这是同一份已校正 2D 数据生成的真实 3D 预览。

{canOpenLinkedWorkspace && }
{canOpenLinkedWorkspace ? threeDPanel : threeDUnavailablePanel}
} + {activeStep === 4 &&

确认 3D 空间

这是同一份已校正 2D 数据生成的真实 3D 预览。

{canOpenLinkedWorkspace && }
{canOpenLinkedWorkspace ? threeDPanel : threeDUnavailablePanel}
} {activeStep === 5 && (canOpenLinkedWorkspace ?
{twoDPanel}{threeDPanel}{editorInspector}
:

当前 3D 预览不可用

联动工作台已关闭,请先返回 2D 校正。

)} {activeStep === 6 && savePanel}
diff --git a/frontend/src/productFlow.test.ts b/frontend/src/productFlow.test.ts index fc5154e..9157e86 100644 --- a/frontend/src/productFlow.test.ts +++ b/frontend/src/productFlow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { canOpenStep, nextProductStep } from './productFlow' +import { completeProductStep, canOpenStep, initialCompletedSteps, nextProductStep } from './productFlow' describe('product flow', () => { it('blocks document-dependent work until a real parsed document exists', () => { @@ -14,4 +14,18 @@ describe('product flow', () => { expect(nextProductStep(2, true)).toBe(3) expect(nextProductStep(5, true)).toBe(6) }) + + it('records only real completion transitions and leaves a failed AI retry incomplete', () => { + const imported = completeProductStep([], 1) + expect(imported).toEqual([1]) + expect(imported).not.toContain(2) + expect(completeProductStep(imported, 2)).toEqual([1, 2]) + expect(completeProductStep([1, 2, 3], 3)).toEqual([1, 2, 3]) + }) + + it('initializes a reloaded saved canonical project with its known completed work', () => { + expect(initialCompletedSteps({ hasCanonicalDocument: false, isSavedProject: false })).toEqual([]) + expect(initialCompletedSteps({ hasCanonicalDocument: true, isSavedProject: false })).toEqual([1, 2]) + expect(initialCompletedSteps({ hasCanonicalDocument: true, isSavedProject: true })).toEqual([1, 2, 6]) + }) }) diff --git a/frontend/src/productFlow.ts b/frontend/src/productFlow.ts index b5e4582..85064e0 100644 --- a/frontend/src/productFlow.ts +++ b/frontend/src/productFlow.ts @@ -18,3 +18,23 @@ export function nextProductStep(step: ProductStep, hasDocument: boolean): Produc const next = PRODUCT_STEPS.find((item) => item.id === step + 1) return next && canOpenStep(next.id, hasDocument) ? next.id : step } + +/** Completion is only set by a user-visible success transition, never merely + * because a later document-gated view happens to be reachable. */ +export function completeProductStep(completed: readonly ProductStep[], step: ProductStep): ProductStep[] { + return Array.from(new Set([...completed, step])).sort((a, b) => a - b) as ProductStep[] +} + +/** A reloaded persisted canonical document proves import and parsing occurred; + * persistence itself also proves a previous explicit save. It does not invent + * completion for the intermediate 2D/3D review transitions. */ +export function initialCompletedSteps({ + hasCanonicalDocument, + isSavedProject, +}: { + hasCanonicalDocument: boolean + isSavedProject: boolean +}): ProductStep[] { + if (!hasCanonicalDocument) return [] + return isSavedProject ? [1, 2, 6] : [1, 2] +} From 87ad3cf561c79edfaaa047104df676d984b5d0c0 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 25 Jul 2026 13:16:47 +0800 Subject: [PATCH 09/39] fix: close issue 19 acceptance gaps --- backend/cmd/fake-vision/main.go | 2 +- docs/issue-19-design-parity.md | 6 +-- frontend/e2e/production.spec.ts | 39 +++++++++++++++-- frontend/src/App.tsx | 73 ++++++++++++++++++++++--------- frontend/src/ProductShell.tsx | 24 ++++++++++ frontend/src/ProductViews.tsx | 14 ++++++ frontend/src/productFlow.test.ts | 39 ++++++++++++----- frontend/src/productFlow.ts | 43 ++++++++++++------ frontend/src/threeDExport.test.ts | 13 ++++++ frontend/src/threeDExport.ts | 21 +++++++++ 10 files changed, 220 insertions(+), 54 deletions(-) create mode 100644 frontend/src/ProductShell.tsx create mode 100644 frontend/src/ProductViews.tsx create mode 100644 frontend/src/threeDExport.test.ts create mode 100644 frontend/src/threeDExport.ts diff --git a/backend/cmd/fake-vision/main.go b/backend/cmd/fake-vision/main.go index 07a61d6..3167d94 100644 --- a/backend/cmd/fake-vision/main.go +++ b/backend/cmd/fake-vision/main.go @@ -8,7 +8,7 @@ import ( const ( modelName = "e2e-fake-vision" - fixtureDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAYAAAC56t6BAAAAF0lEQVR4nGL6////fwZkwARjAAIAAP//YgEEAT/f/TcAAAAASUVORK5CYII=" + 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. 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}}"}}]}` diff --git a/docs/issue-19-design-parity.md b/docs/issue-19-design-parity.md index cf5d3e2..5bdec02 100644 --- a/docs/issue-19-design-parity.md +++ b/docs/issue-19-design-parity.md @@ -4,10 +4,10 @@ Frozen-design evidence: Issue #19 comment `5068143912`; production evidence uses | State | Layout and visual parity | Product semantics | Exact-head evidence / justified difference | | --- | --- | --- | --- | -| 导入 / AI 识别 | Indigo left step rail, white top bar, single centered upload/recognition card, violet primary CTA and muted state card. | Step 1 accepts an image; Step 2 retains the original preview, safe file facts, reselect control, loading/error/retry, and does not reveal editor tools. | `issue-19-import-ai.png`, copied from the production browser run to the non-Git `artifact_path` emitted by the runner. Native file input is retained for keyboard and screen-reader support. | +| 导入 / AI 识别 | Indigo left step rail, white top bar, single centered upload/recognition card, violet primary CTA and muted state card. | Step 1 accepts an image; Step 2 retains a recognizable, non-user controlled floorplan preview (wall outline, door swing and window marker), safe file facts, reselect control, loading/error/retry, and does not reveal editor tools. | `issue-19-import-ai.png`, copied from the production browser run to the non-Git `artifact_path` emitted by the runner. Native file input is retained for keyboard and screen-reader support. | | 校正 2D | Main 2D canvas card with a separate light inspector card; selected wall, endpoints, and selected openings use the shared violet token; unselected doors/windows retain category colors. | Only the API-returned canonical document unlocks this page. The run selects stable `wall-1`, then preserves the parsed geometry through project creation. | `issue-19-2d-correction.png`, fixed at 1440 × 960. No “补画墙体” or object-confirmation claim is shown because no verified canonical contract exists. | | 3D 确认 | Dedicated dark real R3F/WASM preview with visible generated-geometry status, compact confirmation header, violet confirm CTA and neutral return CTA. | This page is distinct from the linked workspace. “返回 2D 校正” changes no data; “完成并打开 3D” enters the linked workspace. | `issue-19-3d-confirm.png`, fixed at 1440 × 960. Height and unverified building attributes remain explicitly described as illustrative/unknown. | -| 2D / 3D 联动 | Equal dark/light workspace panels plus inspector; responsive layout stacks below 1024px while preserving reading and focus order. | 2D and 3D use the same persisted canonical document and stable `wallId`; the run selects the 3D `window-1` marker and verifies owner `wall-2`, then restarts and reloads the project before selecting `wall-2` in 2D. | `issue-19-linked-workspace.png`, fixed at 1440 × 960. The runner verifies all four PNGs exist and have distinct hashes before emitting their artifact directory. On smaller screens stacking is an intentional accessibility difference. | +| 2D / 3D 联动 | Equal dark/light workspace panels plus inspector; responsive layout stacks below 1024px while preserving reading and focus order. | 2D and 3D use the same persisted canonical document and stable `wallId`; the run selects the 3D `window-1` marker and verifies owner `wall-2`, edits and Undo/Redoes in the linked workspace, then captures this state before advancing to Save. | `issue-19-linked-workspace.png`, fixed at 1440 × 960 and captured while Step 5 is current (not the save page). The runner verifies all four PNGs exist and have distinct hashes before emitting their artifact directory. On smaller screens stacking is an intentional accessibility difference. | ## Diagnostic and unknown-content policy @@ -15,4 +15,4 @@ Raw canonical JSON and engineering telemetry (WASM, grid dimensions, triangle co ## Exact-head evidence command -Run `npm --prefix frontend run test:e2e` on the PR exact head. The production Playwright suite performs controlled upload → OpenAI-compatible parse → canonical 2D selection → standalone 3D confirmation → linked selection → manual project save → exact-PID restart → project reload. It captures the four named 1440 × 960 PNG artifacts, copies them outside Git into a unique run-artifact directory, emits `exact_head` and `artifact_path`, verifies all four images are present and distinct, and covers invalid and duplicate canonical identity fail-closed behavior. +Run `npm --prefix frontend run test:e2e` on the PR exact head. The production Playwright suite performs controlled recognizable-fixture upload → OpenAI-compatible parse → canonical 2D selection → standalone 3D confirmation → linked selection/edit/Undo/Redo → Step 5 screenshot → manual project save → exact-PID restart → project reload. It captures the four named 1440 × 960 PNG artifacts, copies them outside Git into a unique run-artifact directory, emits `exact_head` and `artifact_path`, verifies all four images are present and distinct, and covers invalid/duplicate canonical identity, completion-gated navigation, unavailable WASM, and stale-3D-export fail-closed behavior. diff --git a/frontend/e2e/production.spec.ts b/frontend/e2e/production.spec.ts index cc5814f..14273b3 100644 --- a/frontend/e2e/production.spec.ts +++ b/frontend/e2e/production.spec.ts @@ -6,7 +6,7 @@ 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( - 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAYAAAC56t6BAAAAF0lEQVR4nGL6////fwZkwARjAAIAAP//YgEEAT/f/TcAAAAASUVORK5CYII=', + '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 } }) @@ -108,8 +108,10 @@ function visibleLightPixels(png: Buffer): number { async function assertThreeDRenderIsVisible(page: Page, testInfo: TestInfo, name: string): Promise { const path = testInfo.outputPath(name) - await page.getByTestId('three-render-surface').screenshot({ path }) - expect(visibleLightPixels(await readFile(path))).toBeGreaterThan(120) + await expect.poll(async () => { + await page.getByTestId('three-render-surface').screenshot({ path }) + return visibleLightPixels(await readFile(path)) + }, { timeout: 5_000 }).toBeGreaterThan(120) } async function assertSelectingOpeningWallKeepsCanvasPixels(page: Page, testInfo: TestInfo): Promise { @@ -240,10 +242,11 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod await page.waitForFunction((before) => window.__homevoxE2E?.geometry.fingerprint === before, geometryBeforeEndpointEdit.geometry.fingerprint) await page.getByRole('button', { name: '重做(Ctrl/Cmd + Shift+Z 或 Ctrl/Cmd + Y)' }).click() await page.waitForFunction((after) => window.__homevoxE2E?.geometry.fingerprint === after, geometryAfterEndpointEdit.geometry.fingerprint) + await expect(page.getByRole('button', { name: /2D\/3D 联动,当前步骤/ })).toBeVisible() + captures.push(await screenshot(page, testInfo, 'issue-19-linked-workspace.png')) await page.getByRole('button', { name: '继续' }).click() await expect(page.getByRole('button', { name: /2D\/3D 联动,已完成/ })).toBeVisible() await expect(page.getByRole('button', { name: /保存项目,当前步骤/ })).toBeVisible() - captures.push(await screenshot(page, testInfo, 'issue-19-linked-workspace.png')) const hashes = await Promise.all(captures.map(async (path) => createHash('sha256').update(await readFile(path)).digest('hex'))) expect(new Set(hashes).size).toBe(4) @@ -355,6 +358,10 @@ test('makes parse retry and persistence-unavailable states actionable', async ({ await expect(page.getByRole('button', { name: /AI 识别,已完成/ })).toHaveCount(0) await page.unroute('**/api/floorplans/parse') await parseSelectedFile(page) + await page.getByRole('button', { name: '继续' }).click() + await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() + await page.getByRole('button', { name: '完成并打开 3D' }).click() + await page.getByRole('button', { name: '继续' }).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()) @@ -380,3 +387,27 @@ test('keeps the narrow-screen workflow keyboard reachable', async ({ page }) => await expect(page.getByRole('heading', { name: '确认 3D 空间' })).toBeVisible() await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() }) + +test('does not export stale 3D after current canonical geometry becomes invalid', 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.getByRole('button', { name: '继续' }).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.getByText('wall length must be strictly greater than zero', { exact: true })).toBeVisible() + await expect(page.getByLabel('导出3D白模PNG')).toBeDisabled() + let downloads = 0 + page.on('download', () => { downloads += 1 }) + await page.waitForTimeout(150) + expect(downloads).toBe(0) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 40fb3f1..e146dfd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import { Suspense, useEffect, useMemo, useRef, useState, type PointerEvent } from 'react' -import { Canvas, type RootState } from '@react-three/fiber' +import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } 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 { @@ -49,7 +49,10 @@ import { type ProjectDetail, type ProjectSummary, } from './projects' -import { PRODUCT_STEPS, canOpenStep, completeProductStep, initialCompletedSteps, type ProductStep } from './productFlow' +import { completeProductStep, initialCompletedSteps, type ProductStep, type ProductFlowContext } from './productFlow' +import { ProductShell } from './ProductShell' +import { LinkedWorkspace, ThreeDConfirmation, TwoDWorkspace } from './ProductViews' +import { canExportCurrentThreeD } from './threeDExport' import { e2EProjectID, e2EWasmLoader, isE2EInstrumentationEnabled, publishE2EState } from '@homevox-e2e' import './App.css' @@ -59,6 +62,21 @@ const EMPTY_WALLS: WallSegment[] = [] type ParseState = 'idle' | 'uploading' | 'ready' | 'error' +type RendererState = { state: RootState; generation: number } + +function RendererLifecycle({ generation, onMount, onUnmount }: { + generation: number + onMount: (renderer: RendererState) => void + onUnmount: (generation: number) => void +}) { + const state = useThree() + useEffect(() => { + onMount({ state, generation }) + return () => onUnmount(generation) + }, [generation, onMount, onUnmount, state]) + return null +} + type ScenePoint = { x: number y: number @@ -386,7 +404,7 @@ export default function App() { const [showSourceImage, setShowSourceImage] = useState(true) const [imageDimFallback, setImageDimFallback] = useState<{ width: number; height: number } | null>(null) const [editorSize, setEditorSize] = useState({ width: 0, height: 0 }) - const [threeRenderer, setThreeRenderer] = useState(null) + const [threeRenderer, setThreeRenderer] = useState(null) const [projectName, setProjectName] = useState('') const [currentProject, setCurrentProject] = useState(null) const [projects, setProjects] = useState([]) @@ -406,6 +424,7 @@ export default function App() { const projectRequestRef = useRef<{ id: number; controller: AbortController } | null>(null) const projectSequenceRef = useRef(0) const wasmGenerationRef = useRef(0) + const [wasmGeneration, setWasmGeneration] = useState(0) const wasmGeometryRef = useRef(null) const wasmCallsRef = useRef(0) @@ -448,11 +467,16 @@ export default function App() { const canExportModel = walls.length > 0 const isExporting = exportingScope !== null const canExport2D = canExportModel && status === 'ready' && !isExporting - const canExport3D = - canExportModel && - webGLAvailable && - Boolean(threeRenderer?.gl) && - !isExporting + const canExport3D = canExportCurrentThreeD({ + isExporting, + hasModel: canExportModel, + webGLAvailable, + wasmActive: wasmState === 'active', + hasWasmGeometry: wasmGeometry !== null, + rendererMounted: threeRenderer !== null, + rendererGeneration: threeRenderer?.generation ?? null, + canonicalGeneration: wasmGeneration, + }) useEffect(() => { if (!isE2EInstrumentationEnabled()) return @@ -532,6 +556,7 @@ export default function App() { useEffect(() => { const generation = wasmGenerationRef.current + 1 wasmGenerationRef.current = generation + setWasmGeneration(generation) const replaceGeometry = (next: BufferGeometry | null) => { disposeWasmWallGeometry(wasmGeometryRef.current) wasmGeometryRef.current = next @@ -1106,7 +1131,7 @@ export default function App() { setExportError('') try { - const rendererState = threeRenderer + const rendererState = threeRenderer.state rendererState.gl.render(rendererState.scene, rendererState.camera) const fileName = buildScopeFileName('3d') const exportResult = await exportWebGLCanvasToPng(rendererState.gl, fileName) @@ -1123,6 +1148,14 @@ export default function App() { } } + const handleRendererMount = useCallback((renderer: RendererState) => { + setThreeRenderer(renderer) + }, []) + + const handleRendererUnmount = useCallback((generation: number) => { + setThreeRenderer((current) => current?.generation === generation ? null : current) + }, []) + const twoDPanel = (
@@ -1179,7 +1212,8 @@ export default function App() { {geometryValidationError &&

当前开口数据无法生成 3D,请返回 2D 校正后重试。

}
- {webGLAvailable ? + {webGLAvailable ? + :
当前浏览器无法显示 3D 预览。请在启用 WebGL 的浏览器中打开;2D 校正仍可继续。
}
@@ -1243,18 +1277,17 @@ export default function App() { else if (activeStep === 5) completeAndAdvance(5, 6) } + const flow: ProductFlowContext = { completed: completedSteps, hasDocument: Boolean(durableDocument), hasCanonicalGeometry, hasThreeDGeometry: canOpenLinkedWorkspace } + const primaryAction = (activeStep === 1 || activeStep === 3 || activeStep === 5) && + return ( -
- -

步骤 {activeStep} / 6

{PRODUCT_STEPS[activeStep - 1].label}

{durableDocument && 同一份空间数据}{(activeStep === 1 || activeStep === 3 || activeStep === 5) && }
-
+ {activeStep === 1 &&

导入真实户型图

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

{sourcePreview}
} {activeStep === 2 &&

AI 识别

识别前请核对这张原图;识别完成后才会打开可校正的同源 2D 数据。

{sourcePreview}
识别状态:{status === 'ready' ? '解析完成' : status === 'uploading' ? '解析中' : status === 'error' ? '失败' : '等待开始'}{error &&

{error}

}
{status === 'error' && selectedFile && }
} - {activeStep === 3 &&
{twoDPanel}{editorInspector}
} - {activeStep === 4 &&

确认 3D 空间

这是同一份已校正 2D 数据生成的真实 3D 预览。

{canOpenLinkedWorkspace && }
{canOpenLinkedWorkspace ? threeDPanel : threeDUnavailablePanel}
} - {activeStep === 5 && (canOpenLinkedWorkspace ?
{twoDPanel}{threeDPanel}{editorInspector}
:

当前 3D 预览不可用

联动工作台已关闭,请先返回 2D 校正。

)} + {activeStep === 3 && } + {activeStep === 4 && setActiveStep(3)} onComplete={() => completeAndAdvance(4, 5)} />} + {activeStep === 5 && setActiveStep(3)} />} {activeStep === 6 && savePanel} -
-
+ ) } diff --git a/frontend/src/ProductShell.tsx b/frontend/src/ProductShell.tsx new file mode 100644 index 0000000..ab161ca --- /dev/null +++ b/frontend/src/ProductShell.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react' +import { PRODUCT_STEPS, canOpenStep, type ProductFlowContext, type ProductStep } from './productFlow' + +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) { + return
+ +

步骤 {activeStep} / 6

{PRODUCT_STEPS[activeStep - 1].label}

{hasDocument && 同一份空间数据}{primaryAction}
{children}
+
+} diff --git a/frontend/src/ProductViews.tsx b/frontend/src/ProductViews.tsx new file mode 100644 index 0000000..493e7b0 --- /dev/null +++ b/frontend/src/ProductViews.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' + +export function TwoDWorkspace({ editor, inspector }: { editor: ReactNode; inspector: ReactNode }) { + return
{editor}{inspector}
+} + +export function ThreeDConfirmation({ preview, unavailable, canOpenLinkedWorkspace, onBack, onComplete }: { preview: ReactNode; unavailable: ReactNode; canOpenLinkedWorkspace: boolean; onBack: () => void; onComplete: () => void }) { + return

确认 3D 空间

这是同一份已校正 2D 数据生成的真实 3D 预览。

{canOpenLinkedWorkspace && }
{canOpenLinkedWorkspace ? preview : unavailable}
+} + +export function LinkedWorkspace({ editor, preview, inspector, available, onBack }: { editor: ReactNode; preview: ReactNode; inspector: ReactNode; available: boolean; onBack: () => void }) { + if (!available) return

当前 3D 预览不可用

联动工作台已关闭,请先返回 2D 校正。

+ return
{editor}{preview}{inspector}
+} diff --git a/frontend/src/productFlow.test.ts b/frontend/src/productFlow.test.ts index 9157e86..4bd2942 100644 --- a/frontend/src/productFlow.test.ts +++ b/frontend/src/productFlow.test.ts @@ -1,18 +1,33 @@ import { describe, expect, it } from 'vitest' -import { completeProductStep, canOpenStep, initialCompletedSteps, nextProductStep } from './productFlow' +import { completeProductStep, canOpenStep, initialCompletedSteps, nextProductStep, type ProductFlowContext } from './productFlow' + +const context = (completed: number[], extra: Partial = {}): ProductFlowContext => ({ + completed: completed as ProductFlowContext['completed'], + hasDocument: true, + hasCanonicalGeometry: true, + hasThreeDGeometry: true, + ...extra, +}) describe('product flow', () => { - it('blocks document-dependent work until a real parsed document exists', () => { - expect(canOpenStep(1, false)).toBe(true) - expect(canOpenStep(2, false)).toBe(true) - expect(canOpenStep(3, false)).toBe(false) - expect(canOpenStep(5, false)).toBe(false) + it('uses explicit completion transitions as the single navigation guard', () => { + expect(canOpenStep(1, context([]))).toBe(true) + expect(canOpenStep(2, context([]))).toBe(false) + expect(canOpenStep(2, context([1]))).toBe(true) + expect(canOpenStep(3, context([1]))).toBe(false) + expect(canOpenStep(3, context([1, 2]))).toBe(true) + expect(canOpenStep(4, context([1, 2]))).toBe(false) + expect(canOpenStep(4, context([1, 2, 3]))).toBe(true) + expect(canOpenStep(5, context([1, 2, 3, 4], { hasThreeDGeometry: false }))).toBe(false) + expect(canOpenStep(5, context([1, 2, 3, 4]))).toBe(true) + expect(canOpenStep(6, context([1, 2, 3, 4]))).toBe(false) + expect(canOpenStep(6, context([1, 2, 3, 4, 5]))).toBe(true) }) - it('only advances into a real workspace after parsing', () => { - expect(nextProductStep(2, false)).toBe(2) - expect(nextProductStep(2, true)).toBe(3) - expect(nextProductStep(5, true)).toBe(6) + it('does not allow an available document to bypass review transitions', () => { + expect(nextProductStep(2, context([1], { hasDocument: true }))).toBe(2) + expect(nextProductStep(3, context([1, 2]))).toBe(3) + expect(nextProductStep(4, context([1, 2, 3]))).toBe(4) }) it('records only real completion transitions and leaves a failed AI retry incomplete', () => { @@ -20,12 +35,12 @@ describe('product flow', () => { expect(imported).toEqual([1]) expect(imported).not.toContain(2) expect(completeProductStep(imported, 2)).toEqual([1, 2]) - expect(completeProductStep([1, 2, 3], 3)).toEqual([1, 2, 3]) }) - it('initializes a reloaded saved canonical project with its known completed work', () => { + it('restores only facts proven by a saved canonical project', () => { expect(initialCompletedSteps({ hasCanonicalDocument: false, isSavedProject: false })).toEqual([]) expect(initialCompletedSteps({ hasCanonicalDocument: true, isSavedProject: false })).toEqual([1, 2]) expect(initialCompletedSteps({ hasCanonicalDocument: true, isSavedProject: true })).toEqual([1, 2, 6]) + expect(canOpenStep(4, context([1, 2, 6]))).toBe(false) }) }) diff --git a/frontend/src/productFlow.ts b/frontend/src/productFlow.ts index 85064e0..7fae53f 100644 --- a/frontend/src/productFlow.ts +++ b/frontend/src/productFlow.ts @@ -1,22 +1,38 @@ export const PRODUCT_STEPS = [ - { id: 1, label: '导入户型图', requiresDocument: false }, - { id: 2, label: 'AI 识别', requiresDocument: false }, - { id: 3, label: '校正 2D', requiresDocument: true }, - { id: 4, label: '生成 3D', requiresDocument: true }, - { id: 5, label: '2D/3D 联动', requiresDocument: true }, - { id: 6, label: '保存项目', requiresDocument: true }, + { id: 1, label: '导入户型图' }, + { id: 2, label: 'AI 识别' }, + { id: 3, label: '校正 2D' }, + { id: 4, label: '生成 3D' }, + { id: 5, label: '2D/3D 联动' }, + { id: 6, label: '保存项目' }, ] as const export type ProductStep = (typeof PRODUCT_STEPS)[number]['id'] -export function canOpenStep(step: ProductStep, hasDocument: boolean): boolean { - const definition = PRODUCT_STEPS.find((item) => item.id === step) - return Boolean(definition && (!definition.requiresDocument || hasDocument)) +export type ProductFlowContext = { + completed: readonly ProductStep[] + hasDocument: boolean + hasCanonicalGeometry: boolean + hasThreeDGeometry: boolean } -export function nextProductStep(step: ProductStep, hasDocument: boolean): ProductStep { +/** The sole navigation guard. A document can unlock 2D data, but never stands + * in for the user's explicit 2D review, 3D confirmation, or linked review. */ +export function canOpenStep(step: ProductStep, context: ProductFlowContext): boolean { + const completed = new Set(context.completed) + switch (step) { + case 1: return true + case 2: return completed.has(1) + case 3: return completed.has(2) && context.hasDocument + case 4: return completed.has(3) && context.hasCanonicalGeometry + case 5: return completed.has(4) && context.hasCanonicalGeometry && context.hasThreeDGeometry + case 6: return completed.has(5) && context.hasDocument + } +} + +export function nextProductStep(step: ProductStep, context: ProductFlowContext): ProductStep { const next = PRODUCT_STEPS.find((item) => item.id === step + 1) - return next && canOpenStep(next.id, hasDocument) ? next.id : step + return next && canOpenStep(next.id, context) ? next.id : step } /** Completion is only set by a user-visible success transition, never merely @@ -25,9 +41,8 @@ export function completeProductStep(completed: readonly ProductStep[], step: Pro return Array.from(new Set([...completed, step])).sort((a, b) => a - b) as ProductStep[] } -/** A reloaded persisted canonical document proves import and parsing occurred; - * persistence itself also proves a previous explicit save. It does not invent - * completion for the intermediate 2D/3D review transitions. */ +/** A reloaded saved canonical document proves import/parsing and explicit save. + * It deliberately does not manufacture 2D, 3D, or linked-review completion. */ export function initialCompletedSteps({ hasCanonicalDocument, isSavedProject, diff --git a/frontend/src/threeDExport.test.ts b/frontend/src/threeDExport.test.ts new file mode 100644 index 0000000..3b45966 --- /dev/null +++ b/frontend/src/threeDExport.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { canExportCurrentThreeD } from './threeDExport' + +const ready = { isExporting: false, hasModel: true, webGLAvailable: true, wasmActive: true, hasWasmGeometry: true, rendererMounted: true, rendererGeneration: 4, canonicalGeneration: 4 } +describe('3D export admission', () => { + it('requires current WASM geometry and its mounted renderer', () => { + expect(canExportCurrentThreeD(ready)).toBe(true) + expect(canExportCurrentThreeD({ ...ready, wasmActive: false })).toBe(false) + expect(canExportCurrentThreeD({ ...ready, hasWasmGeometry: false })).toBe(false) + expect(canExportCurrentThreeD({ ...ready, rendererMounted: false })).toBe(false) + expect(canExportCurrentThreeD({ ...ready, rendererGeneration: 3 })).toBe(false) + }) +}) diff --git a/frontend/src/threeDExport.ts b/frontend/src/threeDExport.ts new file mode 100644 index 0000000..686b371 --- /dev/null +++ b/frontend/src/threeDExport.ts @@ -0,0 +1,21 @@ +export function canExportCurrentThreeD({ + isExporting, + hasModel, + webGLAvailable, + wasmActive, + hasWasmGeometry, + rendererMounted, + rendererGeneration, + canonicalGeneration, +}: { + isExporting: boolean + hasModel: boolean + webGLAvailable: boolean + wasmActive: boolean + hasWasmGeometry: boolean + rendererMounted: boolean + rendererGeneration: number | null + canonicalGeneration: number +}): boolean { + return hasModel && webGLAvailable && wasmActive && hasWasmGeometry && rendererMounted && rendererGeneration === canonicalGeneration && !isExporting +} From 2bfc3c4bf86169547b2a82450e67d4b348b367fa Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 25 Jul 2026 14:22:44 +0800 Subject: [PATCH 10/39] fix: harden current 3d product flow --- frontend/src/App.tsx | 420 +++++------------- frontend/src/FloorplanEditorPanel.tsx | 117 +++++ frontend/src/ProductViews.tsx | 8 +- frontend/src/ThreeDPreview.tsx | 151 +++++++ frontend/src/floorplanSession.test.ts | 12 + frontend/src/floorplanSession.ts | 11 + frontend/src/productFlow.test.ts | 18 + frontend/src/productFlow.ts | 32 ++ frontend/src/threeDExport.test.ts | 6 +- frontend/src/threeDExport.ts | 8 +- frontend/src/threeDGeneration.test.ts | 23 + frontend/src/threeDGeneration.ts | 35 ++ frontend/src/useProductFlowController.ts | 29 ++ frontend/src/useThreeDGenerationController.ts | 63 +++ frontend/src/wallShell.test.ts | 33 +- frontend/src/wallShell.ts | 94 ++++ 16 files changed, 752 insertions(+), 308 deletions(-) create mode 100644 frontend/src/FloorplanEditorPanel.tsx create mode 100644 frontend/src/ThreeDPreview.tsx create mode 100644 frontend/src/floorplanSession.test.ts create mode 100644 frontend/src/floorplanSession.ts create mode 100644 frontend/src/threeDGeneration.test.ts create mode 100644 frontend/src/threeDGeneration.ts create mode 100644 frontend/src/useProductFlowController.ts create mode 100644 frontend/src/useThreeDGenerationController.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e146dfd..7647f84 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,4 @@ -import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from 'react' -import { Canvas, useThree, 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, @@ -21,8 +19,6 @@ import { canvasScale, canvasUnitsForCssPixels, isParseResponse, - openingLabel, - openingPoint, validateCanonicalFloorplan, MIN_OPENING_WIDTH, type ParsedOpening, @@ -30,7 +26,7 @@ import { 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 WasmFallbackReason } from './wasmMarchingCubes' import { buildWasmWallGeometry, disposeWasmWallGeometry } from './wasmGeometry' @@ -49,10 +45,15 @@ import { type ProjectDetail, type ProjectSummary, } from './projects' -import { completeProductStep, initialCompletedSteps, type ProductStep, type ProductFlowContext } from './productFlow' +import { canApplyProductFlowEvent, initialCompletedSteps, type ProductStep, type ProductFlowContext } from './productFlow' import { ProductShell } from './ProductShell' +import { FloorplanEditorPanel } from './FloorplanEditorPanel' import { LinkedWorkspace, ThreeDConfirmation, TwoDWorkspace } from './ProductViews' +import { ThreeDPreview } from './ThreeDPreview' +import { useProductFlowController } from './useProductFlowController' +import { useThreeDGenerationController } from './useThreeDGenerationController' import { canExportCurrentThreeD } from './threeDExport' +import { canonicalRevisionToken } from './floorplanSession' import { e2EProjectID, e2EWasmLoader, isE2EInstrumentationEnabled, publishE2EState } from '@homevox-e2e' import './App.css' @@ -62,21 +63,6 @@ const EMPTY_WALLS: WallSegment[] = [] type ParseState = 'idle' | 'uploading' | 'ready' | 'error' -type RendererState = { state: RootState; generation: number } - -function RendererLifecycle({ generation, onMount, onUnmount }: { - generation: number - onMount: (renderer: RendererState) => void - onUnmount: (generation: number) => void -}) { - const state = useThree() - useEffect(() => { - onMount({ state, generation }) - return () => onUnmount(generation) - }, [generation, onMount, onUnmount, state]) - return null -} - type ScenePoint = { x: number y: number @@ -100,213 +86,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 (finiteNumber(opening.x) && finiteNumber(opening.y)) coords.push(opening.x, opening.y) } - - if (coords.length < 2) { - return null - } - + 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 - selectedWallID: string | null - selectedOpeningID: string | null - onSelectWall: (wallID: string) => void - onSelectOpening: (openingID: string) => void -} - -function Scene({ model, wasmGeometry, wasmActive, selectedWallID, selectedOpeningID, onSelectWall, onSelectOpening }: FloorplanSceneProps) { - - return ( - <> - - - - {model.floor && ( - - - - - )} - - - - {wasmActive && wasmGeometry && ( - - - - )} - - {model.walls.map((wall) => ( - - { event.stopPropagation(); onSelectWall(wall.id) }} - onClick={(event) => { event.stopPropagation(); onSelectWall(wall.id) }} - > - - {wasmActive ? ( - - ) : ( - - )} - - -
- {webGLAvailable ? - - - :
当前浏览器无法显示 3D 预览。请在启用 WebGL 的浏览器中打开;2D 校正仍可继续。
} +
墙体高度为示意;精确高度、承重属性、墙厚与窗台高度需实测。
@@ -1239,7 +1058,7 @@ export default function App() {

无法生成可靠的 3D 几何。请返回 2D 校正后重试,或在支持 WebGL 的浏览器中打开。

)} - +
) @@ -1266,10 +1085,7 @@ export default function App() {

保存项目

手动保存当前同源 2D 与 3D 数据。保存不可用时会保留当前编辑,不会假装成功。

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

{projectMessage}

}

已保存项目

    {projects.map((item) =>
  • {item.name} r{item.revision}
  • )}{projects.length === 0 &&
  • 暂无已保存项目
  • }
) - const completeAndAdvance = (step: ProductStep, next: ProductStep) => { - setCompletedSteps((current) => completeProductStep(current, step)) - setActiveStep(next) - } + const completeAndAdvance = (step: ProductStep, next: ProductStep) => applyProductTransition({ type: 'complete', step, next }) const goNext = () => { if (activeStep === 1 && selectedFile) completeAndAdvance(1, 2) @@ -1277,16 +1093,24 @@ export default function App() { else if (activeStep === 5) completeAndAdvance(5, 6) } - const flow: ProductFlowContext = { completed: completedSteps, hasDocument: Boolean(durableDocument), hasCanonicalGeometry, hasThreeDGeometry: canOpenLinkedWorkspace } - const primaryAction = (activeStep === 1 || activeStep === 3 || activeStep === 5) && + const flow: ProductFlowContext = { completed: completedSteps, ...productFlowContext } + const pendingCompletion = activeStep === 1 || activeStep === 3 || activeStep === 5 + ? { type: 'complete' as const, step: activeStep, next: (activeStep + 1) as ProductStep } + : null + const canAdvance = Boolean(pendingCompletion && canApplyProductFlowEvent( + { activeStep, completed: completedSteps }, + pendingCompletion, + productFlowContext, + ) && (activeStep !== 1 || selectedFile)) + const primaryAction = pendingCompletion && return ( - + applyProductTransition({ type: 'open', step })} primaryAction={primaryAction}> {activeStep === 1 &&

导入真实户型图

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

{sourcePreview}
} {activeStep === 2 &&

AI 识别

识别前请核对这张原图;识别完成后才会打开可校正的同源 2D 数据。

{sourcePreview}
识别状态:{status === 'ready' ? '解析完成' : status === 'uploading' ? '解析中' : status === 'error' ? '失败' : '等待开始'}{error &&

{error}

}
{status === 'error' && selectedFile && }
} {activeStep === 3 && } - {activeStep === 4 && setActiveStep(3)} onComplete={() => completeAndAdvance(4, 5)} />} - {activeStep === 5 && setActiveStep(3)} />} + {activeStep === 4 && applyProductTransition({ type: 'open', step: 3 })} onComplete={() => completeAndAdvance(4, 5)} />} + {activeStep === 5 && applyProductTransition({ type: 'open', step: 3 })} />} {activeStep === 6 && savePanel}
) diff --git a/frontend/src/FloorplanEditorPanel.tsx b/frontend/src/FloorplanEditorPanel.tsx new file mode 100644 index 0000000..437adf5 --- /dev/null +++ b/frontend/src/FloorplanEditorPanel.tsx @@ -0,0 +1,117 @@ +import type { PointerEvent, RefObject } from 'react' +import type { EndpointRef, WallSegment } from './floorplanEditor' +import { openingLabel, 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 + 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. It only receives typed editor state and + * commands, keeping editing presentation separate from session orchestration. */ +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, + onShowSourceImageChange, + onCanvasPointerMove, + onCanvasPointerUp, + onCanvasPointerCancel, + onEndpointPointerDown, + onWallPointerDown, + onOpeningPointerDown, +}: FloorplanEditorPanelProps) { + return ( +
+
+ 2D 结构 · 原图叠加校正 +
+ + {draggedEndpoint ? '拖拽中' : selectedWallLabel ? `已选择 ${selectedWallLabel}` : hoveredEndpoint ? '可拖拽端点(鼠标悬停)' : '选择墙体或拖拽端点'} +
+
+ {geometryValidationError &&

当前户型几何无法生成 3D 或保存:{geometryValidationError}。请继续在 2D 校正。

} +
+ + + {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' : '#e2e8f0' + 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' + return onOpeningPointerDown(event, opening.id!)} style={{ cursor: 'grab' }} />{openingLabel(opening) && {openingLabel(opening)}} + })} + +
+
+ ) +} diff --git a/frontend/src/ProductViews.tsx b/frontend/src/ProductViews.tsx index 493e7b0..3841c36 100644 --- a/frontend/src/ProductViews.tsx +++ b/frontend/src/ProductViews.tsx @@ -4,11 +4,11 @@ export function TwoDWorkspace({ editor, inspector }: { editor: ReactNode; inspec return
{editor}{inspector}
} -export function ThreeDConfirmation({ preview, unavailable, canOpenLinkedWorkspace, onBack, onComplete }: { preview: ReactNode; unavailable: ReactNode; canOpenLinkedWorkspace: boolean; onBack: () => void; onComplete: () => void }) { - return

确认 3D 空间

这是同一份已校正 2D 数据生成的真实 3D 预览。

{canOpenLinkedWorkspace && }
{canOpenLinkedWorkspace ? preview : unavailable}
+export function ThreeDConfirmation({ preview, unavailable, previewAvailable, canOpenLinkedWorkspace, onBack, onComplete }: { preview: ReactNode; unavailable: ReactNode; previewAvailable: boolean; canOpenLinkedWorkspace: boolean; onBack: () => void; onComplete: () => void }) { + return

确认 3D 空间

这是同一份已校正 2D 数据生成的真实 3D 预览。

{canOpenLinkedWorkspace && }
{previewAvailable ? preview : unavailable}
} -export function LinkedWorkspace({ editor, preview, inspector, available, onBack }: { editor: ReactNode; preview: ReactNode; inspector: ReactNode; available: boolean; onBack: () => void }) { - if (!available) return

当前 3D 预览不可用

联动工作台已关闭,请先返回 2D 校正。

+export function LinkedWorkspace({ editor, preview, inspector, previewAvailable, onBack }: { editor: ReactNode; preview: ReactNode; inspector: ReactNode; previewAvailable: boolean; onBack: () => void }) { + if (!previewAvailable) return

当前 3D 预览不可用

联动工作台已关闭,请先返回 2D 校正。

return
{editor}{preview}{inspector}
} diff --git a/frontend/src/ThreeDPreview.tsx b/frontend/src/ThreeDPreview.tsx new file mode 100644 index 0000000..ae7faa7 --- /dev/null +++ b/frontend/src/ThreeDPreview.tsx @@ -0,0 +1,151 @@ +import { Suspense, useEffect } 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, type WallShellModel } from './wallShell' + +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() + useEffect(() => { + onMount({ state, generation }) + return () => onUnmount(generation) + }, [generation, onMount, onUnmount, state]) + 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 +} + +function CanonicalScene({ + model, + wasmGeometry, + wasmActive, + selectedWallID, + selectedOpeningID, + onSelectWall, + onSelectOpening, +}: Omit) { + const pieces = buildWallShellPieces(model) + return ( + <> + + + {model.floor && ( + + + + + )} + + + {/* Keep the generated WASM mesh in the mounted scene for a single + canonical/WASM/renderer revision. Canonical pieces are the visible + presentation because their explicit spans preserve opening cut-outs. */} + {wasmActive && wasmGeometry && } + + {pieces.map((piece) => ( + + { event.stopPropagation(); onSelectWall(piece.wallId) }} + onClick={(event) => { event.stopPropagation(); onSelectWall(piece.wallId) }} + > + + + + + ))} + {selectedWallID && model.walls.filter((wall) => wall.id === selectedWallID).map((wall) => ( + + + + + + + + ))} + + {model.walls.map((wall) => ( + + -
- ) - - const editorInspector = ( - - ) - - const sourcePreview = previewURL && selectedFile && ( -
-

原图预览

{selectedFile.name} · {selectedFile.type || '未知类型'} · {selectedFile.size.toLocaleString()} bytes

- 上传户型图预览 -
- ) - - const savePanel = ( -

保存项目

手动保存当前同源 2D 与 3D 数据。保存不可用时会保留当前编辑,不会假装成功。

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

{projectMessage}

}

已保存项目

    {projects.map((item) =>
  • {item.name} r{item.revision}
  • )}{projects.length === 0 &&
  • 暂无已保存项目
  • }
- ) + const editorProps: FloorplanEditorPanelProps = { + editorRef, + viewport, + walls, + openings, + showSourceImage, + previewURL, + geometryValidationError, + selectedWallID, + selectedWallLabel: selectedWall?.id ?? null, + selectedOpeningID, + hoveredEndpoint, + draggedEndpoint, + hitRadius, + handleRadius, + activeHandleRadius, + wallHitStroke, + wallStroke, + activeWallStroke, + openingRadius, + openingStroke, + labelOffset, + labelSize, + onShowSourceImageChange: setShowSourceImage, + onCanvasPointerMove: handleCanvasPointerMove, + onCanvasPointerUp: handleCanvasPointerUp, + onCanvasPointerCancel: handleCanvasPointerCancel, + onEndpointPointerDown: handleCanvasPointerDown, + onWallPointerDown: handleWallPointerDown, + onOpeningPointerDown: handleOpeningPointerDown, + } + const inspectorProps: InspectorPanelProps = { + selectedWallID, + selectedOpening, + openingError, + canExport2D, + canExport3D, + exportingScope, + exportError, + canUndo: canUndo(wallEditor), + canRedo: canRedo(wallEditor), + onAddOpening: handleAddOpening, + onOpeningWidthChange: (width) => { + if (selectedOpening?.id) commitOpeningPatch(selectedOpening.id, { width }) + }, + onDeleteOpening: handleDeleteOpening, + onExport2D: () => { void handleExport2D() }, + onExport3D: () => { void handleExport3D() }, + onUndo: handleUndo, + onRedo: handleRedo, + } + const threeDPreviewProps: ThreeDPreviewPanelProps = { + canonicalRevision, + model: wallShellModel, + wasmGeometry, + wasmActive: wasmState === 'active', + webGLAvailable, + selectedWallID, + selectedOpeningID, + onSelectWall: selectWall, + onSelectOpening: selectOpening, + onRendererMount: mountRenderer, + onRendererUnmount: unmountRenderer, + onFrameRendered: acknowledgeFrame, + geometryValidationError, + } const completeAndAdvance = (step: ProductStep, next: ProductStep) => applyProductTransition({ type: 'complete', step, next }) @@ -1106,12 +1023,12 @@ export default function App() { return ( applyProductTransition({ type: 'open', step })} primaryAction={primaryAction}> - {activeStep === 1 &&

导入真实户型图

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

{sourcePreview}
} - {activeStep === 2 &&

AI 识别

识别前请核对这张原图;识别完成后才会打开可校正的同源 2D 数据。

{sourcePreview}
识别状态:{status === 'ready' ? '解析完成' : status === 'uploading' ? '解析中' : status === 'error' ? '失败' : '等待开始'}{error &&

{error}

}
{status === 'error' && selectedFile && }
} - {activeStep === 3 && } - {activeStep === 4 && applyProductTransition({ type: 'open', step: 3 })} onComplete={() => completeAndAdvance(4, 5)} />} - {activeStep === 5 && applyProductTransition({ type: 'open', step: 3 })} />} - {activeStep === 6 && savePanel} + {activeStep === 1 && } + {activeStep === 2 && } + {activeStep === 3 && } + {activeStep === 4 && applyProductTransition({ type: 'open', step: 3 })} onComplete={() => completeAndAdvance(4, 5)} />} + {activeStep === 5 && applyProductTransition({ type: 'open', step: 3 })} />} + {activeStep === 6 && { void saveProject() }} onRefresh={() => { void refreshProjects() }} onLoad={(id) => { void loadProject(id) }} />}
) } diff --git a/frontend/src/InspectorPanel.tsx b/frontend/src/InspectorPanel.tsx new file mode 100644 index 0000000..72326ee --- /dev/null +++ b/frontend/src/InspectorPanel.tsx @@ -0,0 +1,54 @@ +import { MIN_OPENING_WIDTH, type ParsedOpening } from './floorplanUi' + +export type InspectorPanelProps = { + selectedWallID: string | 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 + onOpeningWidthChange: (width: number) => void + onDeleteOpening: () => void + onExport2D: () => void + onExport3D: () => void + onUndo: () => void + onRedo: () => void +} + +/** Typed editing and export controls for the same canonical document used by + * both 2D and 3D. Keeping these callbacks explicit prevents a view from + * reaching into orchestration state or bypassing revision admission. */ +export function InspectorPanel({ + selectedWallID, + selectedOpening, + openingError, + canExport2D, + canExport3D, + exportingScope, + exportError, + canUndo, + canRedo, + onAddOpening, + onOpeningWidthChange, + onDeleteOpening, + onExport2D, + onExport3D, + onUndo, + onRedo, +}: InspectorPanelProps) { + return ( + + ) +} diff --git a/frontend/src/ProductViews.tsx b/frontend/src/ProductViews.tsx index 3841c36..42aee11 100644 --- a/frontend/src/ProductViews.tsx +++ b/frontend/src/ProductViews.tsx @@ -1,14 +1,70 @@ -import type { ReactNode } from 'react' +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 function TwoDWorkspace({ editor, inspector }: { editor: ReactNode; inspector: ReactNode }) { - return
{editor}{inspector}
+export type TwoDWorkspaceProps = { + editor: FloorplanEditorPanelProps + inspector: InspectorPanelProps } -export function ThreeDConfirmation({ preview, unavailable, previewAvailable, canOpenLinkedWorkspace, onBack, onComplete }: { preview: ReactNode; unavailable: ReactNode; previewAvailable: boolean; canOpenLinkedWorkspace: boolean; onBack: () => void; onComplete: () => void }) { - return

确认 3D 空间

这是同一份已校正 2D 数据生成的真实 3D 预览。

{canOpenLinkedWorkspace && }
{previewAvailable ? preview : unavailable}
+export function TwoDWorkspace({ editor, inspector }: TwoDWorkspaceProps) { + return
} -export function LinkedWorkspace({ editor, preview, inspector, previewAvailable, onBack }: { editor: ReactNode; preview: ReactNode; inspector: ReactNode; previewAvailable: boolean; onBack: () => void }) { - if (!previewAvailable) return

当前 3D 预览不可用

联动工作台已关闭,请先返回 2D 校正。

- return
{editor}{preview}{inspector}
+type ThreeDUnavailableProps = { + geometryValidationError: string | null + wasmState: 'idle' | 'loading' | 'active' | 'fallback' + onBack: () => void +} + +function ThreeDUnavailablePanel({ geometryValidationError, wasmState, onBack }: ThreeDUnavailableProps) { + return ( +
+ {geometryValidationError ? ( + <>

当前开口数据无法生成 3D

请返回 2D 校正后修复数据,再重新生成 3D。

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

正在准备 3D 预览

3D 几何准备完成后,才能打开联动工作台。

+ ) : ( + <>

当前 3D 预览不可用

无法生成可靠的 3D 几何。请返回 2D 校正后重试,或在支持 WebGL 的浏览器中打开。

+ )} + +
+ ) +} + +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) { + return ( +
+
+

确认 3D 空间

这是同一份已校正 2D 数据生成的真实 3D 预览。

+
{canOpenLinkedWorkspace && }
+
+ {previewAvailable ? : } +
+ ) +} + +export type LinkedWorkspaceProps = TwoDWorkspaceProps & { + preview: ThreeDPreviewPanelProps + previewAvailable: boolean + onBack: () => void +} + +export function LinkedWorkspace({ editor, preview, inspector, previewAvailable, onBack }: LinkedWorkspaceProps) { + if (!previewAvailable) { + return

当前 3D 预览不可用

联动工作台已关闭,请先返回 2D 校正。

+ } + return
} diff --git a/frontend/src/ProjectSaveView.tsx b/frontend/src/ProjectSaveView.tsx new file mode 100644 index 0000000..acd7440 --- /dev/null +++ b/frontend/src/ProjectSaveView.tsx @@ -0,0 +1,43 @@ +import type { ProjectDetail, ProjectSummary } from './projects' + +export type ProjectSaveViewProps = { + projectName: string + currentProject: ProjectDetail | null + projects: readonly ProjectSummary[] + projectMessage: string + projectBusy: 'list' | 'save' | 'load' | null + canSave: boolean + onProjectNameChange: (name: string) => void + onSave: () => void + onRefresh: () => void + onLoad: (id: string) => void +} + +export function ProjectSaveView({ + projectName, + currentProject, + projects, + projectMessage, + projectBusy, + canSave, + onProjectNameChange, + onSave, + onRefresh, + onLoad, +}: ProjectSaveViewProps) { + return ( +
+

保存项目

+

手动保存当前同源 2D 与 3D 数据。保存不可用时会保留当前编辑,不会假装成功。

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

{projectMessage}

} +
+

已保存项目

+
    {projects.map((item) =>
  • {item.name} r{item.revision}
  • )}{projects.length === 0 &&
  • 暂无已保存项目
  • }
+
+
+
+ ) +} diff --git a/frontend/src/SourceImportView.tsx b/frontend/src/SourceImportView.tsx new file mode 100644 index 0000000..51ff806 --- /dev/null +++ b/frontend/src/SourceImportView.tsx @@ -0,0 +1,60 @@ +export type ParseViewStatus = 'idle' | 'uploading' | 'ready' | 'error' + +type SourceFileProps = { + selectedFile: File | null + previewURL: string + onFileChange: (file: File | null) => void +} + +function SourcePreview({ selectedFile, previewURL, onFileChange }: SourceFileProps) { + if (!selectedFile || !previewURL) return null + return ( +
+
+
+

原图预览

+

{selectedFile.name} · {selectedFile.type || '未知类型'} · {selectedFile.size.toLocaleString()} bytes

+
+ +
+ 上传户型图预览 +
+ ) +} + +export function SourceImportView({ selectedFile, previewURL, onFileChange }: SourceFileProps) { + return ( +
+

导入真实户型图

+

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

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

AI 识别

+

识别前请核对这张原图;识别完成后才会打开可校正的同源 2D 数据。

+ + +
识别状态:{status === 'ready' ? '解析完成' : status === 'uploading' ? '解析中' : status === 'error' ? '失败' : '等待开始'}{error &&

{error}

}
+ {status === 'error' && selectedFile && } +
+ ) +} diff --git a/frontend/src/ThreeDPreview.tsx b/frontend/src/ThreeDPreview.tsx index ae7faa7..283b2fb 100644 --- a/frontend/src/ThreeDPreview.tsx +++ b/frontend/src/ThreeDPreview.tsx @@ -1,8 +1,8 @@ -import { Suspense, useEffect } from 'react' +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, type WallShellModel } from './wallShell' +import { buildWallShellPieces, frameWallShellModel, WINDOW_OPENING_HEIGHT, WINDOW_SILL_HEIGHT, type WallShellModel } from './wallShell' export type ThreeDRenderer = { state: RootState; generation: string } @@ -14,10 +14,87 @@ type RendererLifecycleProps = { function RendererLifecycle({ generation, onMount, onUnmount }: RendererLifecycleProps) { const state = useThree() + const stateRef = useRef(state) + stateRef.current = state useEffect(() => { - onMount({ state, generation }) + // 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, state]) + }, [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 canvas. The off-white canonical + // pieces (or their violet selected state) must occupy real pixels in + // the current render buffer before Step 4/5 or export can proceed. + let wallPixels = 0 + for (let index = 0; index < pixels.length; index += 4) { + const red = pixels[index] + const green = pixels[index + 1] + const blue = pixels[index + 2] + if ((red > 145 && green > 145 && blue > 150) || (red > 85 && blue > 135 && blue - green > 20)) { + wallPixels += 1 + if (wallPixels >= Math.max(24, Math.floor(width * height * 0.00005))) { + onFrameRendered(generation) + return + } + } + } + }) + }) + 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 } @@ -33,6 +110,7 @@ export type ThreeDPreviewProps = { onSelectOpening: (openingID: string) => void onRendererMount: (renderer: ThreeDRenderer) => void onRendererUnmount: (generation: string) => void + onFrameRendered: (generation: string) => void } function CanonicalScene({ @@ -43,7 +121,7 @@ function CanonicalScene({ selectedOpeningID, onSelectWall, onSelectOpening, -}: Omit) { +}: Omit) { const pieces = buildWallShellPieces(model) return ( <> @@ -52,10 +130,10 @@ function CanonicalScene({ {model.floor && ( - + )} - + {/* Keep the generated WASM mesh in the mounted scene for a single canonical/WASM/renderer revision. Canonical pieces are the visible @@ -73,17 +151,22 @@ function CanonicalScene({ onClick={(event) => { event.stopPropagation(); onSelectWall(piece.wallId) }} > - + ))} {selectedWallID && model.walls.filter((wall) => wall.id === selectedWallID).map((wall) => ( - - + + - + ))} @@ -103,7 +186,10 @@ function CanonicalScene({ {model.openings.map((opening) => { const isDoor = opening.kind === 'door' - const markerHeight = isDoor ? 3.2 : 3.7 + // Put the stable-ID selector at the actual opening center instead of + // floating it above the wall. It makes the physical cut-out reviewable + // and keeps 2D/3D selection targets semantically aligned. + const markerHeight = isDoor ? 1.4 : WINDOW_SILL_HEIGHT + WINDOW_OPENING_HEIGHT / 2 return (
当前浏览器无法显示 3D 预览。请在启用 WebGL 的浏览器中打开;2D 校正仍可继续。
} return ( - - + + + + - + ) diff --git a/frontend/src/ThreeDPreviewPanel.tsx b/frontend/src/ThreeDPreviewPanel.tsx new file mode 100644 index 0000000..215cfcb --- /dev/null +++ b/frontend/src/ThreeDPreviewPanel.tsx @@ -0,0 +1,21 @@ +import { ThreeDPreview, type ThreeDPreviewProps } from './ThreeDPreview' + +export type ThreeDPreviewPanelProps = ThreeDPreviewProps & { + geometryValidationError: string | null +} + +/** Product-facing 3D panel. It retains the typed R3F/WASM revision interface, + * so workspaces cannot replace the real renderer with arbitrary markup. */ +export function ThreeDPreviewPanel({ geometryValidationError, ...previewProps }: ThreeDPreviewPanelProps) { + return ( +
+
+
3D 空间 · 同源可编辑预览

已生成可审阅的同源 3D 几何

+

选择墙体、门窗可在两个视图中保持一致。

+ {geometryValidationError &&

当前开口数据无法生成 3D,请返回 2D 校正后重试。

} +
+
+
墙体高度为示意;精确高度、承重属性、墙厚与窗台高度需实测。
+
+ ) +} diff --git a/frontend/src/projectSession.test.ts b/frontend/src/projectSession.test.ts new file mode 100644 index 0000000..cc5db05 --- /dev/null +++ b/frontend/src/projectSession.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { projectSaveIssue } from './projectSession' +import type { ProjectDetail } from './projects' + +const document = { + filename: 'plan.png', + contentType: 'image/png', + size: 12, + result: { rooms: [], walls: [], doors: [], windows: [], scale: { unit: 'px' }, metadata: { source: 'fixture' } }, +} + +const project: ProjectDetail = { + id: '00000000-0000-0000-0000-000000000001', + name: 'Home', + revision: 2, + 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: 12, + document, +} + +describe('project session save admission', () => { + it('fails closed before persistence if the canonical document is absent or invalid', () => { + expect(projectSaveIssue({ document: null, geometryValidationError: null, projectName: 'Home', currentProject: null, sourceFile: null })).toContain('完成户型解析') + expect(projectSaveIssue({ document, geometryValidationError: 'wall-1 长度为零', projectName: 'Home', currentProject: null, sourceFile: new File(['png'], 'plan.png') })).toContain('几何无效') + }) + + it('requires a source image only for a newly created project, never for updating a loaded project', () => { + expect(projectSaveIssue({ document, geometryValidationError: null, projectName: ' ', currentProject: project, sourceFile: null })).toContain('项目名称') + expect(projectSaveIssue({ document, geometryValidationError: null, projectName: 'Home', currentProject: null, sourceFile: null })).toContain('原始户型图') + expect(projectSaveIssue({ document, geometryValidationError: null, projectName: 'Home', currentProject: project, sourceFile: null })).toBeNull() + }) +}) diff --git a/frontend/src/projectSession.ts b/frontend/src/projectSession.ts new file mode 100644 index 0000000..6877315 --- /dev/null +++ b/frontend/src/projectSession.ts @@ -0,0 +1,155 @@ +import type { ParseResponse } from './floorplanUi' +import { + createProject, + getProject, + listProjects, + updateProject, + type ProjectDetail, + type ProjectSummary, +} from './projects' + +export type ProjectBusy = 'list' | 'save' | 'load' | null + +export function projectSaveIssue({ + document, + geometryValidationError, + projectName, + currentProject, + sourceFile, +}: { + document: ParseResponse | null + geometryValidationError: string | null + projectName: string + currentProject: ProjectDetail | null + sourceFile: File | null +}): string | null { + if (!document) return '请先完成户型解析后再创建项目' + if (geometryValidationError) return '当前户型几何无效,请返回 2D 校正后再保存' + if (!projectName.trim()) return '请输入项目名称' + if (!currentProject && !sourceFile) return '创建项目需要原始户型图' + return null +} + +type Request = { id: number; controller: AbortController } + +export type ProjectSession = { + projectName: string + currentProject: ProjectDetail | null + projects: readonly ProjectSummary[] + projectMessage: string + projectBusy: ProjectBusy + setProjectName: (name: string) => void + clearCurrentProject: () => void + refreshProjects: () => Promise + saveProject: () => Promise + loadProject: (id: string) => Promise + dispose: () => void +} + +/** Persistence controller with request identity/abort ownership. Product and + * editor state remain in App, but views receive only this controller's typed + * commands rather than project API details. */ +export function createProjectSession({ + document, + geometryValidationError, + sourceFile, + projectName, + currentProject, + projects, + onProjectSaved, + onProjectLoaded, + onState, +}: { + document: () => ParseResponse | null + geometryValidationError: () => string | null + sourceFile: () => File | null + projectName: () => string + currentProject: () => ProjectDetail | null + projects: () => readonly ProjectSummary[] + onProjectSaved: () => void + onProjectLoaded: (project: ProjectDetail, sourceImage: Blob) => void + onState: (next: Partial>) => void +}): Omit { + let request: Request | null = null + let sequence = 0 + const beginRequest = (): Request => { + request?.controller.abort() + const next = { id: sequence + 1, controller: new AbortController() } + sequence = next.id + request = next + return next + } + const isCurrent = (next: Request): boolean => request?.id === next.id + + return { + async refreshProjects() { + const active = beginRequest() + onState({ projectBusy: 'list' }) + try { + const projects = await listProjects(active.controller.signal) + if (isCurrent(active)) onState({ projects }) + } catch (error) { + if (!active.controller.signal.aborted && isCurrent(active)) onState({ projectMessage: `项目列表加载失败:${error instanceof Error ? error.message : '未知错误'}` }) + } finally { + if (isCurrent(active)) onState({ projectBusy: null }) + } + }, + async saveProject() { + const issue = projectSaveIssue({ + document: document(), + geometryValidationError: geometryValidationError(), + projectName: projectName(), + currentProject: currentProject(), + sourceFile: sourceFile(), + }) + if (issue) { + onState({ projectMessage: issue }) + return + } + const active = beginRequest() + const durableDocument = document() + const existing = currentProject() + const name = projectName() + onState({ projectBusy: 'save', projectMessage: '' }) + try { + const saved = existing + ? await updateProject(existing.id, name, durableDocument!, existing.revision, active.controller.signal) + : await createProject(name, durableDocument!, sourceFile()!, active.controller.signal) + if (!isCurrent(active)) return + onState({ + currentProject: saved, + projectName: saved.name, + projectMessage: existing ? '项目已保存' : '项目已创建', + projects: [saved, ...projects().filter((item) => item.id !== saved.id)], + }) + onProjectSaved() + } catch (error) { + if (!active.controller.signal.aborted && isCurrent(active)) onState({ projectMessage: `项目保存失败:${error instanceof Error ? error.message : '未知错误'}` }) + } finally { + if (isCurrent(active)) onState({ projectBusy: null }) + } + }, + async loadProject(id: string) { + const active = beginRequest() + onState({ projectBusy: 'load', projectMessage: '' }) + try { + const loaded = await getProject(id, active.controller.signal) + const imageResponse = await fetch(loaded.sourceImageURL, { signal: active.controller.signal }) + if (!imageResponse.ok) throw new Error(`HTTP ${imageResponse.status}: 无法加载原始户型图`) + const sourceImage = await imageResponse.blob() + if (!sourceImage.type.startsWith('image/')) throw new Error('原始户型图不是受支持的图片') + if (!isCurrent(active)) return + onState({ currentProject: loaded, projectName: loaded.name, projectMessage: '项目已加载' }) + onProjectLoaded(loaded, sourceImage) + } catch (error) { + if (!active.controller.signal.aborted && isCurrent(active)) onState({ projectMessage: `项目加载失败:${error instanceof Error ? error.message : '未知错误'}` }) + } finally { + if (isCurrent(active)) onState({ projectBusy: null }) + } + }, + dispose() { + request?.controller.abort() + request = null + }, + } +} diff --git a/frontend/src/threeDExport.test.ts b/frontend/src/threeDExport.test.ts index f03cbac..c4ea43f 100644 --- a/frontend/src/threeDExport.test.ts +++ b/frontend/src/threeDExport.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { canExportCurrentThreeD } from './threeDExport' -const ready = { isExporting: false, hasModel: true, webGLAvailable: true, wasmActive: true, hasWasmGeometry: true, rendererMounted: true, rendererGeneration: 'r4', geometryGeneration: 'r4', canonicalGeneration: 'r4' } +const ready = { isExporting: false, hasModel: true, webGLAvailable: true, wasmActive: true, hasWasmGeometry: true, rendererMounted: true, rendererGeneration: 'r4', geometryGeneration: 'r4', frameGeneration: 'r4', canonicalGeneration: 'r4' } describe('3D export admission', () => { it('requires current WASM geometry and its mounted renderer', () => { expect(canExportCurrentThreeD(ready)).toBe(true) @@ -10,6 +10,8 @@ describe('3D export admission', () => { expect(canExportCurrentThreeD({ ...ready, rendererMounted: false })).toBe(false) expect(canExportCurrentThreeD({ ...ready, rendererGeneration: 'r3' })).toBe(false) expect(canExportCurrentThreeD({ ...ready, geometryGeneration: 'r3' })).toBe(false) + expect(canExportCurrentThreeD({ ...ready, frameGeneration: null })).toBe(false) + expect(canExportCurrentThreeD({ ...ready, frameGeneration: 'r3' })).toBe(false) expect(canExportCurrentThreeD({ ...ready, canonicalGeneration: null })).toBe(false) }) }) diff --git a/frontend/src/threeDExport.ts b/frontend/src/threeDExport.ts index 4e4fb0d..70a8bd3 100644 --- a/frontend/src/threeDExport.ts +++ b/frontend/src/threeDExport.ts @@ -7,6 +7,7 @@ export function canExportCurrentThreeD({ rendererMounted, rendererGeneration, geometryGeneration, + frameGeneration, canonicalGeneration, }: { isExporting: boolean @@ -17,7 +18,17 @@ export function canExportCurrentThreeD({ rendererMounted: boolean rendererGeneration: string | null geometryGeneration: string | null + frameGeneration: string | null canonicalGeneration: string | null }): boolean { - return hasModel && webGLAvailable && wasmActive && hasWasmGeometry && rendererMounted && canonicalGeneration !== null && canonicalGeneration === geometryGeneration && canonicalGeneration === rendererGeneration && !isExporting + return hasModel && + webGLAvailable && + wasmActive && + hasWasmGeometry && + rendererMounted && + canonicalGeneration !== null && + canonicalGeneration === geometryGeneration && + canonicalGeneration === rendererGeneration && + canonicalGeneration === frameGeneration && + !isExporting } diff --git a/frontend/src/threeDGeneration.test.ts b/frontend/src/threeDGeneration.test.ts index 0920d7e..b3bb8d3 100644 --- a/frontend/src/threeDGeneration.test.ts +++ b/frontend/src/threeDGeneration.test.ts @@ -1,22 +1,25 @@ import { describe, expect, it } from 'vitest' -import { beginCanonicalGeneration, emptyThreeDGenerationState, hasCurrentThreeDGeneration, mountRendererGeneration, resolveWasmGeneration } from './threeDGeneration' +import { acknowledgeRenderedFrame, beginCanonicalGeneration, emptyThreeDGenerationState, hasCurrentThreeDGeneration, mountRendererGeneration, resolveWasmGeneration } from './threeDGeneration' describe('canonical → WASM → renderer generation controller', () => { it('fails closed synchronously on a canonical transition before effects clean old resources', () => { let state = beginCanonicalGeneration('r1') state = resolveWasmGeneration(state, 'r1') state = mountRendererGeneration(state, 'r1') + state = acknowledgeRenderedFrame(state, 'r1') expect(hasCurrentThreeDGeneration(state)).toBe(true) state = beginCanonicalGeneration('r2') expect(hasCurrentThreeDGeneration(state)).toBe(false) expect(mountRendererGeneration(state, 'r1')).toEqual(state) expect(resolveWasmGeneration(state, 'r1')).toEqual(state) }) - it('requires the mounted renderer and WASM success for the same canonical token', () => { + it('requires a completed frame from the mounted renderer and WASM success for the same canonical token', () => { let state = beginCanonicalGeneration('r3') state = mountRendererGeneration(state, 'r3') - expect(hasCurrentThreeDGeneration(state)).toBe(false) state = resolveWasmGeneration(state, 'r3') + expect(hasCurrentThreeDGeneration(state)).toBe(false) + expect(acknowledgeRenderedFrame(state, 'r2')).toEqual(state) + state = acknowledgeRenderedFrame(state, 'r3') expect(hasCurrentThreeDGeneration(state)).toBe(true) expect(emptyThreeDGenerationState().canonicalToken).toBeNull() }) diff --git a/frontend/src/threeDGeneration.ts b/frontend/src/threeDGeneration.ts index 627f5de..68ca1f8 100644 --- a/frontend/src/threeDGeneration.ts +++ b/frontend/src/threeDGeneration.ts @@ -4,16 +4,18 @@ export type ThreeDGenerationState = { canonicalToken: string | null geometryToken: string | null rendererToken: string | null + frameToken: string | null wasmReady: boolean rendererMounted: boolean + frameRendered: boolean } export const emptyThreeDGenerationState = (): ThreeDGenerationState => ({ - canonicalToken: null, geometryToken: null, rendererToken: null, wasmReady: false, rendererMounted: false, + canonicalToken: null, geometryToken: null, rendererToken: null, frameToken: null, wasmReady: false, rendererMounted: false, frameRendered: false, }) export function beginCanonicalGeneration(token: string | null): ThreeDGenerationState { - return { canonicalToken: token, geometryToken: null, rendererToken: null, wasmReady: false, rendererMounted: false } + return { canonicalToken: token, geometryToken: null, rendererToken: null, frameToken: null, wasmReady: false, rendererMounted: false, frameRendered: false } } export function resolveWasmGeneration(state: ThreeDGenerationState, token: string): ThreeDGenerationState { @@ -26,10 +28,27 @@ export function mountRendererGeneration(state: ThreeDGenerationState, token: str return { ...state, rendererToken: token, rendererMounted: true } } +/** A mounted R3F root is insufficient: an export/step completion can only use + * a generation after that root has completed a frame for the same revision. */ +export function acknowledgeRenderedFrame(state: ThreeDGenerationState, token: string): ThreeDGenerationState { + if (!token || state.canonicalToken !== token || state.rendererToken !== token) return state + return { ...state, frameToken: token, frameRendered: true } +} + export function unmountRendererGeneration(state: ThreeDGenerationState, token: string): ThreeDGenerationState { - return state.rendererToken === token ? { ...state, rendererToken: null, rendererMounted: false } : state + return state.rendererToken === token + ? { ...state, rendererToken: null, frameToken: null, rendererMounted: false, frameRendered: false } + : state } export function hasCurrentThreeDGeneration(state: ThreeDGenerationState): boolean { - return Boolean(state.canonicalToken && state.wasmReady && state.rendererMounted && state.canonicalToken === state.geometryToken && state.canonicalToken === state.rendererToken) + return Boolean( + state.canonicalToken && + state.wasmReady && + state.rendererMounted && + state.frameRendered && + state.canonicalToken === state.geometryToken && + state.canonicalToken === state.rendererToken && + state.canonicalToken === state.frameToken, + ) } diff --git a/frontend/src/useProjectSession.ts b/frontend/src/useProjectSession.ts new file mode 100644 index 0000000..7d20ed2 --- /dev/null +++ b/frontend/src/useProjectSession.ts @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from 'react' +import type { ParseResponse } from './floorplanUi' +import type { ProjectDetail, ProjectSummary } from './projects' +import { createProjectSession, type ProjectBusy, type ProjectSession } from './projectSession' + +type ProjectSessionOptions = { + document: ParseResponse | null + geometryValidationError: string | null + sourceFile: File | null + onProjectSaved: () => void + onProjectLoaded: (project: ProjectDetail, sourceImage: Blob) => void +} + +type ProjectSessionState = Pick + +const initialState: ProjectSessionState = { + projectName: '', + currentProject: null, + projects: [], + projectMessage: '', + projectBusy: null, +} + +export type UseProjectSession = ProjectSession + +/** React state boundary for durable project persistence. The stable controller + * owns cancellation/request identity while App supplies the current canonical + * document and applies a successfully loaded document to the editor. */ +export function useProjectSession(options: ProjectSessionOptions): UseProjectSession { + const [state, setState] = useState(initialState) + const documentRef = useRef(options.document) + const geometryErrorRef = useRef(options.geometryValidationError) + const sourceFileRef = useRef(options.sourceFile) + const savedRef = useRef(options.onProjectSaved) + const loadedRef = useRef(options.onProjectLoaded) + const stateRef = useRef(state) + documentRef.current = options.document + geometryErrorRef.current = options.geometryValidationError + sourceFileRef.current = options.sourceFile + savedRef.current = options.onProjectSaved + loadedRef.current = options.onProjectLoaded + stateRef.current = state + + const controllerRef = useRef | null>(null) + if (!controllerRef.current) { + controllerRef.current = createProjectSession({ + document: () => documentRef.current, + geometryValidationError: () => geometryErrorRef.current, + sourceFile: () => sourceFileRef.current, + projectName: () => stateRef.current.projectName, + currentProject: () => stateRef.current.currentProject, + projects: () => stateRef.current.projects, + onProjectSaved: () => savedRef.current(), + onProjectLoaded: (project, sourceImage) => loadedRef.current(project, sourceImage), + onState: (next) => { + setState((current) => ({ ...current, ...next })) + }, + }) + } + const controller = controllerRef.current + + useEffect(() => () => controller.dispose(), [controller]) + + return { + ...state, + setProjectName: (projectName: string) => setState((current) => ({ ...current, projectName })), + clearCurrentProject: () => setState((current) => ({ ...current, currentProject: null })), + refreshProjects: controller.refreshProjects, + saveProject: controller.saveProject, + loadProject: controller.loadProject, + dispose: controller.dispose, + } +} + +export type { ProjectBusy, ProjectDetail, ProjectSummary } diff --git a/frontend/src/useThreeDGenerationController.ts b/frontend/src/useThreeDGenerationController.ts index 27408a4..8514863 100644 --- a/frontend/src/useThreeDGenerationController.ts +++ b/frontend/src/useThreeDGenerationController.ts @@ -5,11 +5,13 @@ import { hasCurrentThreeDGeneration } from './threeDGeneration' export type ThreeDGenerationController = { geometryRevision: string | null renderer: ThreeDRenderer | null + frameRevision: string | null isCurrent: (wasmReady: boolean) => boolean invalidateGeometry: () => void resolveGeometry: (revision: string) => void mountRenderer: (renderer: ThreeDRenderer) => void unmountRenderer: (revision: string) => void + acknowledgeFrame: (revision: string) => void } /** Owns the renderer and WASM revision hand-off. Canonical revision is an @@ -18,9 +20,11 @@ export type ThreeDGenerationController = { export function useThreeDGenerationController(canonicalRevision: string | null): ThreeDGenerationController { const [geometryRevision, setGeometryRevision] = useState(null) const [renderer, setRenderer] = useState(null) + const [frameRevision, setFrameRevision] = useState(null) const invalidateGeometry = useCallback(() => { setGeometryRevision(null) + setFrameRevision(null) }, []) const resolveGeometry = useCallback((revision: string) => { @@ -33,18 +37,26 @@ export function useThreeDGenerationController(canonicalRevision: string | null): // Canvas lifecycle callbacks can flush child effects before an ancestor // effect. Store the callback and compare tokens in isCurrent instead. setRenderer(next) + setFrameRevision(null) }, []) const unmountRenderer = useCallback((revision: string) => { setRenderer((current) => current?.generation === revision ? null : current) + setFrameRevision((current) => current === revision ? null : current) + }, []) + + const acknowledgeFrame = useCallback((revision: string) => { + setFrameRevision(revision) }, []) const state = useMemo(() => ({ canonicalToken: canonicalRevision, geometryToken: geometryRevision, rendererToken: renderer?.generation ?? null, + frameToken: frameRevision, rendererMounted: renderer !== null, - }), [canonicalRevision, geometryRevision, renderer]) + frameRendered: frameRevision !== null, + }), [canonicalRevision, frameRevision, geometryRevision, renderer]) const isCurrent = useCallback((wasmReady: boolean) => hasCurrentThreeDGeneration({ ...state, @@ -54,10 +66,12 @@ export function useThreeDGenerationController(canonicalRevision: string | null): return { geometryRevision, renderer, + frameRevision, isCurrent, invalidateGeometry, resolveGeometry, mountRenderer, unmountRenderer, + acknowledgeFrame, } } diff --git a/frontend/src/wallShell.test.ts b/frontend/src/wallShell.test.ts index 0f3f871..af5fed0 100644 --- a/frontend/src/wallShell.test.ts +++ b/frontend/src/wallShell.test.ts @@ -132,10 +132,13 @@ describe('3D wall shell model', () => { describe('3D scene framing', () => { it('fits the normalized floorplan rather than relying on a fixed camera distance', () => { - const frame = frameWallShellModel(buildWallShellModel(rectangleWalls, [], [])) + const model = buildWallShellModel(rectangleWalls, [], []) + const frame = frameWallShellModel(model) + const narrowFrame = frameWallShellModel(model, 0.85) expect(frame.floorSpan).toBeGreaterThan(8) - expect(frame.position[0]).toBeGreaterThan(frame.floorSpan) + expect(frame.position[0]).toBeGreaterThan(frame.floorSpan * 0.9) expect(frame.target[1]).toBeGreaterThan(0) + expect(narrowFrame.position[0]).toBeGreaterThan(frame.position[0]) }) }) diff --git a/frontend/src/wallShell.ts b/frontend/src/wallShell.ts index 330c5bb..de53c8d 100644 --- a/frontend/src/wallShell.ts +++ b/frontend/src/wallShell.ts @@ -205,12 +205,25 @@ export function buildWallShellModel( } } -export type ThreeDFrame = { position: readonly [number, number, number]; target: readonly [number, number, number]; floorSpan: number } -/** Camera derived from the same normalized shell, rather than a fixed scene-size guess. */ -export function frameWallShellModel(model: WallShellModel): ThreeDFrame { +export type ThreeDFrame = { + position: readonly [number, number, number] + target: readonly [number, number, number] + floorSpan: number +} + +/** Camera derived from normalized floorplan bounds and the actual canvas aspect + * ratio. The previous fixed-distance framing made a valid floorplan occupy too + * little of Step 4/5 on production layouts. */ +export function frameWallShellModel(model: WallShellModel, aspectRatio = 16 / 9): ThreeDFrame { const floorSpan = Math.max(model.floor?.width ?? 0, model.floor?.depth ?? 0, 1) - const distance = floorSpan * 1.16 + WALL_SHELL_HEIGHT * 1.5 - return { position: [distance, distance * 0.72, distance], target: [0, WALL_SHELL_HEIGHT * 0.38, 0], floorSpan } + const aspect = Number.isFinite(aspectRatio) ? Math.min(2.6, Math.max(0.8, aspectRatio)) : 16 / 9 + const horizontalDistance = floorSpan * (aspect >= 1.2 ? 0.92 : 1.06) + WALL_SHELL_HEIGHT * 0.55 + const elevation = Math.max(WALL_SHELL_HEIGHT * 2.25, floorSpan * 0.58 + WALL_SHELL_HEIGHT * 0.7) + return { + position: [horizontalDistance, elevation, horizontalDistance], + target: [0, WALL_SHELL_HEIGHT * 0.38, 0], + floorSpan, + } } /** Builds visible wall spans from canonical openings. The renderer uses these From 0ed336b76f2b73b6e61000e5a580730caa6ae798 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 25 Jul 2026 16:15:40 +0800 Subject: [PATCH 12/39] fix: close 3D frame and export races --- frontend/e2e/production.spec.ts | 58 ++++++- frontend/src/App.tsx | 43 ++++- frontend/src/ThreeDPreview.tsx | 21 +-- frontend/src/projectSession.test.ts | 155 +++++++++++++++++- frontend/src/projectSession.ts | 58 +++++-- frontend/src/threeDExportSession.test.ts | 76 +++++++++ frontend/src/threeDExportSession.ts | 65 ++++++++ frontend/src/threeDFrameAnalysis.test.ts | 58 +++++++ frontend/src/threeDFrameAnalysis.ts | 191 +++++++++++++++++++++++ 9 files changed, 686 insertions(+), 39 deletions(-) create mode 100644 frontend/src/threeDExportSession.test.ts create mode 100644 frontend/src/threeDExportSession.ts create mode 100644 frontend/src/threeDFrameAnalysis.test.ts create mode 100644 frontend/src/threeDFrameAnalysis.ts diff --git a/frontend/e2e/production.spec.ts b/frontend/e2e/production.spec.ts index ab1d3f6..e7c6b2e 100644 --- a/frontend/e2e/production.spec.ts +++ b/frontend/e2e/production.spec.ts @@ -110,8 +110,10 @@ type PixelFootprint = { } function isWallRgb(red: number, green: number, blue: number): boolean { - // Off-white canonical walls and violet selected wall spans. This excludes - // the dark background/grid and does not accept a few Html control dots. + // 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) } @@ -605,3 +607,55 @@ test('disables export for a valid canonical edit until the matching WASM, render }, 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.getByRole('button', { name: '继续' }).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: '3D 选择窗 window-1' }).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('导出3D PNG') + expect(downloads).toBe(0) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fc5044d..8399106 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -48,10 +48,12 @@ import { ProjectSaveView } from './ProjectSaveView' import { LinkedWorkspace, ThreeDConfirmation, TwoDWorkspace } from './ProductViews' import { SourceImportView, AIParseView, type ParseViewStatus } from './SourceImportView' 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 { e2EProjectID, e2EWasmLoader, isE2EInstrumentationEnabled, publishE2EState } from '@homevox-e2e' import './App.css' @@ -227,6 +229,13 @@ export default function App() { const [, setWasmGeneration] = useState(0) const wasmGeometryRef = useRef(null) const wasmCallsRef = useRef(0) + const threeDExportRevisionRef = useRef>({ + canonicalRevision: null, + geometryRevision: null, + rendererRevision: null, + frameRevision: null, + renderer: null, + }) const result = parseResponse?.result ?? null const walls = dragPreviewWalls ?? wallEditor?.walls ?? result?.walls ?? EMPTY_WALLS @@ -356,6 +365,16 @@ export default function App() { frameGeneration: frameRevision, canonicalGeneration: canonicalRevision, }) + // This ref is updated during every render instead of in an effect so an + // event that starts a toBlob export always compares against the latest + // canonical/WASM/renderer/frame identity after any legal edit. + threeDExportRevisionRef.current = { + canonicalRevision, + geometryRevision, + rendererRevision: threeRenderer?.generation ?? null, + frameRevision, + renderer: threeRenderer, + } useEffect(() => { if (!isE2EInstrumentationEnabled()) return @@ -910,7 +929,9 @@ export default function App() { } async function handleExport3D() { - if (!canExport3D || !threeRenderer) { + const revision = threeDExportRevisionRef.current + const renderer = revision.renderer + if (!canExport3D || !renderer) { return } @@ -918,16 +939,22 @@ export default function App() { setExportError('') try { - const rendererState = threeRenderer.state - rendererState.gl.render(rendererState.scene, rendererState.camera) const fileName = buildScopeFileName('3d') - const exportResult = await exportWebGLCanvasToPng(rendererState.gl, fileName) - if (!exportResult.ok) { - setExportError(exportResult.error.message) + const result = await exportCurrentThreeDRevision({ + revision, + readCurrent: () => threeDExportRevisionRef.current, + render: () => { + const rendererState = renderer.state + rendererState.gl.render(rendererState.scene, rendererState.camera) + }, + exportPng: () => exportWebGLCanvasToPng(renderer.state.gl, fileName), + download: downloadBlobAsPng, + onStale: () => setExportError('模型已更新,请重新导出。'), + }) + if (result.status === 'failure') { + setExportError(result.error.message) return } - - downloadBlobAsPng(exportResult.value) } catch (error) { setExportError(`3D 导出失败:${error instanceof Error ? error.message : '未知错误'}`) } finally { diff --git a/frontend/src/ThreeDPreview.tsx b/frontend/src/ThreeDPreview.tsx index 283b2fb..e43ce8f 100644 --- a/frontend/src/ThreeDPreview.tsx +++ b/frontend/src/ThreeDPreview.tsx @@ -3,6 +3,7 @@ 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 { analyzeCurrentThreeDFrame } from './threeDFrameAnalysis' export type ThreeDRenderer = { state: RootState; generation: string } @@ -57,22 +58,10 @@ function RenderedFrameLifecycle({ generation, onFrameRendered }: { pixels, ) if (context.getError() !== context.NO_ERROR) return - // Do not admit a background-only canvas. The off-white canonical - // pieces (or their violet selected state) must occupy real pixels in - // the current render buffer before Step 4/5 or export can proceed. - let wallPixels = 0 - for (let index = 0; index < pixels.length; index += 4) { - const red = pixels[index] - const green = pixels[index + 1] - const blue = pixels[index + 2] - if ((red > 145 && green > 145 && blue > 150) || (red > 85 && blue > 135 && blue - green > 20)) { - wallPixels += 1 - if (wallPixels >= Math.max(24, Math.floor(width * height * 0.00005))) { - onFrameRendered(generation) - 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 () => { diff --git a/frontend/src/projectSession.test.ts b/frontend/src/projectSession.test.ts index cc5db05..54606ef 100644 --- a/frontend/src/projectSession.test.ts +++ b/frontend/src/projectSession.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest' -import { projectSaveIssue } from './projectSession' -import type { ProjectDetail } from './projects' +import { describe, expect, it, vi } from 'vitest' +import { createProjectSession, projectSaveIssue, type ProjectSessionDependencies } from './projectSession' +import type { ProjectDetail, ProjectSummary } from './projects' const document = { filename: 'plan.png', @@ -21,6 +21,15 @@ const project: ProjectDetail = { document, } +const projectSummary: ProjectSummary = { + id: project.id, + name: project.name, + revision: project.revision, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + sourceImageURL: project.sourceImageURL, +} + describe('project session save admission', () => { it('fails closed before persistence if the canonical document is absent or invalid', () => { expect(projectSaveIssue({ document: null, geometryValidationError: null, projectName: 'Home', currentProject: null, sourceFile: null })).toContain('完成户型解析') @@ -33,3 +42,143 @@ describe('project session save admission', () => { expect(projectSaveIssue({ document, geometryValidationError: null, projectName: 'Home', currentProject: project, sourceFile: null })).toBeNull() }) }) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((next, fail) => { resolve = next; reject = fail }) + return { promise, resolve, reject } +} + +function sessionHarness({ + currentProject = null, + sourceFile = new File(['png'], 'plan.png', { type: 'image/png' }), + dependencies = {}, +}: { + currentProject?: ProjectDetail | null + sourceFile?: File | null + dependencies?: Partial +} = {}) { + const states: Array> = [] + const onProjectSaved = vi.fn() + const onProjectLoaded = vi.fn() + const defaults: ProjectSessionDependencies = { + listProjects: vi.fn().mockResolvedValue([]), + getProject: vi.fn().mockResolvedValue(project), + createProject: vi.fn().mockResolvedValue(project), + updateProject: vi.fn().mockResolvedValue({ ...project, revision: 3 }), + fetchSourceImage: vi.fn().mockResolvedValue(new Response(new Blob(['png'], { type: 'image/png' }), { status: 200 })), + } + const controller = createProjectSession({ + document: () => document, + geometryValidationError: () => null, + sourceFile: () => sourceFile, + projectName: () => 'Home', + currentProject: () => currentProject, + projects: () => [] as readonly ProjectSummary[], + onProjectSaved, + onProjectLoaded, + onState: (next) => states.push(next), + }, { ...defaults, ...dependencies }) + return { controller, states, onProjectSaved, onProjectLoaded, dependencies: { ...defaults, ...dependencies } } +} + +describe('ProjectSession controller', () => { + it('aborts the prior request and prevents a stale success from publishing over the latest request', async () => { + const first = deferred() + const second = deferred() + const listProjects = vi.fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + const { controller, states } = sessionHarness({ dependencies: { listProjects } }) + + const oldRequest = controller.refreshProjects() + const oldSignal = listProjects.mock.calls[0][0] as AbortSignal + const latestRequest = controller.refreshProjects() + expect(oldSignal.aborted).toBe(true) + + first.resolve([{ ...projectSummary, name: 'stale project' }]) + second.resolve([projectSummary]) + await Promise.all([oldRequest, latestRequest]) + + expect(states.filter((state) => 'projects' in state).at(-1)?.projects).toEqual([projectSummary]) + expect(states.some((state) => JSON.stringify(state.projects ?? []).includes('stale project'))).toBe(false) + }) + + it('does not publish a stale request error after a newer refresh succeeds', async () => { + const first = deferred() + const second = deferred() + const listProjects = vi.fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + const { controller, states } = sessionHarness({ dependencies: { listProjects } }) + + const oldRequest = controller.refreshProjects() + const latestRequest = controller.refreshProjects() + first.reject(new Error('old failure')) + second.resolve([projectSummary]) + await Promise.all([oldRequest, latestRequest]) + + expect(states.some((state) => String(state.projectMessage ?? '').includes('old failure'))).toBe(false) + expect(states.filter((state) => 'projects' in state).at(-1)?.projects).toEqual([projectSummary]) + }) + + it('aborts pending work on dispose and does not publish a late success', async () => { + const pending = deferred() + const listProjects = vi.fn().mockReturnValue(pending.promise) + const { controller, states } = sessionHarness({ dependencies: { listProjects } }) + + const request = controller.refreshProjects() + const signal = listProjects.mock.calls[0][0] as AbortSignal + const stateCountBeforeDispose = states.length + controller.dispose() + expect(signal.aborted).toBe(true) + pending.resolve([]) + await request + + expect(states).toHaveLength(stateCountBeforeDispose) + }) + + it('loads only a valid project with a successful image response and never fabricates a loaded callback', async () => { + const success = sessionHarness() + await success.controller.loadProject(project.id) + expect(success.onProjectLoaded).toHaveBeenCalledOnce() + expect(success.onProjectLoaded).toHaveBeenCalledWith(project, expect.any(Blob)) + + const failure = sessionHarness({ + dependencies: { + fetchSourceImage: vi.fn().mockResolvedValue(new Response('not an image', { status: 200, headers: { 'Content-Type': 'text/plain' } })), + }, + }) + await failure.controller.loadProject(project.id) + expect(failure.onProjectLoaded).not.toHaveBeenCalled() + expect(failure.states.some((state) => String(state.projectMessage ?? '').includes('原始户型图不是受支持的图片'))).toBe(true) + + const unavailable = sessionHarness({ + dependencies: { + fetchSourceImage: vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 })), + }, + }) + await unavailable.controller.loadProject(project.id) + expect(unavailable.onProjectLoaded).not.toHaveBeenCalled() + expect(unavailable.states.some((state) => String(state.projectMessage ?? '').includes('HTTP 503'))).toBe(true) + }) + + it('preserves create and update semantics and only completes after the API confirms success', async () => { + const pendingCreate = deferred() + const create = sessionHarness({ dependencies: { createProject: vi.fn().mockReturnValue(pendingCreate.promise) } }) + const createRequest = create.controller.saveProject() + expect(create.dependencies.createProject).toHaveBeenCalledWith('Home', document, expect.any(File), expect.any(AbortSignal)) + expect(create.dependencies.updateProject).not.toHaveBeenCalled() + expect(create.onProjectSaved).not.toHaveBeenCalled() + pendingCreate.resolve(project) + await createRequest + expect(create.onProjectSaved).toHaveBeenCalledOnce() + + const update = sessionHarness({ currentProject: project, sourceFile: null }) + await update.controller.saveProject() + expect(update.dependencies.updateProject).toHaveBeenCalledWith(project.id, 'Home', document, project.revision, expect.any(AbortSignal)) + expect(update.dependencies.createProject).not.toHaveBeenCalled() + expect(update.onProjectSaved).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/projectSession.ts b/frontend/src/projectSession.ts index 6877315..5f41a1f 100644 --- a/frontend/src/projectSession.ts +++ b/frontend/src/projectSession.ts @@ -32,6 +32,36 @@ export function projectSaveIssue({ type Request = { id: number; controller: AbortController } +export type ProjectSessionDependencies = { + listProjects: (signal?: AbortSignal) => Promise + getProject: (id: string, signal?: AbortSignal) => Promise + createProject: ( + name: string, + document: ParseResponse, + sourceImage: File, + signal?: AbortSignal, + ) => Promise + updateProject: ( + id: string, + name: string, + document: ParseResponse, + expectedRevision: number, + signal?: AbortSignal, + ) => Promise + fetchSourceImage: ( + url: string, + init: { signal: AbortSignal }, + ) => Promise +} + +const defaultProjectSessionDependencies: ProjectSessionDependencies = { + listProjects, + getProject, + createProject, + updateProject, + fetchSourceImage: (url, init) => fetch(url, init), +} + export type ProjectSession = { projectName: string currentProject: ProjectDetail | null @@ -69,25 +99,28 @@ export function createProjectSession({ onProjectSaved: () => void onProjectLoaded: (project: ProjectDetail, sourceImage: Blob) => void onState: (next: Partial>) => void -}): Omit { +}, dependencies: ProjectSessionDependencies = defaultProjectSessionDependencies): Omit { let request: Request | null = null let sequence = 0 - const beginRequest = (): Request => { + let disposed = false + const beginRequest = (): Request | null => { + if (disposed) return null request?.controller.abort() const next = { id: sequence + 1, controller: new AbortController() } sequence = next.id request = next return next } - const isCurrent = (next: Request): boolean => request?.id === next.id + const isCurrent = (next: Request): boolean => !disposed && request?.id === next.id return { async refreshProjects() { const active = beginRequest() + if (!active) return onState({ projectBusy: 'list' }) try { - const projects = await listProjects(active.controller.signal) - if (isCurrent(active)) onState({ projects }) + const nextProjects = await dependencies.listProjects(active.controller.signal) + if (isCurrent(active)) onState({ projects: nextProjects }) } catch (error) { if (!active.controller.signal.aborted && isCurrent(active)) onState({ projectMessage: `项目列表加载失败:${error instanceof Error ? error.message : '未知错误'}` }) } finally { @@ -107,14 +140,15 @@ export function createProjectSession({ return } const active = beginRequest() + if (!active) return const durableDocument = document() const existing = currentProject() const name = projectName() onState({ projectBusy: 'save', projectMessage: '' }) try { const saved = existing - ? await updateProject(existing.id, name, durableDocument!, existing.revision, active.controller.signal) - : await createProject(name, durableDocument!, sourceFile()!, active.controller.signal) + ? await dependencies.updateProject(existing.id, name, durableDocument!, existing.revision, active.controller.signal) + : await dependencies.createProject(name, durableDocument!, sourceFile()!, active.controller.signal) if (!isCurrent(active)) return onState({ currentProject: saved, @@ -131,13 +165,16 @@ export function createProjectSession({ }, async loadProject(id: string) { const active = beginRequest() + if (!active) return onState({ projectBusy: 'load', projectMessage: '' }) try { - const loaded = await getProject(id, active.controller.signal) - const imageResponse = await fetch(loaded.sourceImageURL, { signal: active.controller.signal }) + const loaded = await dependencies.getProject(id, active.controller.signal) + if (!isCurrent(active)) return + const imageResponse = await dependencies.fetchSourceImage(loaded.sourceImageURL, { signal: active.controller.signal }) if (!imageResponse.ok) throw new Error(`HTTP ${imageResponse.status}: 无法加载原始户型图`) + const contentType = imageResponse.headers.get('content-type')?.toLowerCase() ?? '' + if (!contentType.startsWith('image/')) throw new Error('原始户型图不是受支持的图片') const sourceImage = await imageResponse.blob() - if (!sourceImage.type.startsWith('image/')) throw new Error('原始户型图不是受支持的图片') if (!isCurrent(active)) return onState({ currentProject: loaded, projectName: loaded.name, projectMessage: '项目已加载' }) onProjectLoaded(loaded, sourceImage) @@ -148,6 +185,7 @@ export function createProjectSession({ } }, dispose() { + disposed = true request?.controller.abort() request = null }, diff --git a/frontend/src/threeDExportSession.test.ts b/frontend/src/threeDExportSession.test.ts new file mode 100644 index 0000000..e13b0b7 --- /dev/null +++ b/frontend/src/threeDExportSession.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExportFile, ExportResult } from './export' +import { exportCurrentThreeDRevision, type ThreeDExportRevision } from './threeDExportSession' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((next) => { resolve = next }) + return { promise, resolve } +} + +const file: ExportFile = { + fileName: 'homevox-wallshell.png', + blob: new Blob(['png'], { type: 'image/png' }), + width: 64, + height: 64, +} + +function revision(renderer: object): ThreeDExportRevision { + return { + canonicalRevision: 'r4', + geometryRevision: 'r4', + rendererRevision: 'r4', + frameRevision: 'r4', + renderer, + } +} + +describe('in-flight 3D export revision guard', () => { + it('drops a pending old toBlob result after a canonical edit instead of downloading it', async () => { + const renderer = {} + const initial = revision(renderer) + let current: ThreeDExportRevision = initial + const pending = deferred>() + const render = vi.fn() + const download = vi.fn() + const stale = vi.fn() + + const run = exportCurrentThreeDRevision({ + revision: initial, + readCurrent: () => current, + render, + exportPng: () => pending.promise, + download, + onStale: stale, + }) + expect(render).toHaveBeenCalledTimes(1) + + current = { ...initial, canonicalRevision: 'r5', geometryRevision: null, frameRevision: null } + pending.resolve({ ok: true, value: file }) + + await expect(run).resolves.toEqual({ status: 'stale' }) + expect(download).not.toHaveBeenCalled() + expect(stale).toHaveBeenCalledOnce() + }) + + it('downloads exactly once only when the frozen renderer identity and all revisions remain current', async () => { + const renderer = {} + const initial = revision(renderer) + const pending = deferred>() + const download = vi.fn() + + const run = exportCurrentThreeDRevision({ + revision: initial, + readCurrent: () => initial, + render: vi.fn(), + exportPng: () => pending.promise, + download, + onStale: vi.fn(), + }) + pending.resolve({ ok: true, value: file }) + + await expect(run).resolves.toEqual({ status: 'downloaded' }) + expect(download).toHaveBeenCalledTimes(1) + expect(download).toHaveBeenCalledWith(file) + }) +}) diff --git a/frontend/src/threeDExportSession.ts b/frontend/src/threeDExportSession.ts new file mode 100644 index 0000000..e350db8 --- /dev/null +++ b/frontend/src/threeDExportSession.ts @@ -0,0 +1,65 @@ +import type { ExportError, ExportFile, ExportResult } from './export' + +/** Exact identities frozen for one export transaction. A matching generation + * string is insufficient on its own: a remounted renderer for the same + * canonical token must not complete an old canvas readback. */ +export type ThreeDExportRevision = { + canonicalRevision: string | null + geometryRevision: string | null + rendererRevision: string | null + frameRevision: string | null + renderer: Renderer | null +} + +export type ThreeDExportOutcome = + | { status: 'downloaded' } + | { status: 'stale' } + | { status: 'failure'; error: ExportError } + +function matchesRevision( + frozen: ThreeDExportRevision, + current: ThreeDExportRevision, +): boolean { + return frozen.canonicalRevision === current.canonicalRevision && + frozen.geometryRevision === current.geometryRevision && + frozen.rendererRevision === current.rendererRevision && + frozen.frameRevision === current.frameRevision && + frozen.renderer === current.renderer +} + +/** Runs an export only while a single visible canonical/WASM/renderer frame + * remains current. Both sides of an asynchronous `canvas.toBlob` boundary are + * guarded so a legal edit can never download the old frame. */ +export async function exportCurrentThreeDRevision({ + revision, + readCurrent, + render, + exportPng, + download, + onStale, +}: { + revision: ThreeDExportRevision + readCurrent: () => ThreeDExportRevision + render: () => void + exportPng: () => Promise> + download: (file: ExportFile) => void + onStale: () => void +}): Promise { + const stale = (): ThreeDExportOutcome => { + onStale() + return { status: 'stale' } + } + + if (!matchesRevision(revision, readCurrent())) return stale() + render() + // `render()` is synchronous. Check immediately before the helper performs + // its WebGL readback and begins the asynchronous canvas.toBlob operation. + if (!matchesRevision(revision, readCurrent())) return stale() + const result = await exportPng() + // An edit, geometry invalidation, renderer replacement, or frame + // invalidation can occur while toBlob is pending. + if (!matchesRevision(revision, readCurrent())) return stale() + if (!result.ok) return { status: 'failure', error: result.error } + download(result.value) + return { status: 'downloaded' } +} diff --git a/frontend/src/threeDFrameAnalysis.test.ts b/frontend/src/threeDFrameAnalysis.test.ts new file mode 100644 index 0000000..fed4060 --- /dev/null +++ b/frontend/src/threeDFrameAnalysis.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { analyzeCurrentThreeDFrame } from './threeDFrameAnalysis' + +type Color = readonly [number, number, number, number] + +function frame(width: number, height: number, fill: Color): Uint8Array { + const pixels = new Uint8Array(width * height * 4) + for (let index = 0; index < pixels.length; index += 4) pixels.set(fill, index) + return pixels +} + +function paintRect(pixels: Uint8Array, width: number, x: number, y: number, rectWidth: number, rectHeight: number, color: Color): void { + for (let row = y; row < y + rectHeight; row += 1) { + for (let column = x; column < x + rectWidth; column += 1) { + pixels.set(color, (row * width + column) * 4) + } + } +} + +describe('current 3D frame pixel analysis', () => { + const width = 200 + const height = 140 + const background: Color = [12, 19, 37, 255] + + it('rejects background, grid section lines, floor color, and a few marker/control pixels', () => { + expect(analyzeCurrentThreeDFrame(width, height, frame(width, height, background)).accepted).toBe(false) + expect(analyzeCurrentThreeDFrame(width, height, frame(width, height, [144, 164, 198, 255])).accepted).toBe(false) + expect(analyzeCurrentThreeDFrame(width, height, frame(width, height, [83, 109, 157, 255])).accepted).toBe(false) + + const markerOnly = frame(width, height, background) + paintRect(markerOnly, width, 92, 62, 8, 8, [196, 181, 253, 255]) + expect(analyzeCurrentThreeDFrame(width, height, markerOnly).accepted).toBe(false) + + const openingMarkerOnly = frame(width, height, background) + paintRect(openingMarkerOnly, width, 92, 62, 8, 8, [124, 58, 237, 255]) + expect(analyzeCurrentThreeDFrame(width, height, openingMarkerOnly).accepted).toBe(false) + }) + + it('requires a structurally sized and distributed off-white canonical wall footprint', () => { + const pixels = frame(width, height, background) + paintRect(pixels, width, 25, 28, 126, 56, [232, 239, 249, 255]) + + const analysis = analyzeCurrentThreeDFrame(width, height, pixels) + expect(analysis.accepted).toBe(true) + expect(analysis.coverage).toBeGreaterThan(0.02) + expect(analysis.bounds.widthRatio).toBeGreaterThan(0.5) + expect(analysis.bounds.heightRatio).toBeGreaterThan(0.3) + }) + + it('accepts a structurally sized selected-violet wall footprint without accepting tiny violet controls', () => { + const pixels = frame(width, height, background) + paintRect(pixels, width, 40, 22, 112, 74, [167, 139, 250, 255]) + + const analysis = analyzeCurrentThreeDFrame(width, height, pixels) + expect(analysis.accepted).toBe(true) + expect(analysis.largestComponent.count).toBeGreaterThan(5_000) + }) +}) diff --git a/frontend/src/threeDFrameAnalysis.ts b/frontend/src/threeDFrameAnalysis.ts new file mode 100644 index 0000000..eb1f024 --- /dev/null +++ b/frontend/src/threeDFrameAnalysis.ts @@ -0,0 +1,191 @@ +type FrameBounds = { + minX: number + minY: number + maxX: number + maxY: number + width: number + height: number + widthRatio: number + heightRatio: number +} + +export type CurrentThreeDFrameAnalysis = { + accepted: boolean + coverage: number + wallPixelCount: number + componentCount: number + bounds: FrameBounds + largestComponent: FrameBounds & { count: number } +} + +const emptyBounds = (): FrameBounds => ({ + minX: 0, + minY: 0, + maxX: -1, + maxY: -1, + width: 0, + height: 0, + widthRatio: 0, + heightRatio: 0, +}) + +function boundsFor( + minX: number, + minY: number, + maxX: number, + maxY: number, + width: number, + height: number, +): FrameBounds { + if (maxX < minX || maxY < minY) return emptyBounds() + const boundsWidth = maxX - minX + 1 + const boundsHeight = maxY - minY + 1 + return { + minX, + minY, + maxX, + maxY, + width: boundsWidth, + height: boundsHeight, + widthRatio: boundsWidth / width, + heightRatio: boundsHeight / height, + } +} + +function isGridOrFloorPixel(red: number, green: number, blue: number): boolean { + // The Grid's section color is #90a4c6 and the floor is #536d9d. Lighting + // can darken both, but they remain distinctly blue/green biased instead of + // the neutral wall shell or red-biased selected violet. + return blue - red >= 28 && green - red >= 5 +} + +function isWallPixel(red: number, green: number, blue: number, alpha: number): boolean { + if (alpha < 220 || isGridOrFloorPixel(red, green, blue)) return false + + // Lit #e8eff9 wall surfaces resolve to neutral greys such as #adb1b6. + const neutralWall = Math.min(red, green, blue) >= 120 && + Math.max(red, green, blue) - Math.min(red, green, blue) <= 32 + + // Lit #a78bfa selected spans resolve near #9165d2. Requiring a useful + // green channel rejects the small #7c3aed opening selector/marker. + const selectedWall = red >= 100 && + green >= 75 && + blue >= 150 && + red - green >= 8 && + blue - green >= 32 + + return neutralWall || selectedWall +} + +function hasStructuralWallFootprint( + totalPixels: number, + coverage: number, + bounds: FrameBounds, + largestComponent: CurrentThreeDFrameAnalysis['largestComponent'], +): boolean { + // A single marker, a thin control stripe, or a few antialiased pixels must + // not admit a frame. Require meaningful surface area, model-sized global + // distribution, and one contiguous canonical wall surface. + const minimumCoverage = Math.max(0.003, 900 / totalPixels) + const minimumComponentCoverage = Math.max(0.0015, 480 / totalPixels) + return coverage >= minimumCoverage && + bounds.widthRatio >= 0.18 && + bounds.heightRatio >= 0.13 && + largestComponent.count / totalPixels >= minimumComponentCoverage && + (largestComponent.widthRatio >= 0.12 || largestComponent.heightRatio >= 0.1) +} + +/** Analyses a completed WebGL pixel buffer for visible canonical wall spans. + * It deliberately classifies the renderer palette instead of accepting any + * bright/blue pixel: background, grid, floor, and compact selectors cannot + * acknowledge a canonical/WASM/renderer generation. */ +export function analyzeCurrentThreeDFrame( + width: number, + height: number, + pixels: Uint8Array, +): CurrentThreeDFrameAnalysis { + const totalPixels = width * height + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 2 || height < 2 || pixels.length < totalPixels * 4) { + return { + accepted: false, + coverage: 0, + wallPixelCount: 0, + componentCount: 0, + bounds: emptyBounds(), + largestComponent: { ...emptyBounds(), count: 0 }, + } + } + + const wallMask = new Uint8Array(totalPixels) + let wallPixelCount = 0 + let minX = width + let minY = height + let maxX = -1 + let maxY = -1 + for (let pixel = 0; pixel < totalPixels; pixel += 1) { + const offset = pixel * 4 + if (!isWallPixel(pixels[offset], pixels[offset + 1], pixels[offset + 2], pixels[offset + 3])) continue + wallMask[pixel] = 1 + wallPixelCount += 1 + const x = pixel % width + const y = Math.floor(pixel / width) + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x) + maxY = Math.max(maxY, y) + } + + const seen = new Uint8Array(totalPixels) + const queue = new Uint32Array(totalPixels) + let componentCount = 0 + let largestComponent: CurrentThreeDFrameAnalysis['largestComponent'] = { ...emptyBounds(), count: 0 } + for (let start = 0; start < totalPixels; start += 1) { + if (!wallMask[start] || seen[start]) continue + componentCount += 1 + let head = 0 + let tail = 0 + let count = 0 + let componentMinX = width + let componentMinY = height + let componentMaxX = -1 + let componentMaxY = -1 + queue[tail++] = start + seen[start] = 1 + while (head < tail) { + const pixel = queue[head++] + const x = pixel % width + const y = Math.floor(pixel / width) + count += 1 + componentMinX = Math.min(componentMinX, x) + componentMinY = Math.min(componentMinY, y) + componentMaxX = Math.max(componentMaxX, x) + componentMaxY = Math.max(componentMaxY, y) + + const left = pixel - 1 + const right = pixel + 1 + const up = pixel - width + const down = pixel + width + if (x > 0 && wallMask[left] && !seen[left]) { seen[left] = 1; queue[tail++] = left } + if (x + 1 < width && wallMask[right] && !seen[right]) { seen[right] = 1; queue[tail++] = right } + if (y > 0 && wallMask[up] && !seen[up]) { seen[up] = 1; queue[tail++] = up } + if (y + 1 < height && wallMask[down] && !seen[down]) { seen[down] = 1; queue[tail++] = down } + } + if (count > largestComponent.count) { + largestComponent = { + ...boundsFor(componentMinX, componentMinY, componentMaxX, componentMaxY, width, height), + count, + } + } + } + + const coverage = wallPixelCount / totalPixels + const bounds = boundsFor(minX, minY, maxX, maxY, width, height) + return { + accepted: hasStructuralWallFootprint(totalPixels, coverage, bounds, largestComponent), + coverage, + wallPixelCount, + componentCount, + bounds, + largestComponent, + } +} From 01df273cb236a74b9d13968b5e3c974346ead022 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 25 Jul 2026 18:25:21 +0800 Subject: [PATCH 13/39] feat: align HomeVox product flow with Penpot --- CHANGELOG.md | 9 ++ docs/issue-19-design-parity.md | 18 ++-- frontend/e2e/production.spec.ts | 41 +++---- frontend/src/App.css | 166 ++++++++++++++++++++++++----- frontend/src/App.tsx | 17 ++- frontend/src/InspectorPanel.tsx | 6 +- frontend/src/ProductShell.tsx | 12 ++- frontend/src/ProductViews.tsx | 42 ++++++-- frontend/src/ProjectSaveView.tsx | 19 ++-- frontend/src/SourceImportView.tsx | 67 +++++++++--- frontend/src/productDesign.test.ts | 24 +++++ frontend/src/productDesign.ts | 58 ++++++++++ frontend/vite.config.ts | 22 +++- 13 files changed, 398 insertions(+), 103 deletions(-) create mode 100644 frontend/src/productDesign.test.ts create mode 100644 frontend/src/productDesign.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cdd94c..5b9eecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ### Added +- 完成 Issue #19 的 Penpot 产品化闭环:直接通过已配置的 Streamable HTTP Penpot MCP 回读当前 live file/page 与 6 个 1440 × 960 Board,并将 232px 侧栏、72px 顶栏、Inter 字体层级、颜色、圆角、工作区与 unknown 语义固化为前端产品设计合同和 production layout gate。 +- 导入页现在在真实浏览器上传后保持在用户可理解的「导入真实户型图」两栏确认界面;主 CTA 才会调用 `/api/floorplans/parse` 并进入 AI 识别状态,避免选择文件即跳到工程流程页。 +- 校正、生成 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 + +- 新增 `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/docs/issue-19-design-parity.md b/docs/issue-19-design-parity.md index 5bdec02..f457818 100644 --- a/docs/issue-19-design-parity.md +++ b/docs/issue-19-design-parity.md @@ -1,13 +1,17 @@ -# Issue #19 design–implementation parity +# Issue #19 Penpot design–implementation parity -Frozen-design evidence: Issue #19 comment `5068143912`; production evidence uses a fixed 1440 × 960 viewport. The production browser run uses a test-only build for evidence instrumentation; normal production builds contain no URL-addressable fixture, failure switch, or diagnostic bridge. +Authoritative source read directly through the configured Streamable HTTP Penpot MCP on July 25, 2026: file `7622c4ac-2f6b-802b-8008-5f15321d47e8` (**HomeVox · 户型到可编辑 2D/3D**), page `7622c4ac-2f6b-802b-8008-5f15321d47e9` (**户型数字化主流程**). The six live 1440 × 960 boards are the source of truth; the deprecated file `7622c4ac-2f6b-802b-8008-5dd03ffef589` was not read or used. -| State | Layout and visual parity | Product semantics | Exact-head evidence / justified difference | +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 honest `#d98b21` `unknown` states. `productDesign.ts` and the production browser geometry assertion make those values a regression contract. + +| Live Penpot board | Penpot fact → implemented product parity | Real behavior retained | Verification | | --- | --- | --- | --- | -| 导入 / AI 识别 | Indigo left step rail, white top bar, single centered upload/recognition card, violet primary CTA and muted state card. | Step 1 accepts an image; Step 2 retains a recognizable, non-user controlled floorplan preview (wall outline, door swing and window marker), safe file facts, reselect control, loading/error/retry, and does not reveal editor tools. | `issue-19-import-ai.png`, copied from the production browser run to the non-Git `artifact_path` emitted by the runner. Native file input is retained for keyboard and screen-reader support. | -| 校正 2D | Main 2D canvas card with a separate light inspector card; selected wall, endpoints, and selected openings use the shared violet token; unselected doors/windows retain category colors. | Only the API-returned canonical document unlocks this page. The run selects stable `wall-1`, then preserves the parsed geometry through project creation. | `issue-19-2d-correction.png`, fixed at 1440 × 960. No “补画墙体” or object-confirmation claim is shown because no verified canonical contract exists. | -| 3D 确认 | Dedicated dark real R3F/WASM preview with visible generated-geometry status, compact confirmation header, violet confirm CTA and neutral return CTA. | This page is distinct from the linked workspace. “返回 2D 校正” changes no data; “完成并打开 3D” enters the linked workspace. | `issue-19-3d-confirm.png`, fixed at 1440 × 960. Height and unverified building attributes remain explicitly described as illustrative/unknown. | -| 2D / 3D 联动 | Equal dark/light workspace panels plus inspector; responsive layout stacks below 1024px while preserving reading and focus order. | 2D and 3D use the same persisted canonical document and stable `wallId`; the run selects the 3D `window-1` marker and verifies owner `wall-2`, edits and Undo/Redoes in the linked workspace, then captures this state before advancing to Save. | `issue-19-linked-workspace.png`, fixed at 1440 × 960 and captured while Step 5 is current (not the save page). The runner verifies all four PNGs exist and have distinct hashes before emitting their artifact directory. On smaller screens stacking is an intentional accessibility difference. | +| 01 导入真实户型图 | Live board `d862747a-c9b2-80e7-8008-5f1b098c88cd`: 700px source card + 396px setup card at x=268/996; 16px cards; violet primary CTA; factual recognition rows. The product now keeps the selected image on Step 1, presents the two-card 1124px workspace, and uses the live copy including `unknown`. | Native browser image upload remains the entry point; **开始 AI 识别** invokes `/api/floorplans/parse`, rather than presenting a pre-filled result. | Fixed 1440 × 960 production screenshot `issue-19-import-ai.png`; layout has no horizontal overflow. The current live export SHA is `d71fcb3ff504c6d1b42464f581c404e205173ec9a098b2796e248f6c6e004c50`, which intentionally remains different from the supplied older frozen SHA `f536278e556d3d0056c4de745999baf4ee5f7dbb19f80aa1335afec511b92f7b`. | +| 02 AI 识别中 | Live board `d862747a-c9b2-80e7-8008-5f1b09a49b1e`: loading is a separate Step 2 and does not claim that unconfirmed values are facts. The product transitions only after the user starts the real parse and preserves its loading/error/retry feedback. | Parser failure remains actionable and does not unlock 2D. | Production parse-retry test and Step 2 flow guard. | +| 03 校正可编辑 2D | Live board `d862747a-c9b2-80e7-8008-5f1b09a9f669`: 870px workspace, 48px toolbar, 2D-first surface, right inspector, violet Undo/Redo and amber unconfirmed semantics. The product has that product toolbar and an accessible 2D editor/inspector shell without exposing raw IDs. | Canonical walls/openings, stable IDs (internal only), shared endpoint drag, opening edit, Undo/Redo, invalid-geometry fail-closed and 2D export remain real. | `issue-19-2d-correction.png`; production selects a wall/opening and verifies canonical edit/Undo/Redo. | +| 04 生成可编辑 3D | Live board `d862747a-c9b2-80e7-8008-5f1b09ae9891`: 812px preview card + 284px settings card; a 570px 3D surface; `同一空间数据`; unknown height/structure disclosures; violet confirm CTA. The product now mirrors that two-column composition. | It is a real R3F canvas fed by the Rust/WASM geometry revision, not an image; unavailable or invalid geometry stays fail-closed. | `issue-19-3d-confirm.png`; production asserts wall pixels, floor, openings, current renderer frame and canvas footprint. | +| 05 2D / 3D 联动编辑 | Live board `d862747a-c9b2-80e7-8008-5f1b09b35e19`: 1136px shared workspace, 48px toolbar, parallel 2D/3D, object state and explicit `unknown` non-upgrade copy. The product uses the same tri-panel workspace and customer-facing language. | 2D and 3D select the same canonical owner; edits regenerate the actual model; export refuses stale `toBlob` output. | `issue-19-linked-workspace.png`; production asserts selection before/after pixels, canonical revisions, opening ownership, edit/Undo/Redo, and stale-export rejection. | +| 06 保存为 HomeVox 项目 | Live board `d862747a-c9b2-80e7-8008-5f1b09b852b0`: centered 990px × 700px success card, 104px success circle, 28px title, save/continue state and `unknown` retained. The product save view follows that completion hierarchy rather than a diagnostic list. | Create/update/list/reload still use the real persisted document and source image. | Production lifecycle saves, restarts the Go process and reloads the same canonical document and geometry fingerprint. | ## Diagnostic and unknown-content policy diff --git a/frontend/e2e/production.spec.ts b/frontend/e2e/production.spec.ts index e7c6b2e..305a022 100644 --- a/frontend/e2e/production.spec.ts +++ b/frontend/e2e/production.spec.ts @@ -292,7 +292,7 @@ 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: 'AI 识别', level: 3 })).toBeVisible() + 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() @@ -327,10 +327,12 @@ async function dragEndpoint(page: Page, testID: string, deltaX: number, deltaY: test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one production lifecycle', async ({ page }, testInfo) => { await page.goto('/?e2e=instrument') - await expect(page.getByRole('heading', { name: '导入真实户型图' })).toBeVisible() + 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 expect(page.locator('.product-step[data-completed="true"]')).toHaveCount(1) - await expect(page.getByRole('button', { name: /导入户型图,已完成/ })).toBeVisible() + await expect(page.getByRole('button', { name: /导入户型图,当前步骤,已完成/ })).toBeVisible() const captures = [await screenshot(page, testInfo, 'issue-19-import-ai.png')] await parseSelectedFile(page) await expect(page.getByRole('button', { name: /AI 识别,已完成/ })).toBeVisible() @@ -339,9 +341,9 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod await expect(page.getByTestId('selected-wall-id')).toHaveText('wall-1') captures.push(await screenshot(page, testInfo, 'issue-19-2d-correction.png')) - await page.getByRole('button', { name: '继续' }).click() + await page.getByTestId('complete-product-step').click() await expect(page.getByRole('button', { name: /校正 2D,已完成/ })).toBeVisible() - await expect(page.getByRole('heading', { name: '确认 3D 空间' })).toBeVisible() + await expect(page.getByRole('heading', { name: '正在从 2D 结构生成 3D' })).toBeVisible() await expect(page.getByLabel('3D 户型预览')).toBeVisible() await expect(page.locator('canvas')).toBeVisible() await expect(page.getByText('已生成可审阅的同源 3D 几何')).toBeVisible() @@ -407,13 +409,12 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod 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.getByRole('button', { name: '继续' }).click() + await page.getByTestId('complete-product-step').click() await expect(page.getByRole('button', { name: /2D\/3D 联动,已完成/ })).toBeVisible() 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.getByRole('button', { name: '保存项目' }).click() await page.getByLabel('项目名称').fill('Production lifecycle project') const save = page.waitForResponse((response) => response.url().endsWith('/api/projects') && response.request().method() === 'POST') await page.getByRole('button', { name: '创建项目' }).click() @@ -441,7 +442,7 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() await page.getByTestId('opening-handle-window-1').click({ force: true }) await expect(page.getByTestId('opening-width')).toHaveValue('60') - await page.getByRole('button', { name: '继续' }).click() + await page.getByTestId('complete-product-step').click() await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() const reloadedGeometry = await e2eState(page) expect(reloadedGeometry.geometry.finite).toBe(true) @@ -458,7 +459,7 @@ test('keeps invalid and duplicate canonical identity failures closed', async ({ await page.goto('/?e2e=instrument') await uploadAndParse(page) await expect(page.getByRole('alert')).toContainText('opening references a missing or degenerate wall') - await expect(page.getByRole('button', { name: '继续' })).toBeDisabled() + 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() @@ -470,7 +471,7 @@ test('keeps invalid and duplicate canonical identity failures closed', async ({ await page.goto('/?e2e=instrument') await uploadAndParse(page) await expect(page.getByRole('alert')).toContainText('wall id must be unique') - await expect(page.getByRole('button', { name: '继续' })).toBeDisabled() + 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() @@ -492,8 +493,8 @@ test('fails closed after a 2D endpoint is dragged into a zero-length wall', asyn expect(zeroLengthWall).toBeDefined() expect(Math.hypot((zeroLengthWall?.x2 ?? 0) - (zeroLengthWall?.x1 ?? 0), (zeroLengthWall?.y2 ?? 0) - (zeroLengthWall?.y1 ?? 0))).toBeLessThan(1e-3) await expect(page.getByText('wall length must be strictly greater than zero', { exact: true })).toBeVisible() - await expect(page.getByRole('button', { name: '继续' })).toBeDisabled() - await expect(page.getByRole('button', { name: '生成 3D' })).toBeDisabled() + await expect(page.getByTestId('complete-product-step')).toBeDisabled() + await expect(page.getByRole('button', { name: '生成 3D,未解锁' })).toBeDisabled() await expect(page.getByRole('button', { name: '2D/3D 联动' })).toBeDisabled() await expect(page.getByRole('button', { name: '保存项目' })).toBeDisabled() await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() @@ -502,7 +503,7 @@ test('fails closed after a 2D endpoint is dragged into a zero-length wall', asyn 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.getByRole('button', { name: '继续' }).click() + await page.getByTestId('complete-product-step').click() await expect(page.getByRole('alert')).toContainText('当前 3D 预览不可用') await expect(page.getByLabel('3D 户型预览')).toHaveCount(0) await expect(page.getByRole('button', { name: '完成并打开 3D' })).toHaveCount(0) @@ -521,10 +522,10 @@ test('makes parse retry and persistence-unavailable states actionable', async ({ await expect(page.getByRole('button', { name: /AI 识别,已完成/ })).toHaveCount(0) await page.unroute('**/api/floorplans/parse') await parseSelectedFile(page) - await page.getByRole('button', { name: '继续' }).click() + await page.getByTestId('complete-product-step').click() await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() await page.getByRole('button', { name: '完成并打开 3D' }).click() - await page.getByRole('button', { name: '继续' }).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()) @@ -543,11 +544,11 @@ test('keeps the narrow-screen workflow keyboard reachable', async ({ page }) => await expect(correctionStep).toBeFocused() await page.keyboard.press('Enter') await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() - const continueButton = page.getByRole('button', { name: '继续' }) + 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: '确认 3D 空间' })).toBeVisible() + await expect(page.getByRole('heading', { name: '正在从 2D 结构生成 3D' })).toBeVisible() await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() }) @@ -556,7 +557,7 @@ test('does not export stale 3D after current canonical geometry becomes invalid' 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.getByRole('button', { name: '继续' }).click() + 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() @@ -578,7 +579,7 @@ test('does not export stale 3D after current canonical geometry becomes invalid' 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.getByRole('button', { name: '继续' }).click() + 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) @@ -611,7 +612,7 @@ test('disables export for a valid canonical edit until the matching WASM, render 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.getByRole('button', { name: '继续' }).click() + 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) diff --git a/frontend/src/App.css b/frontend/src/App.css index 81b0e2d..2f19ff9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,11 +1,11 @@ .homevox-app { min-height: 100%; - color: #16213d; - background: #f4f7fc; + color: #182033; + background: #f4f6fa; } .product-sidebar { - background: linear-gradient(180deg, #14285d 0%, #1c2461 100%); + background: #182033; color: #fff; } @@ -18,28 +18,28 @@ .product-step { border: 1px solid transparent; - color: #cbd6ff; + color: #aeb7c8; } .product-step[data-active='true'] { - border-color: rgb(255 255 255 / 18%); + border-color: transparent; color: #fff; - background: rgb(255 255 255 / 13%); + background: #2a3454; } .product-step[data-completed='true'] { - color: #f0edff; + color: #d7f4e8; } .product-step[data-completed='true'] .step-dot { - border-color: #7ee7bf; - background: #16775b; + border-color: #1f9d70; + background: #1f9d70; color: #fff; } .product-step[data-active='true'][data-completed='true'] .step-dot { - border-color: #c6bdff; - background: #7767ef; + border-color: #5b5ce2; + background: #5b5ce2; } .product-step[data-locked='true'] { @@ -48,19 +48,31 @@ .step-dot { display: grid; - width: 25px; - height: 25px; + width: 12px; + height: 12px; flex: 0 0 auto; place-items: center; - border: 1px solid rgb(255 255 255 / 30%); + border: 0; border-radius: 999px; - font-size: 11px; - font-weight: 700; + background: #566078; + color: transparent; + font-size: 0; } .product-step[data-active='true'] .step-dot { - border-color: #a99cff; - background: #7767ef; + 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 { @@ -71,7 +83,7 @@ } .product-workspace-linked { - grid-template-columns: minmax(280px, .75fr) minmax(500px, 1.55fr) minmax(260px, .65fr); + grid-template-columns: minmax(280px, .9fr) minmax(420px, 1.24fr) minmax(260px, .66fr); } .workspace-card { @@ -82,12 +94,13 @@ } .canvas-card { - min-height: 480px; + min-height: 620px; + box-shadow: none; } .three-card { - height: min(68vh, 680px); - min-height: 520px; + height: 620px; + min-height: 620px; background: #0c1325; } @@ -96,12 +109,20 @@ } .status-chip { - border: 1px solid #e1e5f3; + border: 0; border-radius: 999px; - background: #f6f4ff; + 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; @@ -109,6 +130,89 @@ color: #845d19; } +.import-workspace .workspace-card { + min-height: 0; +} + +.import-fact { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 46px; + padding: 0 16px; + border-radius: 9px; + background: #eaf8f2; +} + +.import-fact dt { + font-weight: 600; + color: #182033; +} + +.import-fact dd { + font-size: 12px; + font-weight: 500; + text-align: right; +} + +.product-workspace-shell { + min-height: 800px; + padding: 20px; +} + +.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; +} + +.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%; @@ -138,4 +242,18 @@ .product-workspace-linked { grid-template-columns: 1fr; } + + .import-workspace { + grid-template-columns: 1fr; + } + + .three-generation { + grid-template-columns: 1fr; + } + + .workspace-toolbar { + align-items: flex-start; + flex-direction: column; + padding: 12px; + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8399106..42a182f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -624,6 +624,7 @@ export default function App() { const controller = new AbortController() parseRequestRef.current = { id: requestId, controller } + applyProductTransition({ type: 'complete', step: 1, next: 2 }, { hasDocument: false, hasCanonicalGeometry: false, hasThreeDGeometry: false }) setStatus('uploading') setError('') const formData = new FormData() @@ -701,7 +702,6 @@ export default function App() { setImageDimFallback(null) clearCurrentProject() applyProductTransition({ type: 'reload', completed: file ? [1] : [] }, { hasDocument: false, hasCanonicalGeometry: false, hasThreeDGeometry: false }) - if (file) applyProductTransition({ type: 'open', step: 2 }, { hasDocument: false, hasCanonicalGeometry: false, hasThreeDGeometry: false }) setPreviewURL((currentURL) => { if (currentURL) URL.revokeObjectURL(currentURL) @@ -1032,29 +1032,28 @@ export default function App() { const completeAndAdvance = (step: ProductStep, next: ProductStep) => applyProductTransition({ type: 'complete', step, next }) const goNext = () => { - if (activeStep === 1 && selectedFile) completeAndAdvance(1, 2) - else if (activeStep === 3) completeAndAdvance(3, 4) + if (activeStep === 3) completeAndAdvance(3, 4) else if (activeStep === 5) completeAndAdvance(5, 6) } const flow: ProductFlowContext = { completed: completedSteps, ...productFlowContext } - const pendingCompletion = activeStep === 1 || activeStep === 3 || activeStep === 5 + const pendingCompletion = activeStep === 3 || activeStep === 5 ? { type: 'complete' as const, step: activeStep, next: (activeStep + 1) as ProductStep } : null const canAdvance = Boolean(pendingCompletion && canApplyProductFlowEvent( { activeStep, completed: completedSteps }, pendingCompletion, productFlowContext, - ) && (activeStep !== 1 || selectedFile)) - const primaryAction = pendingCompletion && + )) + const primaryAction = undefined return ( applyProductTransition({ type: 'open', step })} primaryAction={primaryAction}> - {activeStep === 1 && } + {activeStep === 1 && } {activeStep === 2 && } - {activeStep === 3 && } + {activeStep === 3 && } {activeStep === 4 && applyProductTransition({ type: 'open', step: 3 })} onComplete={() => completeAndAdvance(4, 5)} />} - {activeStep === 5 && applyProductTransition({ type: 'open', step: 3 })} />} + {activeStep === 5 && applyProductTransition({ type: 'open', step: 3 })} />} {activeStep === 6 && { void saveProject() }} onRefresh={() => { void refreshProjects() }} onLoad={(id) => { void loadProject(id) }} />} ) diff --git a/frontend/src/InspectorPanel.tsx b/frontend/src/InspectorPanel.tsx index 72326ee..92179a0 100644 --- a/frontend/src/InspectorPanel.tsx +++ b/frontend/src/InspectorPanel.tsx @@ -43,9 +43,9 @@ export function InspectorPanel({ return ( -

{copy.title}

{copy.subtitle}

{hasDocument && 同一份空间数据}自动保存{primaryAction}
{children}
+ })}

同一个 HomeVox 项目

2D 与 3D 会一起更新

+

{copy.title}

{copy.subtitle}

{hasDocument && 已同步}自动保存{primaryAction}
{children}
} diff --git a/frontend/src/ProductViews.tsx b/frontend/src/ProductViews.tsx index b4562f7..1f05f3f 100644 --- a/frontend/src/ProductViews.tsx +++ b/frontend/src/ProductViews.tsx @@ -16,7 +16,7 @@ function WorkspaceToolbar({ inspector, linked = false, onAdvance, canAdvance }: return
{linked ? '并排视图' : '2D 编辑'} - {linked ? 几何已同步 · 语义待确认 : 3D 预览} + {linked ? 平面图与空间预览已同步 : 调整完成后可查看 3D}
@@ -29,7 +29,7 @@ function WorkspaceToolbar({ inspector, linked = false, onAdvance, canAdvance }: } export function TwoDWorkspace({ editor, inspector, canAdvance, onAdvance }: TwoDWorkspaceProps) { - return
+ return
} type ThreeDUnavailableProps = { @@ -42,11 +42,11 @@ function ThreeDUnavailablePanel({ geometryValidationError, wasmState, onBack }: return (
{geometryValidationError ? ( - <>

当前开口数据无法生成 3D

请返回 2D 校正后修复数据,再重新生成 3D。

+ <>

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

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

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

正在准备 3D 预览

3D 几何准备完成后,才能打开联动工作台。

+ <>

正在准备空间预览

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

) : ( - <>

当前 3D 预览不可用

无法生成可靠的 3D 几何。请返回 2D 校正后重试,或在支持 WebGL 的浏览器中打开。

+ <>

当前无法显示空间预览

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

)}
@@ -63,16 +63,17 @@ export type ThreeDConfirmationProps = { } export function ThreeDConfirmation({ preview, previewAvailable, canOpenLinkedWorkspace, wasmState, onBack, onComplete }: ThreeDConfirmationProps) { + const ready = previewAvailable && canOpenLinkedWorkspace return (
-

正在从 2D 结构生成 3D

同一空间数据
+

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

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

空间轮廓、房间邻接和开口来自当前 2D。层高 / 墙高仍为 unknown,现阶段只用于空间编辑预览。

+

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

@@ -90,5 +91,5 @@ export function LinkedWorkspace({ editor, preview, inspector, previewAvailable, if (!previewAvailable) { return

当前 3D 预览不可用

联动工作台已关闭,请先返回 2D 校正。

} - return
+ return
} diff --git a/frontend/src/SourceImportView.tsx b/frontend/src/SourceImportView.tsx index d5634aa..c7099d1 100644 --- a/frontend/src/SourceImportView.tsx +++ b/frontend/src/SourceImportView.tsx @@ -6,22 +6,17 @@ type SourceFileProps = { onFileChange: (file: File | null) => void } -function SourcePreview({ selectedFile, previewURL, onFileChange }: SourceFileProps) { +function SourcePreview({ selectedFile, previewURL }: SourceFileProps) { if (!selectedFile || !previewURL) return null return ( -
+
-
-

已选择的户型图

-

{selectedFile.name}

-
- +

已选择的户型图

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

图纸包含外轮廓、房间名称、门窗与尺寸链;识别后都可以逐项校正。

+ 上传户型图预览 +

{selectedFile.name}

+

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

) } @@ -36,11 +31,11 @@ function ImportFacts() { return

本次识别内容

-
外轮廓与房间
将在图纸中查找
-
门洞与开口
待你校正
-
图纸尺寸链
保留原值,不猜语义
-
朝向
unknown
-
层高 / 墙高
unknown
+
房间与边界
将在图纸中查找
+
门窗位置
待你校正
+
图中尺寸
保留图中标注
+
朝向
尚未识别
+
层高 / 墙高
尚未测量
} @@ -64,9 +59,9 @@ export function SourceImportView({ selectedFile, previewURL, onFileChange, statu
} -export function SourceImportView({ selectedFile, previewURL, onFileChange, status, error, onParse }: SourceImportViewProps) { +export function SourceImportView({ selectedFile, previewURL, onFileChange, status, error }: SourceImportViewProps) { if (!selectedFile || !previewURL) { return (
@@ -63,7 +65,7 @@ export function SourceImportView({ selectedFile, previewURL, onFileChange, statu

只围绕你的户型建立项目,后续修改会同时反映在平面图与空间预览中。

{error &&

{error}

} - +

{status === 'uploading' ? '正在判断…' : status === 'ready' ? '判断完成' : '图片尺寸就绪后自动判断'}

@@ -73,18 +75,69 @@ export function SourceImportView({ selectedFile, previewURL, onFileChange, statu export type AIParseViewProps = SourceFileProps & { status: ParseViewStatus error: string - onParse: () => void } -export function AIParseView({ selectedFile, previewURL, onFileChange, status, error, onParse }: AIParseViewProps) { +export function AIParseView({ selectedFile, previewURL, onFileChange, status, error }: AIParseViewProps) { return (
-

AI 识别

-

识别前请核对这张原图;完成后可在平面图上继续调整。

+

正在判断

+

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

- -
识别状态:{status === 'ready' ? '解析完成' : status === 'uploading' ? '解析中' : status === 'error' ? '失败' : '等待开始'}{error &&

{error}

}
- {status === 'error' && selectedFile && } +
判断状态:{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 (!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
+
+

确认户型区域

拖动裁切框或拖动四角和边缘,按方向键可微调。

原始像素
+ { const next = cropKeyboardNudge(crop, event.key, imageSize, event.shiftKey ? 10 : 1); if (next) { event.preventDefault(); onCropChange(next) } }}> + + + + {(['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/cropFlow.test.ts b/frontend/src/cropFlow.test.ts new file mode 100644 index 0000000..099406e --- /dev/null +++ b/frontend/src/cropFlow.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { clampCrop, cropDisplayMetrics, cropKeyboardNudge, cropPointerToImage, drawCropToCanvas, fullImageCrop, isCandidateDetection, moveCrop, resizeCrop, selectCandidate } from './cropFlow' + +const image = { width: 200, height: 100 } +describe('crop flow', () => { + it('validates analysis schema and fails closed on fake rectangles', () => { + expect(isCandidateDetection({ mode: 'single', candidates: [{ x: 1, y: 2, width: 10, height: 10 }] }, image)).toBe(true) + expect(isCandidateDetection({ mode: 'single', candidates: [{ x: 1, y: 2, width: 10, height: 10 }], extra: true }, image)).toBe(false) + expect(isCandidateDetection({ mode: 'single', candidates: [{ x: 1.5, y: 2, width: 10, height: 10 }] }, image)).toBe(false) + expect(isCandidateDetection({ mode: 'single', candidates: [{ x: 195, y: 2, width: 10, height: 10 }] }, image)).toBe(false) + expect(isCandidateDetection({ mode: 'single', candidates: [{ x: 1, y: 2, width: 7, height: 9 }] }, image)).toBe(false) + expect(isCandidateDetection({ mode: 'composite', candidates: [{ x: 0, y: 0, width: 10, height: 10 }, { x: 9, y: 0, width: 10, height: 10 }] }, image)).toBe(false) + expect(isCandidateDetection({ mode: 'composite', candidates: [{ x: 0, y: 0, width: 10, height: 10 }, { x: 10, y: 0, width: 10, height: 10 }] }, image)).toBe(true) + expect(isCandidateDetection({ mode: 'single', candidates: [] })).toBe(false) + expect(isCandidateDetection({ mode: 'single', candidates: [{ x: 1, y: 2, width: 10, height: 10 }, { x: 2, y: 3, width: 10, height: 10 }] })).toBe(false) + expect(isCandidateDetection({ mode: 'composite', candidates: [{ x: 1, y: 2, width: 10, height: 10 }] })).toBe(false) + expect(isCandidateDetection({ mode: 'uncertain', candidates: [{ x: 1, y: 2, width: 10, height: 10 }] })).toBe(false) + expect(isCandidateDetection({ mode: 'single', candidates: [{ x: -1, y: 0, width: 1, height: 1 }] })).toBe(false) + expect(isCandidateDetection({ mode: 'wrong', candidates: [] })).toBe(false) + }) + it('clamps boundaries and minimum dimensions during move and all resize directions', () => { + expect(clampCrop({ x: -4, y: 90, width: 5, height: 2 }, image)).toEqual({ x: 0, y: 68, width: 32, height: 32 }) + expect(moveCrop({ x: 160, y: 60, width: 32, height: 32 }, 40, 40, image)).toEqual({ x: 168, y: 68, width: 32, height: 32 }) + for (const handle of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const) { + const result = resizeCrop({ x: 50, y: 30, width: 80, height: 50 }, handle, -100, -100, image) + expect(result.x).toBeGreaterThanOrEqual(0); expect(result.y).toBeGreaterThanOrEqual(0) + expect(result.x + result.width).toBeLessThanOrEqual(200); expect(result.y + result.height).toBeLessThanOrEqual(100) + expect(result.width).toBeGreaterThanOrEqual(32); expect(result.height).toBeGreaterThanOrEqual(32) + } + }) + it('selects, restores full image and accepts keyboard nudges', () => { + expect(selectCandidate([{ x: 20, y: 10, width: 50, height: 50 }], 0, image)).toEqual({ x: 20, y: 10, width: 50, height: 50 }) + expect(selectCandidate([], 0, image)).toBeNull() + expect(fullImageCrop(image)).toEqual({ x: 0, y: 0, width: 200, height: 100 }) + expect(cropKeyboardNudge({ x: 0, y: 0, width: 32, height: 32 }, 'ArrowLeft', image)).toEqual({ x: 0, y: 0, width: 32, height: 32 }) + expect(selectCandidate([{ x: 1, y: 2, width: 10, height: 10 }], 0, image)).toEqual({ x: 1, y: 2, width: 10, height: 10 }) + }) + it('draws original source pixels to a matching output canvas', () => { + const calls: unknown[][] = []; const canvas = { width: 0, height: 0, getContext: () => ({ drawImage: (...args: unknown[]) => calls.push(args) }) } + const source = {} as CanvasImageSource + drawCropToCanvas(canvas, source, { x: 10, y: 20, width: 50, height: 40 }) + expect([canvas.width, canvas.height]).toEqual([50, 40]); expect(calls[0]).toEqual([source, 10, 20, 50, 40, 0, 0, 50, 40]) + }) + it('keeps crop handles and hit targets visible on high-resolution source images', () => { + expect(cropDisplayMetrics({ width: 600, height: 440 })).toEqual({ handleSize: 14, hitDistance: 18, strokeWidth: 3 }) + expect(cropDisplayMetrics({ width: 8001, height: 4501 })).toEqual({ handleSize: 176, hitDistance: 224, strokeWidth: 38 }) + }) + it('maps pointer coordinates through xMidYMid meet letterboxing into original pixels', () => { + const box = { left: 100, top: 50, width: 640, height: 640 } + const source = { width: 8001, height: 4501 } + expect(cropPointerToImage({ clientX: 100, clientY: 190 }, box, source)).toEqual({ x: 0, y: 0 }) + expect(cropPointerToImage({ clientX: 740, clientY: 550 }, box, source)).toEqual({ x: 8001, y: 4501 }) + }) +}) diff --git a/frontend/src/cropFlow.ts b/frontend/src/cropFlow.ts new file mode 100644 index 0000000..9eb90d4 --- /dev/null +++ b/frontend/src/cropFlow.ts @@ -0,0 +1,142 @@ +export type CropRect = { x: number; y: number; width: number; height: number } +export type CropHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' +export type CandidateMode = 'single' | 'composite' | 'uncertain' +export type CandidateDetection = { mode: CandidateMode; candidates: CropRect[] } + +export const MIN_CROP_SIZE = 32 + +export function cropDisplayMetrics(image: { width: number; height: number }): { handleSize: number; hitDistance: number; strokeWidth: number } { + const longestSide = Math.max(1, image.width, image.height) + return { + handleSize: Math.max(14, Math.round(longestSide * 0.022)), + hitDistance: Math.max(18, Math.round(longestSide * 0.028)), + strokeWidth: Math.max(3, Math.round(longestSide * 0.0047)), + } +} + +export function cropPointerToImage( + pointer: { clientX: number; clientY: number }, + box: { left: number; top: number; width: number; height: number }, + image: { width: number; height: number }, +): { x: number; y: number } { + const scale = Math.min(box.width / image.width, box.height / image.height) + if (!Number.isFinite(scale) || scale <= 0) return { x: 0, y: 0 } + const renderedWidth = image.width * scale + const renderedHeight = image.height * scale + const left = box.left + (box.width - renderedWidth) / 2 + const top = box.top + (box.height - renderedHeight) / 2 + return { + x: Math.min(image.width, Math.max(0, Math.round((pointer.clientX - left) / scale))), + y: Math.min(image.height, Math.max(0, Math.round((pointer.clientY - top) / scale))), + } +} + +const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) + +const exactKeys = (value: object, expected: readonly string[]) => { + const keys = Object.keys(value).sort() + return keys.length === expected.length && keys.every((key, index) => key === [...expected].sort()[index]) +} + +const overlaps = (a: CropRect, b: CropRect) => + Math.min(a.x + a.width, b.x + b.width) > Math.max(a.x, b.x) && + Math.min(a.y + a.height, b.y + b.height) > Math.max(a.y, b.y) + +export function isCandidateDetection(value: unknown, image?: { width: number; height: number }): value is CandidateDetection { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + if (!exactKeys(value, ['mode', 'candidates'])) return false + const candidate = value as { mode?: unknown; candidates?: unknown } + if (!(candidate.mode === 'single' || candidate.mode === 'composite' || candidate.mode === 'uncertain') || !Array.isArray(candidate.candidates)) return false + const validCount = candidate.mode === 'single' + ? candidate.candidates.length === 1 + : candidate.mode === 'composite' + ? candidate.candidates.length >= 2 + : candidate.candidates.length === 0 + if (!validCount) return false + const rectangles = candidate.candidates as unknown[] + if (!rectangles.every((rect) => { + if (!rect || typeof rect !== 'object' || Array.isArray(rect)) return false + if (!exactKeys(rect, ['x', 'y', 'width', 'height'])) return false + const r = rect as CropRect + return finite(r.x) && finite(r.y) && finite(r.width) && finite(r.height) && + Number.isInteger(r.x) && Number.isInteger(r.y) && Number.isInteger(r.width) && Number.isInteger(r.height) && + r.x >= 0 && r.y >= 0 && r.width > 0 && r.height > 0 && r.width * r.height >= 64 && + (!image || (r.x + r.width <= image.width && r.y + r.height <= image.height)) + })) return false + const typed = rectangles as CropRect[] + return typed.every((rect, index) => typed.slice(0, index).every((prior) => !overlaps(rect, prior))) +} + +export function fullImageCrop(image: { width: number; height: number }): CropRect { + return { x: 0, y: 0, width: Math.max(0, Math.round(image.width)), height: Math.max(0, Math.round(image.height)) } +} + +export function clampCrop(rect: CropRect, image: { width: number; height: number }, minimum = MIN_CROP_SIZE): CropRect { + const maxWidth = Math.max(1, Math.round(image.width)); const maxHeight = Math.max(1, Math.round(image.height)) + const minWidth = Math.min(Math.max(1, minimum), maxWidth); const minHeight = Math.min(Math.max(1, minimum), maxHeight) + const width = Math.min(maxWidth, Math.max(minWidth, Math.round(rect.width))) + const height = Math.min(maxHeight, Math.max(minHeight, Math.round(rect.height))) + return { + x: Math.min(maxWidth - width, Math.max(0, Math.round(rect.x))), + y: Math.min(maxHeight - height, Math.max(0, Math.round(rect.y))), + width, + height, + } +} + +export function moveCrop(rect: CropRect, dx: number, dy: number, image: { width: number; height: number }): CropRect { + return clampCrop({ ...rect, x: rect.x + dx, y: rect.y + dy }, image, 1) +} + +export function resizeCrop(rect: CropRect, handle: CropHandle, dx: number, dy: number, image: { width: number; height: number }, minimum = MIN_CROP_SIZE): CropRect { + let left = rect.x; let right = rect.x + rect.width; let top = rect.y; let bottom = rect.y + rect.height + if (handle.includes('w')) left += dx + if (handle.includes('e')) right += dx + if (handle.includes('n')) top += dy + if (handle.includes('s')) bottom += dy + const minWidth = Math.min(minimum, image.width); const minHeight = Math.min(minimum, image.height) + if (right - left < minWidth) { if (handle.includes('w')) left = right - minWidth; else right = left + minWidth } + if (bottom - top < minHeight) { if (handle.includes('n')) top = bottom - minHeight; else bottom = top + minHeight } + if (left < 0) { if (handle.includes('w')) left = 0; else right = Math.min(image.width, right - left); } + if (top < 0) { if (handle.includes('n')) top = 0; else bottom = Math.min(image.height, bottom - top); } + if (right > image.width) { if (handle.includes('e')) right = image.width; else left = Math.max(0, left - (right - image.width)); } + if (bottom > image.height) { if (handle.includes('s')) bottom = image.height; else top = Math.max(0, top - (bottom - image.height)); } + return clampCrop({ x: left, y: top, width: right - left, height: bottom - top }, image, minimum) +} + +export function selectCandidate(candidates: readonly CropRect[], index: number, image: { width: number; height: number }): CropRect | null { + const candidate = candidates[index] + return candidate ? clampCrop(candidate, image, 1) : null +} + +export function cropKeyboardNudge(rect: CropRect, key: string, image: { width: number; height: number }, step = 1): CropRect | null { + const amount = Math.max(1, Math.round(step)) + if (key === 'ArrowLeft') return moveCrop(rect, -amount, 0, image) + if (key === 'ArrowRight') return moveCrop(rect, amount, 0, image) + if (key === 'ArrowUp') return moveCrop(rect, 0, -amount, image) + if (key === 'ArrowDown') return moveCrop(rect, 0, amount, image) + return null +} + +export type CropCanvas = { width: number; height: number; getContext: (kind: '2d') => Pick | null } +export function drawCropToCanvas(canvas: CropCanvas, image: CanvasImageSource, crop: CropRect): CropCanvas { + canvas.width = crop.width; canvas.height = crop.height + const context = canvas.getContext('2d') + if (!context) throw new Error('浏览器无法创建裁切画布。') + context.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height) + return canvas +} + +export async function createCroppedFile(source: File, crop: CropRect): Promise { + const url = URL.createObjectURL(source) + try { + const image = await new Promise((resolve, reject) => { + const element = new Image(); element.onload = () => resolve(element); element.onerror = () => reject(new Error('无法读取户型图。')); element.src = url + }) + const canvas = document.createElement('canvas') + drawCropToCanvas(canvas, image, clampCrop(crop, { width: image.naturalWidth, height: image.naturalHeight }, 1)) + const blob = await new Promise((resolve, reject) => canvas.toBlob((result) => result ? resolve(result) : reject(new Error('无法生成裁切图。')), source.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.95)) + const extension = blob.type === 'image/png' ? 'png' : 'jpg' + return new File([blob], `${source.name.replace(/\.[^.]+$/, '')}-crop.${extension}`, { type: blob.type }) + } finally { URL.revokeObjectURL(url) } +} diff --git a/frontend/src/latestOperation.test.ts b/frontend/src/latestOperation.test.ts new file mode 100644 index 0000000..1ca4df0 --- /dev/null +++ b/frontend/src/latestOperation.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { LatestOperation } from './latestOperation' + +describe('LatestOperation', () => { + it('invalidates an older operation before its asynchronous work completes', () => { + const operations = new LatestOperation() + const older = operations.begin() + expect(older.isCurrent()).toBe(true) + const newer = operations.begin() + expect(older.isCurrent()).toBe(false) + expect(newer.isCurrent()).toBe(true) + }) + + it('invalidates the current operation when the source changes', () => { + const operations = new LatestOperation() + const active = operations.begin() + operations.invalidate() + expect(active.isCurrent()).toBe(false) + }) + + it('invalidates work during an effect cleanup without disabling the remounted instance', () => { + const operations = new LatestOperation() + const active = operations.begin() + operations.invalidate() + expect(active.isCurrent()).toBe(false) + expect(operations.begin().isCurrent()).toBe(true) + }) +}) \ No newline at end of file diff --git a/frontend/src/latestOperation.ts b/frontend/src/latestOperation.ts new file mode 100644 index 0000000..54a4e9a --- /dev/null +++ b/frontend/src/latestOperation.ts @@ -0,0 +1,14 @@ +export type OperationToken = { isCurrent: () => boolean } + +export class LatestOperation { + private generation = 0 + + begin(): OperationToken { + const generation = ++this.generation + return { isCurrent: () => generation === this.generation } + } + + invalidate(): void { + this.generation += 1 + } +} \ No newline at end of file diff --git a/frontend/src/projectSession.test.ts b/frontend/src/projectSession.test.ts index 25c8785..e07d8b6 100644 --- a/frontend/src/projectSession.test.ts +++ b/frontend/src/projectSession.test.ts @@ -5,7 +5,7 @@ import type { ProjectDetail, ProjectSummary } from './projects' const document = { filename: 'plan.png', contentType: 'image/png', - size: 12, + 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 } }, } @@ -38,7 +38,8 @@ describe('project session save admission', () => { it('requires a source image only for a newly created project, never for updating a loaded project', () => { expect(projectSaveIssue({ document, geometryValidationError: null, projectName: ' ', currentProject: project, sourceFile: null })).toContain('项目名称') - expect(projectSaveIssue({ document, geometryValidationError: null, projectName: 'Home', currentProject: null, sourceFile: null })).toContain('原始户型图') + expect(projectSaveIssue({ document, geometryValidationError: null, projectName: 'Home', currentProject: null, sourceFile: null })).toContain('有效裁切') + expect(projectSaveIssue({ document, geometryValidationError: null, projectName: 'Home', currentProject: null, sourceFile: new File(['original'], 'original.png', { type: 'image/png' }) })).toContain('有效裁切图一致') expect(projectSaveIssue({ document, geometryValidationError: null, projectName: 'Home', currentProject: project, sourceFile: null })).toBeNull() }) }) @@ -169,6 +170,8 @@ describe('ProjectSession controller', () => { const create = sessionHarness({ dependencies: { createProject: vi.fn().mockReturnValue(pendingCreate.promise) } }) const createRequest = create.controller.saveProject() expect(create.dependencies.createProject).toHaveBeenCalledWith('Home', document, expect.any(File), expect.any(AbortSignal)) + const uploaded = (create.dependencies.createProject as ReturnType).mock.calls[0][2] as File + expect([uploaded.name, uploaded.type, uploaded.size]).toEqual([document.filename, document.contentType, document.size]) expect(create.dependencies.updateProject).not.toHaveBeenCalled() expect(create.onProjectSaved).not.toHaveBeenCalled() pendingCreate.resolve(project) diff --git a/frontend/src/projectSession.ts b/frontend/src/projectSession.ts index 5f41a1f..0c3e6f2 100644 --- a/frontend/src/projectSession.ts +++ b/frontend/src/projectSession.ts @@ -26,7 +26,12 @@ export function projectSaveIssue({ if (!document) return '请先完成户型解析后再创建项目' if (geometryValidationError) return '当前户型几何无效,请返回 2D 校正后再保存' if (!projectName.trim()) return '请输入项目名称' - if (!currentProject && !sourceFile) return '创建项目需要原始户型图' + if (!currentProject && !sourceFile) return '创建项目需要有效裁切户型图' + if (!currentProject && sourceFile && ( + sourceFile.name !== document.filename || + sourceFile.type !== document.contentType || + sourceFile.size !== document.size + )) return '待保存图片必须与解析后的有效裁切图一致' return null } From 30ae3d53af5b0b2a5ca33be2f3680945d867be7c Mon Sep 17 00:00:00 2001 From: Lux Date: Sun, 26 Jul 2026 10:17:35 +0800 Subject: [PATCH 18/39] fix: clarify floorplan judging feedback --- CHANGELOG.md | 1 + frontend/e2e/production.spec.ts | 57 ++++++++++++++++++++++++++++++ frontend/src/App.css | 7 ++++ frontend/src/App.tsx | 16 ++++++--- frontend/src/SourceImportView.tsx | 20 ++++++----- frontend/src/parseFeedback.test.ts | 10 +++++- frontend/src/parseFeedback.ts | 9 +++++ 7 files changed, 107 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ce878..294149b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- 裁切确认后的真实 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,更新时会回读不可变对象验证。 diff --git a/frontend/e2e/production.spec.ts b/frontend/e2e/production.spec.ts index 308db99..3c1f365 100644 --- a/frontend/e2e/production.spec.ts +++ b/frontend/e2e/production.spec.ts @@ -530,6 +530,63 @@ test('keeps a selected composite candidate and adjusted crop after parse failure } }) +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('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.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') diff --git a/frontend/src/App.css b/frontend/src/App.css index 0c2140c..9d5078f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -353,5 +353,12 @@ } } .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 a831be4..c18789e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -57,7 +57,7 @@ import { useThreeDGenerationController } from './useThreeDGenerationController' import { canExportCurrentThreeD } from './threeDExport' import { exportCurrentThreeDRevision, type ThreeDExportRevision } from './threeDExportSession' import { canonicalRevisionToken } from './floorplanSession' -import { parseFailureMessage, parseNetworkFailureMessage } from './parseFeedback' +import { parseFailureMessage, parseNetworkFailureMessage, parseRetryFailureMessage } from './parseFeedback' import { e2EProjectID, e2EWasmLoader, isE2EInstrumentationEnabled, publishE2EState } from '@homevox-e2e' import './App.css' @@ -650,7 +650,12 @@ export default function App() { } }, [wallEditor]) - async function handleParse(fileToParse: File | null, parsedPreviewURL: string, operation?: OperationToken): Promise { + async function handleParse( + fileToParse: File | null, + parsedPreviewURL: string, + operation?: OperationToken, + source: 'automatic' | 'confirmed-crop' = 'automatic', + ): Promise { if (!fileToParse) { setError('请先选择 PNG / JPG / WebP 户型图') setStatus('error') @@ -688,7 +693,9 @@ export default function App() { // Reverse proxies and upstream failures can return non-JSON error pages. } if (!response.ok) { - throw new Error(parseFailureMessage(response.status, body)) + throw new Error(source === 'confirmed-crop' + ? parseRetryFailureMessage(response.status, body) + : parseFailureMessage(response.status, body)) } if (!isParseResponse(body)) { throw new Error('识别结果暂时无法使用,请重新选择图纸后再试。') @@ -769,6 +776,7 @@ export default function App() { const operation = cropOperationRef.current.begin() parseRequestRef.current?.controller.abort() setStatus('uploading') + setError('') // A changed crop defines a new coordinate system. Invalidate the previous // canonical/effective pair before starting recognition so a failed retry // can never leave new crop controls attached to stale 2D/3D geometry. @@ -798,7 +806,7 @@ export default function App() { URL.revokeObjectURL(croppedURL) return } - if (!await handleParse(cropped, croppedURL, operation)) URL.revokeObjectURL(croppedURL) + if (!await handleParse(cropped, croppedURL, operation, 'confirmed-crop')) URL.revokeObjectURL(croppedURL) } catch (err) { if (!operation.isCurrent() || originalSourceRef.current !== source) return setStatus('error'); setError(err instanceof Error ? err.message : '无法生成裁切图。') diff --git a/frontend/src/SourceImportView.tsx b/frontend/src/SourceImportView.tsx index 98c71c3..a868c38 100644 --- a/frontend/src/SourceImportView.tsx +++ b/frontend/src/SourceImportView.tsx @@ -112,7 +112,7 @@ export function CropConfirmView({ selectedFile, previewURL, imageSize, detection return positions.find(([, px, py]) => Math.abs(px - x) <= distance && Math.abs(py - y) <= distance)?.[0] ?? 'move' } const pointer = (event: PointerEvent) => { - if (!crop || !imageSize) return + 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 @@ -127,17 +127,21 @@ export function CropConfirmView({ selectedFile, previewURL, imageSize, detection if (!selectedFile || !previewURL || !imageSize || !crop) return null return
-

确认户型区域

拖动裁切框或拖动四角和边缘,按方向键可微调。

原始像素
- { const next = cropKeyboardNudge(crop, event.key, imageSize, event.shiftKey ? 10 : 1); if (next) { event.preventDefault(); onCropChange(next) } }}> +

确认户型区域

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

原始像素
+ { if (status === 'uploading') return; const next = cropKeyboardNudge(crop, event.key, imageSize, event.shiftKey ? 10 : 1); if (next) { event.preventDefault(); onCropChange(next) } }}> - - {(['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 })} + + {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 })}
+ {status !== 'uploading' && (detection?.candidates.length ?

推荐区域

{detection.candidates.map((candidate, index) => )}
:

无法可靠判断户型区域,请手动框选;默认已保留整张原图。

)} +
+ {status === 'uploading' &&
+
+

正在查找完整墙体、房间与门窗,请不要关闭页面。

+
} + {status !== 'uploading' && error &&

{error}

}
} diff --git a/frontend/src/parseFeedback.test.ts b/frontend/src/parseFeedback.test.ts index c08aa9e..acf1919 100644 --- a/frontend/src/parseFeedback.test.ts +++ b/frontend/src/parseFeedback.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseFailureMessage } from './parseFeedback' +import { parseFailureMessage, parseRetryFailureMessage } from './parseFeedback' describe('parse feedback contract', () => { it('separates unavailable transport from invalid AI output and unreliable image content', () => { @@ -8,4 +8,12 @@ describe('parse feedback contract', () => { expect(parseFailureMessage(422, { code: 'ai_content_unreliable' })).toContain('裁切') expect(parseFailureMessage(502, {})).not.toContain('检查网络') }) + + it('explains that a confirmed crop can still lack visible wall topology', () => { + const message = parseRetryFailureMessage(422, { code: 'ai_content_unreliable' }) + + expect(message).toContain('当前裁切区域') + expect(message).toContain('完整、无遮挡') + expect(message).not.toContain('裁切到单个户型') + }) }) diff --git a/frontend/src/parseFeedback.ts b/frontend/src/parseFeedback.ts index 69b02f2..c044c72 100644 --- a/frontend/src/parseFeedback.ts +++ b/frontend/src/parseFeedback.ts @@ -23,6 +23,15 @@ export function parseFailureMessage(status: number, body: unknown): string { } } +/** Feedback after the user has already confirmed a crop. Avoid telling them to + * repeat the same action; explain the actual reliability boundary instead. */ +export function parseRetryFailureMessage(status: number, body: unknown): string { + if (errorCode(body) === 'ai_content_unreliable') { + return '当前裁切区域仍缺少完整、无遮挡的墙体边界,无法可靠生成可编辑户型。请继续调整裁切区域,或上传原始、清晰且无遮挡的平面图。' + } + return parseFailureMessage(status, body) +} + export function parseNetworkFailureMessage(): string { return '网络连接暂时不可用,请检查连接后重试。' } From e49adc95b85ffdce09b1ff631bc16073e865eb54 Mon Sep 17 00:00:00 2001 From: Lux Date: Sun, 26 Jul 2026 11:03:33 +0800 Subject: [PATCH 19/39] fix: bind vision output to source pixels --- CHANGELOG.md | 1 + backend/cmd/fake-vision/main.go | 5 ++- backend/internal/api/router.go | 10 +---- backend/internal/api/router_test.go | 37 ++++++++++++++++-- backend/internal/floorplan/parser.go | 47 ++++++++++++++++++++++- backend/internal/floorplan/parser_test.go | 26 +++++++++++++ 6 files changed, 110 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 294149b..ec6cb5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- 修复真实 Vision Provider 默认按内部预处理尺寸回报 `metadata.image_width/image_height`,导致有效裁切被错误拒绝的问题:解析请求现在显式绑定后端已解码的原图精确尺寸与坐标网格;Provider 必须确认使用同一网格,durable metadata 才采用上传字节的真实宽高,尺寸不一致或任何房间/墙体坐标越界仍失败关闭。真实懒猫浏览器以 `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。 diff --git a/backend/cmd/fake-vision/main.go b/backend/cmd/fake-vision/main.go index 7571d16..f377cc0 100644 --- a/backend/cmd/fake-vision/main.go +++ b/backend/cmd/fake-vision/main.go @@ -21,7 +21,7 @@ const ( 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. Do not include markdown fences. Omit an opening if its wall-local position or width cannot be established." + 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}]}"}}]}` @@ -151,7 +151,8 @@ func validContract(request chatRequest) (string, bool) { Type string `json:"type"` Text string `json:"text"` } - if json.Unmarshal(request.Messages[1].Content, &user) != nil || user[0].Text != visionUser || !strings.HasPrefix(imageURL, "data:image/png;base64,") { + 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 diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index e4a8f03..bdef060 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -109,7 +109,7 @@ func newRouterWithCleanup(cfg config.Config, startupTimeout time.Duration, initi } 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 { if cfg.AIAPIKey == "" || cfg.AIBaseURL == "" || cfg.AIModel == "" { c.JSON(http.StatusServiceUnavailable, gin.H{ @@ -137,14 +137,6 @@ func newRouterWithCleanup(cfg config.Config, startupTimeout time.Duration, initi } return } - if result.Metadata.ImageWidth != width || result.Metadata.ImageHeight != height { - c.JSON(http.StatusUnprocessableEntity, gin.H{ - "code": "ai_content_unreliable", - "error": "识别结果与当前裁切图不一致,未生成可编辑户型。请重新确认裁切区域后再试。", - }) - return - } - document := floorplan.ParseResponse{ Filename: header.Filename, ContentType: contentType, diff --git a/backend/internal/api/router_test.go b/backend/internal/api/router_test.go index 9ec2618..7c0b833 100644 --- a/backend/internal/api/router_test.go +++ b/backend/internal/api/router_test.go @@ -168,9 +168,24 @@ func TestParseFloorplanReportsMissingAIConfig(t *testing.T) { } } -func TestParseFloorplanRejectsCanonicalDimensionsThatDoNotMatchEffectiveImage(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":200,"image_height":300}}` - vision := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +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":2,"image_height":3}}` + 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) })) @@ -190,9 +205,23 @@ func TestParseFloorplanRejectsCanonicalDimensionsThatDoNotMatchEffectiveImage(t request.Header.Set("Content-Type", writer.FormDataContentType()) response := httptest.NewRecorder() router.ServeHTTP(response, request) - if response.Code != http.StatusUnprocessableEntity || !strings.Contains(response.Body.String(), "ai_content_unreliable") { + 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) { diff --git a/backend/internal/floorplan/parser.go b/backend/internal/floorplan/parser.go index 854d792..6079efa 100644 --- a/backend/internal/floorplan/parser.go +++ b/backend/internal/floorplan/parser.go @@ -14,6 +14,7 @@ import ( 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." @@ -52,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") } @@ -67,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}}, }, }, @@ -86,9 +99,23 @@ func (p *Parser) Parse(ctx context.Context, imageDataURL string) (ParseResult, e if err != nil { return ParseResult{}, &ParseError{Code: ParseErrorSchema, Err: err} } + if imageWidth > 0 && imageHeight > 0 { + if result.Metadata.ImageWidth != imageWidth || result.Metadata.ImageHeight != imageHeight { + return ParseResult{}, &ParseError{Code: ParseErrorContent, Err: fmt.Errorf("ai result does not use the source image coordinate grid")} + } + // Decoded upload bytes remain the durable source of truth after the + // provider has explicitly confirmed it used the same coordinate grid. + result.Metadata.ImageWidth = imageWidth + result.Metadata.ImageHeight = imageHeight + } if err := validateParsedResult(result); err != nil { 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. for i := range result.Doors { @@ -100,6 +127,24 @@ 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) { diff --git a/backend/internal/floorplan/parser_test.go b/backend/internal/floorplan/parser_test.go index 826fda9..a563836 100644 --- a/backend/internal/floorplan/parser_test.go +++ b/backend/internal/floorplan/parser_test.go @@ -84,6 +84,32 @@ 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 TestParseAtDimensionsRejectsProviderCoordinateGridMismatch(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() + + _, 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(), "coordinate grid") { + t.Fatalf("error = %v", err) + } +} + func TestParseRejectsInvalidOpenAIEnvelopes(t *testing.T) { for name, body := range map[string]string{ "non JSON": `not-json`, "empty choices": `{"choices":[]}`, "empty content": `{"choices":[{"message":{"content":""}}]}`, From 7b1f44af7ce2355e3ee8e7f55f590eb1d4038c83 Mon Sep 17 00:00:00 2001 From: Lux Date: Sun, 26 Jul 2026 11:06:06 +0800 Subject: [PATCH 20/39] fix: trust decoded floorplan dimensions --- backend/internal/api/router_test.go | 2 +- backend/internal/floorplan/parser.go | 9 ++++----- backend/internal/floorplan/parser_test.go | 11 +++++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/internal/api/router_test.go b/backend/internal/api/router_test.go index 7c0b833..02e2593 100644 --- a/backend/internal/api/router_test.go +++ b/backend/internal/api/router_test.go @@ -169,7 +169,7 @@ 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":2,"image_height":3}}` + 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 { diff --git a/backend/internal/floorplan/parser.go b/backend/internal/floorplan/parser.go index 6079efa..7f6af9c 100644 --- a/backend/internal/floorplan/parser.go +++ b/backend/internal/floorplan/parser.go @@ -100,11 +100,10 @@ func (p *Parser) parse(ctx context.Context, imageDataURL, userPrompt string, ima return ParseResult{}, &ParseError{Code: ParseErrorSchema, Err: err} } if imageWidth > 0 && imageHeight > 0 { - if result.Metadata.ImageWidth != imageWidth || result.Metadata.ImageHeight != imageHeight { - return ParseResult{}, &ParseError{Code: ParseErrorContent, Err: fmt.Errorf("ai result does not use the source image coordinate grid")} - } - // Decoded upload bytes remain the durable source of truth after the - // provider has explicitly confirmed it used the same coordinate grid. + // 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 } diff --git a/backend/internal/floorplan/parser_test.go b/backend/internal/floorplan/parser_test.go index a563836..d49a968 100644 --- a/backend/internal/floorplan/parser_test.go +++ b/backend/internal/floorplan/parser_test.go @@ -97,16 +97,19 @@ func TestParseAtDimensionsRejectsGeometryOutsideDecodedImage(t *testing.T) { } } -func TestParseAtDimensionsRejectsProviderCoordinateGridMismatch(t *testing.T) { +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() - _, err := NewParser(ai.NewClient(server.URL+"/v1", "test-key", "vision-test")).ParseAtDimensions( + 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 || ErrorCode(err) != ParseErrorContent || !strings.Contains(err.Error(), "coordinate grid") { - t.Fatalf("error = %v", err) + 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) } } From e3885c40d42b9d5430a39dc3fca36f38d39674b6 Mon Sep 17 00:00:00 2001 From: Lux Date: Sun, 26 Jul 2026 14:40:51 +0800 Subject: [PATCH 21/39] feat: secure resumable recognition snapshots --- CHANGELOG.md | 5 +- README.md | 10 +- backend/internal/api/project_routes_test.go | 188 ++++++++++++------ backend/internal/api/projects.go | 125 +++++++----- backend/internal/api/router.go | 36 +++- backend/internal/api/router_test.go | 51 ++++- backend/internal/db/postgres.go | 72 ++----- .../internal/db/postgres_integration_test.go | 23 ++- backend/internal/project/project.go | 24 ++- backend/internal/project/project_test.go | 23 +++ frontend/e2e/production.spec.ts | 119 ++++++++--- frontend/src/App.tsx | 77 ++++--- frontend/src/InspectorPanel.tsx | 69 ++++++- frontend/src/ProductShell.tsx | 4 +- frontend/src/ProductViews.test.ts | 97 +++++++++ frontend/src/ProductViews.tsx | 28 ++- frontend/src/ProjectSaveView.tsx | 23 +-- frontend/src/e2eBridge.disabled.ts | 1 - frontend/src/e2eBridge.ts | 6 - frontend/src/floorplanEditor.test.ts | 39 ++++ frontend/src/floorplanEditor.ts | 31 ++- frontend/src/floorplanUi.test.ts | 6 + frontend/src/floorplanUi.ts | 4 +- frontend/src/main.tsx | 8 +- frontend/src/projectAccess.test.ts | 63 ++++++ frontend/src/projectAccess.ts | 53 +++++ frontend/src/projectSession.test.ts | 186 ++++++++++------- frontend/src/projectSession.ts | 173 ++++++++++------ frontend/src/projects.test.ts | 51 +++-- frontend/src/projects.ts | 74 +++++-- frontend/src/useProjectSession.ts | 36 ++-- frontend/src/viteConfig.test.ts | 10 + frontend/vite.config.ts | 2 +- 33 files changed, 1246 insertions(+), 471 deletions(-) create mode 100644 frontend/src/ProductViews.test.ts create mode 100644 frontend/src/projectAccess.test.ts create mode 100644 frontend/src/projectAccess.ts create mode 100644 frontend/src/viteConfig.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6cb5d..a263dc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ ### Added -- 修复真实 Vision Provider 默认按内部预处理尺寸回报 `metadata.image_width/image_height`,导致有效裁切被错误拒绝的问题:解析请求现在显式绑定后端已解码的原图精确尺寸与坐标网格;Provider 必须确认使用同一网格,durable metadata 才采用上传字节的真实宽高,尺寸不一致或任何房间/墙体坐标越界仍失败关闭。真实懒猫浏览器以 `4701 × 4501` 裁切验证 `/api/floorplans/parse` 返回 200,并进入同尺寸、可编辑墙体覆盖可见的 2D 校正页。 +- 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 身份边界时关闭。 +- 选中墙体后可直接输入起点/终点四个 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。 @@ -25,6 +27,7 @@ ### 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;上游错误正文不会回显给浏览器。 diff --git a/README.md b/README.md index 9395c51..e4b0e58 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,18 @@ HomeVox 采用混合技术路线:**Go 管业务 API,Rust 管体素/几何核 - ✅ 户型图上传与 OpenAI-compatible AI 解析接口已落地;正向解析运行需要配置 `AI_API_KEY`、`AI_BASE_URL`、`AI_MODEL` - ✅ 自动判断与裁切:新文件会先请求候选区域;单一候选自动生成裁切后的 effective source 并解析,复合/不确定候选进入可调整裁切。分析的传输、schema 或内容失败会安全回到全图手动裁切;裁切后解析失败会保留原图、候选和裁切框以便重试。 - ✅ 持久化边界:只保存已确认并实际解析的 effective source;原图仅保留在当前浏览器会话中用于重新裁切,不会上传为项目 source image。 -- ✅ 2D 户型校正编辑器支持墙体选择、共享端点拖拽、Undo/Redo 与底图显隐 +- ✅ 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 的轮换/吊销入口;在此之前不要把继续编辑链接发给不受信任的人。 - ✅ 项目 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 与浏览器验收 @@ -65,7 +67,7 @@ npm --prefix frontend run test:e2e 本地开发与 LazyCat 生产发布是两条独立链路。HomeVox 在开发机和生产容器内都固定监听 `0.0.0.0:18088`。 -本地开发直接启动 Go 单端口服务,并通过开发机的 18088 端口前缀域名访问;不运行 `lzc-cli project deploy`,也不安装开发 LPK: +本地开发直接启动 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 diff --git a/backend/internal/api/project_routes_test.go b/backend/internal/api/project_routes_test.go index bbf772e..55172f7 100644 --- a/backend/internal/api/project_routes_test.go +++ b/backend/internal/api/project_routes_test.go @@ -3,6 +3,7 @@ package api import ( "bytes" "context" + "crypto/sha256" "encoding/json" "fmt" "mime/multipart" @@ -19,13 +20,14 @@ import ( ) 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","pixel_to_unit":null},"metadata":{"source":"fixture","confidence":0.5,"image_width":100,"image_height":80}}}` @@ -38,12 +40,12 @@ func projectDocumentForSourceImage(t testing.TB) string { } 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 { @@ -65,21 +67,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 { @@ -93,29 +96,6 @@ 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, - }) - } - if len(items) > limit { - return items[:limit], nil - } - return items, nil -} - func (f *fakeProjectRepo) Close() {} var _ db.ProjectRepository = (*fakeProjectRepo)(nil) @@ -182,6 +162,57 @@ func newProjectRouter(repo db.ProjectRepository, store storage.ObjectStore) *gin 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()) @@ -208,11 +239,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 { @@ -224,7 +255,7 @@ func TestProjectAPIReturnsUnavailableWhenPersistenceNotReady(t *testing.T) { } } -func TestProjectCreateGetsListAndSourceImage(t *testing.T) { +func TestProjectCreateReturnsCapabilityAndAuthorizesReads(t *testing.T) { repo := newFakeProjectRepo() store := newFakeObjectStore() router := newProjectRouter(repo, store) @@ -251,12 +282,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) } @@ -267,27 +305,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")) } @@ -296,11 +335,34 @@ 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 TestProjectCreateRejectsOversizedMultipartBodyBeforeParsing(t *testing.T) { @@ -374,19 +436,20 @@ func TestProjectCreateRejectsEffectiveSourceDimensionMismatch(t *testing.T) { } } -func TestProjectListRejectsLimitAbove100(t *testing.T) { +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 { @@ -396,7 +459,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) } @@ -405,6 +468,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 { @@ -421,6 +485,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) @@ -444,6 +509,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 { @@ -455,7 +521,7 @@ func TestProjectUpdateRejectsSourceImageMetadataMutation(t *testing.T) { repo := newFakeProjectRepo() image := validPNG(t) document := projectDocumentForSourceImage(t) - _, err := repo.Create(context.Background(), "00000000-0000-0000-0000-000000000001", "Plan", "source", "image/png", int64(len(image)), []byte(document)) + _, 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) } @@ -465,6 +531,7 @@ func TestProjectUpdateRejectsSourceImageMetadataMutation(t *testing.T) { 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) @@ -477,7 +544,7 @@ func TestProjectUpdateRejectsEffectiveSourceDimensionMutation(t *testing.T) { repo := newFakeProjectRepo() image := validPNG(t) document := projectDocumentForSourceImage(t) - _, err := repo.Create(context.Background(), "00000000-0000-0000-0000-000000000001", "Plan", "source", "image/png", int64(len(image)), []byte(document)) + _, 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) } @@ -488,6 +555,7 @@ func TestProjectUpdateRejectsEffectiveSourceDimensionMutation(t *testing.T) { 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") { @@ -499,7 +567,7 @@ 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", "Plan", "source", "image/png", int64(len(image)), []byte(document)) + _, 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) } @@ -509,6 +577,7 @@ func TestProjectUpdateChecksImmutableSourceObjectDimensions(t *testing.T) { 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") { @@ -581,18 +650,15 @@ 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) Close() {} func TestProjectDependenciesReadinessMatrix(t *testing.T) { diff --git a/backend/internal/api/projects.go b/backend/internal/api/projects.go index 96f69a9..016ea36 100644 --- a/backend/internal/api/projects.go +++ b/backend/internal/api/projects.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -54,14 +56,15 @@ type databaseConfig struct { } 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) @@ -125,6 +128,11 @@ func (deps projectDependencies) ready() bool { 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() + }) group.POST("", func(c *gin.Context) { if !deps.ready() { writeProjectError(c, http.StatusServiceUnavailable, "persistence_unavailable", "project persistence unavailable") @@ -192,6 +200,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 +218,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 +229,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 +237,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,17 +278,17 @@ 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", (&db.RevisionConflictError{ID: id, Expected: payload.ExpectedRevision, Current: current.Revision}).Error()) + writeProjectError(c, http.StatusConflict, "revision_conflict", "project has changed; load the latest version before saving") return } currentDocument, err := project.NormalizeDocument(current.Document) @@ -300,21 +317,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) { @@ -322,35 +339,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) { @@ -358,22 +347,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) { @@ -381,16 +374,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") @@ -423,14 +420,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, @@ -440,7 +437,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 bdef060..6b0b2c9 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -251,9 +252,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 @@ -261,3 +272,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 02e2593..cb5152a 100644 --- a/backend/internal/api/router_test.go +++ b/backend/internal/api/router_test.go @@ -19,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) diff --git a/backend/internal/db/postgres.go b/backend/internal/db/postgres.go index 8cca1dd..e078f84 100644 --- a/backend/internal/db/postgres.go +++ b/backend/internal/db/postgres.go @@ -37,20 +37,11 @@ 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) } type PostgresRepository struct { @@ -89,6 +80,8 @@ 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; + 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 +108,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 +130,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 +147,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} } diff --git a/backend/internal/db/postgres_integration_test.go b/backend/internal/db/postgres_integration_test.go index 525fb61..552b13b 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) + } + 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 len(list) != 1 { - t.Fatalf("list len = %d, want 1", len(list)) + if storedCapabilityHash != capabilityHash { + t.Fatalf("stored capability hash mismatch") } updatedDoc := floorplan.ParseResult{Walls: []floorplan.Segment{{X1: 0, Y1: 0, X2: 2, Y2: 0}}} @@ -78,14 +81,14 @@ 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") } } diff --git a/backend/internal/project/project.go b/backend/internal/project/project.go index 85a90e1..cc24d3e 100644 --- a/backend/internal/project/project.go +++ b/backend/internal/project/project.go @@ -19,6 +19,7 @@ const ( MaxCreateRequestBytes = MaxDocumentBytes + MaxSourceImageBytes + MaxCreateRequestOverhead MaxUpdateRequestBytes = MaxDocumentBytes + MaxCreateRequestOverhead MinNameLength = 1 + MinCanonicalWallLength = 1e-3 ) var SupportedImageContentTypes = []string{"image/png", "image/jpeg", "image/gif", "image/webp"} @@ -107,6 +108,9 @@ func validateDocumentEnvelope(doc floorplan.ParseResponse) error { 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 @@ -114,6 +118,24 @@ 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 { @@ -127,7 +149,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) } } diff --git a/backend/internal/project/project_test.go b/backend/internal/project/project_test.go index 923338d..22e8b97 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" ) @@ -115,6 +116,28 @@ 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 TestValidateSourceImageMetadata(t *testing.T) { doc, err := NormalizeDocument(validDocumentJSON()) if err != nil { diff --git a/frontend/e2e/production.spec.ts b/frontend/e2e/production.spec.ts index 3c1f365..128b57a 100644 --- a/frontend/e2e/production.spec.ts +++ b/frontend/e2e/production.spec.ts @@ -343,7 +343,7 @@ async function dragEndpoint(page: Page, testID: string, deltaX: number, deltaY: await page.mouse.up() } -test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one production lifecycle', async ({ page }, testInfo) => { +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') @@ -383,6 +383,53 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod 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() + 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.goto(`/?e2e=instrument#project=${createdSnapshot.id}&cap=${createdSnapshot.capability}`) + 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() + 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() @@ -463,13 +510,14 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod expect(new Set(hashes).size).toBe(4) await page.getByLabel('项目名称').fill('Production lifecycle project') - const save = page.waitForResponse((response) => response.url().endsWith('/api/projects') && response.request().method() === 'POST') - await page.getByRole('button', { name: '创建项目' }).click() + 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(201) + 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(1) + 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: 60 })])) @@ -480,24 +528,38 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod const pids = await restarted.json() as { oldPid: number; newPid: number } expect(pids.newPid).not.toBe(pids.oldPid) - await page.goto(`/?e2e=instrument&project=${savedProject.id}`) - await expect(page.getByRole('button', { name: /校正 2D,当前步骤/ })).toBeEnabled() - await expect(page.getByRole('button', { name: /导入户型图,已完成/ })).toBeVisible() - await expect(page.getByRole('button', { name: /AI 识别,已完成/ })).toBeVisible() - await expect(page.getByRole('button', { name: /保存项目,已完成/ })).toBeVisible() - await page.getByRole('button', { name: /校正 2D,当前步骤/ }).click() - await expect(page.getByLabel('2D 墙体编辑器')).toBeVisible() - await page.getByTestId('opening-handle-window-1').click({ force: true }) - await expect(page.getByTestId('opening-width')).toHaveValue('60') - await page.getByTestId('complete-product-step').click() - await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() - const reloadedGeometry = await e2eState(page) + 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: /保存项目,已完成/ })).toBeVisible() + 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('60') + 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 page.locator('body').ariaSnapshot() + const accessibility = await restartedPage.locator('body').ariaSnapshot() expect(accessibility).not.toMatch(/(?:WASM|Grid|triangles|fallback|结构化 JSON)/i) - await expect(page.locator('pre')).toHaveCount(0) + await expect(restartedPage.locator('pre')).toHaveCount(0) + await restartedContext.close() }) test('keeps a selected composite candidate and adjusted crop after parse failure', async ({ page }) => { @@ -656,7 +718,7 @@ test('keeps invalid and duplicate canonical identity failures closed', async ({ await expect(page.getByRole('button', { name: '2D/3D 联动' })).toBeDisabled() }) -test('fails closed after a 2D endpoint is dragged into a zero-length wall', async ({ page }) => { +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') @@ -668,14 +730,9 @@ test('fails closed after a 2D endpoint is dragged into a zero-length wall', asyn 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) - const zeroLengthWall = afterDrag.walls.find((wall) => wall.id === 'wall-1') - expect(zeroLengthWall).toBeDefined() - expect(Math.hypot((zeroLengthWall?.x2 ?? 0) - (zeroLengthWall?.x1 ?? 0), (zeroLengthWall?.y2 ?? 0) - (zeroLengthWall?.y1 ?? 0))).toBeLessThan(1e-3) - await expect(page.getByText('这个户型有一处需要调整', { exact: false })).toBeVisible() - await expect(page.getByTestId('complete-product-step')).toBeDisabled() - await expect(page.getByRole('button', { name: '生成 3D,未解锁' })).toBeDisabled() - await expect(page.getByRole('button', { name: '2D/3D 联动' })).toBeDisabled() - await expect(page.getByRole('button', { name: '保存项目' })).toBeDisabled() + 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() }) @@ -713,7 +770,7 @@ test('makes parse retry and persistence-unavailable states actionable', async ({ await page.getByRole('button', { name: '保存项目' }).click() await page.getByLabel('项目名称').fill('Unavailable persistence') await page.getByRole('button', { name: '创建项目' }).click() - await expect(page.getByRole('status')).toContainText('项目保存失败') + await expect(page.getByRole('alert')).toContainText('项目保存失败') }) test('keeps the narrow-screen workflow keyboard reachable', async ({ page }) => { @@ -733,7 +790,7 @@ test('keeps the narrow-screen workflow keyboard reachable', async ({ page }) => await expect(page.getByRole('button', { name: '完成并打开 3D' })).toBeVisible() }) -test('does not export stale 3D after current canonical geometry becomes invalid', async ({ page }) => { +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') @@ -749,7 +806,7 @@ test('does not export stale 3D after current canonical geometry becomes invalid' 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.getByText('这个户型有一处需要调整', { exact: false })).toBeVisible() + await expect(page.getByRole('alert')).toContainText('暂时无法这样调整') await expect(page.getByLabel('导出3D白模PNG')).toBeDisabled() let downloads = 0 page.on('download', () => { downloads += 1 }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c18789e..26b5d7e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { canUndo, createWallEditorState, addOpening, + editWallCoordinates, moveEndpoint, pushWallSnapshot, removeOpening, @@ -58,7 +59,7 @@ import { canExportCurrentThreeD } from './threeDExport' import { exportCurrentThreeDRevision, type ThreeDExportRevision } from './threeDExportSession' import { canonicalRevisionToken } from './floorplanSession' import { parseFailureMessage, parseNetworkFailureMessage, parseRetryFailureMessage } from './parseFeedback' -import { e2EProjectID, e2EWasmLoader, isE2EInstrumentationEnabled, publishE2EState } from '@homevox-e2e' +import { e2EWasmLoader, isE2EInstrumentationEnabled, publishE2EState } from '@homevox-e2e' import './App.css' const API_PARSE_URL = '/api/floorplans/parse' @@ -240,7 +241,7 @@ export default function App() { originalSourceRef.current = originalSource const requestSequenceRef = useRef(0) const cropOperationRef = useRef(new LatestOperation()) - const initialProjectLoadRef = useRef<(id: string) => Promise>(() => Promise.resolve()) + const wasmGenerationRef = useRef(0) const [, setWasmGeneration] = useState(0) const wasmGeometryRef = useRef(null) @@ -264,8 +265,8 @@ export default function App() { parseResponse ? { ...parseResponse, result: { ...parseResponse.result, walls, doors, windows } } : null ), [parseResponse, walls, doors, windows]) const geometryValidationError = useMemo( - () => validateCanonicalFloorplan(walls, openings), - [walls, openings], + () => validateCanonicalFloorplan(walls, openings, effectiveImageSize), + [effectiveImageSize, walls, openings], ) const hasCanonicalGeometry = Boolean(durableDocument) && !geometryValidationError // Content token changes in the same render as every wall/opening commit, before @@ -330,24 +331,27 @@ export default function App() { setOpeningError('') }, [applyProductTransition]) const projectSession = useProjectSession({ - document: durableDocument, - geometryValidationError, - sourceFile: effectiveSource, - onProjectSaved: () => applyProductTransition({ type: 'complete', step: 6 }, productFlowContext), + document: durableDocument, + geometryValidationError, + sourceFile: effectiveSource, + onProjectSaved: () => { + if (activeStep === 6) applyProductTransition({ type: 'complete', step: 6 }, productFlowContext) + }, onProjectLoaded: applyLoadedProject, }) const { projectName, currentProject, - projects, projectMessage, + projectMessageTone, projectBusy, setProjectName, clearCurrentProject, - refreshProjects, saveProject, - loadProject, - } = projectSession + loadInitialProject, + reloadProject, + copyProjectResumeLink, + } = projectSession const wallShellModel = useMemo( () => buildWallShellModel(walls, doors, windows), [walls, doors, windows], @@ -542,17 +546,11 @@ export default function App() { } }, [canonicalRevision, geometryValidationError, invalidateGeometry, resolveGeometry, wallVoxelModel]) + // The fragment-selected project is consumed once by the persistence controller; + // later editor changes must never reload it over local edits. useEffect(() => { - void refreshProjects() - }, [refreshProjects]) - - // The URL-selected project is an initial-load command, not a reactive - // request; later editor changes must never reload it over local edits. - initialProjectLoadRef.current = loadProject - useEffect(() => { - const projectID = e2EProjectID() - if (projectID) void initialProjectLoadRef.current(projectID) - }, []) + void loadInitialProject() + }, [loadInitialProject]) useEffect(() => () => { if (originalPreviewURL) URL.revokeObjectURL(originalPreviewURL) @@ -916,7 +914,7 @@ export default function App() { if (draggedEndpoint) { const moveResult = moveEndpoint(wallEditor, draggedEndpoint, cursor) const openingValidationError = moveResult.changed - ? validateCanonicalFloorplan(moveResult.walls, wallEditor.openings) + ? validateCanonicalFloorplan(moveResult.walls, wallEditor.openings, effectiveImageSize) : null setDragPreviewWalls(moveResult.changed ? moveResult.walls : null) setOpeningError(openingValidationError ?? '') @@ -972,6 +970,17 @@ export default function App() { setOpeningError('') } + function handleWallCoordinatesChange(coordinates: Pick) { + if (!wallEditor || !selectedWallID || !effectiveImageSize) return + const edit = editWallCoordinates(wallEditor, selectedWallID, coordinates, effectiveImageSize) + if (edit.error) { + setOpeningError(edit.error) + return + } + if (edit.changed) setWallEditor(pushWallSnapshot(wallEditor, edit.walls, wallEditor.openings)) + setOpeningError('') + } + function handleAddOpening(kind: 'door' | 'window') { if (!wallEditor || !selectedWallID) { setOpeningError('请先选择一面墙再添加开口') @@ -1027,8 +1036,8 @@ export default function App() { return } if (draggedEndpoint && dragPreviewWalls) { - const openingValidationError = validateCanonicalFloorplan(dragPreviewWalls, wallEditor.openings) - setWallEditor(pushWallSnapshot(wallEditor, dragPreviewWalls, wallEditor.openings)) + const openingValidationError = validateCanonicalFloorplan(dragPreviewWalls, wallEditor.openings, effectiveImageSize) + if (!openingValidationError) setWallEditor(pushWallSnapshot(wallEditor, dragPreviewWalls, wallEditor.openings)) setOpeningError(openingValidationError ?? '') } if (draggedOpeningID && dragPreviewOpenings) setWallEditor(pushWallSnapshot(wallEditor, wallEditor.walls, dragPreviewOpenings)) @@ -1150,6 +1159,7 @@ export default function App() { } const inspectorProps: InspectorPanelProps = { selectedWallID, + selectedWall, selectedOpening, openingError, canExport2D, @@ -1159,6 +1169,7 @@ export default function App() { canUndo: canUndo(wallEditor), canRedo: canRedo(wallEditor), onAddOpening: handleAddOpening, + onWallCoordinatesChange: handleWallCoordinatesChange, onOpeningWidthChange: (width) => { if (selectedOpening?.id) commitOpeningPatch(selectedOpening.id, { width }) }, @@ -1168,6 +1179,16 @@ export default function App() { onUndo: handleUndo, onRedo: handleRedo, } + const snapshot = { + exists: currentProject !== null, + busy: projectBusy === 'save', + canSave: hasCanonicalGeometry, + message: projectMessage, + messageTone: projectMessageTone, + onSave: () => { void saveProject() }, + onReload: () => { void reloadProject() }, + onCopyResumeLink: () => { void copyProjectResumeLink(window.location.href, (value) => navigator.clipboard.writeText(value)) }, + } const threeDPreviewProps: ThreeDPreviewPanelProps = { canonicalRevision, model: wallShellModel, @@ -1206,10 +1227,10 @@ export default function App() { applyProductTransition({ type: 'open', step })} primaryAction={primaryAction}> {activeStep === 1 && } {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 === 3 && } {activeStep === 4 && applyProductTransition({ type: 'open', step: 3 })} onComplete={() => completeAndAdvance(4, 5)} />} - {activeStep === 5 && applyProductTransition({ type: 'open', step: 3 })} />} - {activeStep === 6 && { void saveProject() }} onRefresh={() => { void refreshProjects() }} onLoad={(id) => { void loadProject(id) }} />} + {activeStep === 5 && applyProductTransition({ type: 'open', step: 3 })} />} + {activeStep === 6 && { void saveProject() }} onCopyResumeLink={() => { void copyProjectResumeLink(window.location.href, (value) => navigator.clipboard.writeText(value)) }} />} ) } diff --git a/frontend/src/InspectorPanel.tsx b/frontend/src/InspectorPanel.tsx index 20b1976..14724e6 100644 --- a/frontend/src/InspectorPanel.tsx +++ b/frontend/src/InspectorPanel.tsx @@ -1,8 +1,11 @@ +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 @@ -12,6 +15,7 @@ export type InspectorPanelProps = { canUndo: boolean canRedo: boolean onAddOpening: (kind: 'door' | 'window') => void + onWallCoordinatesChange?: (coordinates: Pick) => void onOpeningWidthChange: (width: number) => void onDeleteOpening: () => void onExport2D: () => void @@ -20,10 +24,57 @@ export type InspectorPanelProps = { 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, selectedOpening, openingError, canExport2D, canExport3D, exportingScope, - exportError, onAddOpening, onOpeningWidthChange, onDeleteOpening, onExport2D, onExport3D, + 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 @@ -32,8 +83,18 @@ export function InspectorPanel({ diff --git a/frontend/src/ProductShell.tsx b/frontend/src/ProductShell.tsx index 36d47d8..91175bd 100644 --- a/frontend/src/ProductShell.tsx +++ b/frontend/src/ProductShell.tsx @@ -9,7 +9,7 @@ type Props = { hasDocument: boolean onOpenStep: (step: ProductStep) => void primaryAction?: ReactNode - children: ReactNode + children?: ReactNode } export function ProductShell({ activeStep, completedSteps, flow, hasDocument, onOpenStep, primaryAction, children }: Props) { @@ -21,6 +21,6 @@ export function ProductShell({ activeStep, completedSteps, flow, hasDocument, on const stateLabel = [activeStep === step.id ? '当前步骤' : '', completed ? '已完成' : '', !completed && activeStep !== step.id ? (unlocked ? '可进入' : '未解锁') : ''].filter(Boolean).join(',') return })}

同一个 HomeVox 项目

2D 与 3D 会一起更新

-

{copy.title}

{copy.subtitle}

{hasDocument && 已同步}自动保存{primaryAction}
{children}
+

{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 index 1f05f3f..e09194e 100644 --- a/frontend/src/ProductViews.tsx +++ b/frontend/src/ProductViews.tsx @@ -8,19 +8,33 @@ 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 } -function WorkspaceToolbar({ inspector, linked = false, onAdvance, canAdvance }: Pick & { linked?: boolean }) { +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 ? : } @@ -28,8 +42,8 @@ function WorkspaceToolbar({ inspector, linked = false, onAdvance, canAdvance }:
} -export function TwoDWorkspace({ editor, inspector, canAdvance, onAdvance }: TwoDWorkspaceProps) { - return
+export function TwoDWorkspace({ editor, inspector, snapshot, canAdvance, onAdvance }: TwoDWorkspaceProps) { + return
} type ThreeDUnavailableProps = { @@ -87,9 +101,9 @@ export type LinkedWorkspaceProps = TwoDWorkspaceProps & { onBack: () => void } -export function LinkedWorkspace({ editor, preview, inspector, previewAvailable, canAdvance, onAdvance, onBack }: LinkedWorkspaceProps) { +export function LinkedWorkspace({ editor, preview, inspector, snapshot, previewAvailable, canAdvance, onAdvance, onBack }: LinkedWorkspaceProps) { if (!previewAvailable) { return

当前 3D 预览不可用

联动工作台已关闭,请先返回 2D 校正。

} - return
+ return
} diff --git a/frontend/src/ProjectSaveView.tsx b/frontend/src/ProjectSaveView.tsx index 3a03c4d..2c38cd0 100644 --- a/frontend/src/ProjectSaveView.tsx +++ b/frontend/src/ProjectSaveView.tsx @@ -1,43 +1,38 @@ -import type { ProjectDetail, ProjectSummary } from './projects' +import type { ProjectDetail } from './projects' export type ProjectSaveViewProps = { projectName: string currentProject: ProjectDetail | null - projects: readonly ProjectSummary[] projectMessage: string - projectBusy: 'list' | 'save' | 'load' | null + projectMessageTone: 'success' | 'error' + projectBusy: 'save' | 'load' | null canSave: boolean onProjectNameChange: (name: string) => void onSave: () => void - onRefresh: () => void - onLoad: (id: string) => void + onCopyResumeLink: () => void } export function ProjectSaveView({ projectName, currentProject, - projects, projectMessage, + projectMessageTone, projectBusy, canSave, onProjectNameChange, onSave, - onRefresh, - onLoad, + onCopyResumeLink, }: ProjectSaveViewProps) { return (
✓

保存为 HomeVox 项目

-

真实户型图、可编辑 2D 与可编辑 3D 会归入同一个 HomeVox 项目,可以随时继续编辑。

+

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

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

{projectMessage}

} -
-

已保存项目

-
    {projects.map((item) =>
  • {item.name}
  • )}{projects.length === 0 &&
  • 保存后会在这里显示你的项目。
  • }
-
+ {currentProject && } + {projectMessage &&

{projectMessage}

}
) diff --git a/frontend/src/e2eBridge.disabled.ts b/frontend/src/e2eBridge.disabled.ts index 40c4654..ef0b942 100644 --- a/frontend/src/e2eBridge.disabled.ts +++ b/frontend/src/e2eBridge.disabled.ts @@ -1,6 +1,5 @@ import type { WasmBindings } from './wasmMarchingCubes' export function isE2EInstrumentationEnabled(): boolean { return false } -export function e2EProjectID(): string | null { return null } export function e2EWasmLoader(): (() => Promise) | undefined { return undefined } export function publishE2EState(_state: unknown): void {} diff --git a/frontend/src/e2eBridge.ts b/frontend/src/e2eBridge.ts index 7e90c17..05d4402 100644 --- a/frontend/src/e2eBridge.ts +++ b/frontend/src/e2eBridge.ts @@ -14,12 +14,6 @@ export function isE2EInstrumentationEnabled(): boolean { return enabled() } -export function e2EProjectID(): string | null { - if (!enabled()) return null - const value = new URLSearchParams(window.location.search).get('project') - return value && /^[0-9a-f-]{36}$/i.test(value) ? value : null -} - export function e2EWasmLoader(): (() => Promise) | undefined { if (enabled() && new URLSearchParams(window.location.search).get('wasm') === 'load-failure') { return async () => { throw new Error('test-only WASM loader failure') } diff --git a/frontend/src/floorplanEditor.test.ts b/frontend/src/floorplanEditor.test.ts index 8d7ccfa..752e416 100644 --- a/frontend/src/floorplanEditor.test.ts +++ b/frontend/src/floorplanEditor.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createWallEditorState, + editWallCoordinates, moveEndpoint, pushWallSnapshot, redo, @@ -9,6 +10,44 @@ import { import { validateOpenings } from './floorplanUi' describe('wall edit core', () => { + it('applies bounded numeric coordinates as one shared-topology edit', () => { + const state = createWallEditorState([ + { id: 'wall-a', x1: 0, y1: 0, x2: 10, y2: 0 }, + { id: 'wall-b', x1: 10, y1: 0, x2: 20, y2: 0 }, + ]) + + const edited = editWallCoordinates(state, 'wall-a', { x1: 1, y1: 2, x2: 12, y2: 3 }, { width: 100, height: 80 }) + + expect(edited.error).toBeNull() + expect(edited.changed).toBe(true) + expect(edited.walls[0]).toEqual({ id: 'wall-a', x1: 1, y1: 2, x2: 12, y2: 3 }) + expect(edited.walls[1]).toEqual({ id: 'wall-b', x1: 12, y1: 3, x2: 20, y2: 0 }) + }) + + it('rejects non-finite, out-of-image, degenerate, and opening-breaking numeric coordinates', () => { + const state = createWallEditorState( + [{ id: 'wall-a', x1: 0, y1: 0, x2: 100, y2: 0 }], + [{ id: 'door-a', kind: 'door', wallId: 'wall-a', position: 0.5, width: 20 }], + ) + const bounds = { width: 100, height: 80 } + + expect(editWallCoordinates(state, 'wall-a', { x1: Number.NaN, y1: 0, x2: 100, y2: 0 }, bounds).error).toContain('finite') + expect(editWallCoordinates(state, 'wall-a', { x1: -1, y1: 0, x2: 100, y2: 0 }, bounds).error).toContain('image') + expect(editWallCoordinates(state, 'wall-a', { x1: 50, y1: 50, x2: 50, y2: 50 }, bounds).error).toContain('length') + expect(editWallCoordinates(state, 'wall-a', { x1: 0, y1: 0, x2: 20, y2: 0 }, bounds).error).toContain('opening') + }) + + it('rejects a shared-endpoint edit that degenerates an adjacent wall', () => { + const state = createWallEditorState([ + { id: 'wall-a', x1: 0, y1: 0, x2: 10, y2: 0 }, + { id: 'wall-b', x1: 10, y1: 0, x2: 20, y2: 0 }, + ]) + const edited = editWallCoordinates(state, 'wall-a', { x1: 0, y1: 0, x2: 20, y2: 0 }, { width: 100, height: 80 }) + expect(edited.error).toContain('length') + expect(edited.changed).toBe(false) + expect(edited.walls).toEqual(state.walls) + }) + it('moves only one endpoint when detached', () => { const state = createWallEditorState([ { diff --git a/frontend/src/floorplanEditor.ts b/frontend/src/floorplanEditor.ts index 4c9747d..3875ac2 100644 --- a/frontend/src/floorplanEditor.ts +++ b/frontend/src/floorplanEditor.ts @@ -1,4 +1,4 @@ -import { validateOpenings, type ParsedOpening } from './floorplanUi' +import { MIN_CANONICAL_WALL_LENGTH, validateOpenings, type ParsedOpening } from './floorplanUi' export type Endpoint = 'start' | 'end' @@ -26,6 +26,7 @@ export interface WallEditorState { } export interface EndpointMoveResult { walls: WallSegment[]; changed: boolean } +export interface WallCoordinateEditResult extends EndpointMoveResult { error: string | null } export interface OpeningEditResult { openings: ParsedOpening[]; changed: boolean; error: string | null } function isFiniteNumber(value: number): value is number { return Number.isFinite(value) } @@ -54,6 +55,34 @@ export function createWallEditorState(segments: readonly WallSegment[], openings export function getWallForEndpoint(state: WallEditorState, endpointRef: EndpointRef): Point | null { return isValidEndpointRef(state, endpointRef) ? endpointPoint(state.walls[endpointRef.wallIndex], endpointRef.endpoint) : null } export function moveEndpoint(state: WallEditorState, endpointRef: EndpointRef, cursor: Point): EndpointMoveResult { const anchor = getWallForEndpoint(state, endpointRef); if (!anchor || !isFinitePoint(cursor)) return { walls: cloneSegments(state.walls), changed: false }; const targets = state.sharedEndpoints.get(endpointKey(anchor)) ?? [endpointRef]; const moved = cloneSegments(state.walls); targets.forEach((target) => { if (isValidEndpointRef(state, target)) moved[target.wallIndex] = applyEndpointPoint(moved[target.wallIndex], target.endpoint, cursor) }); return { walls: moved, changed: !areSegmentsEqual(moved, state.walls) } } +export function editWallCoordinates( + state: WallEditorState, + wallID: string, + coordinates: Pick, + image: { width: number; height: number }, +): WallCoordinateEditResult { + const wallIndex = state.walls.findIndex((wall) => wall.id === wallID) + if (wallIndex < 0) return { walls: cloneSegments(state.walls), changed: false, error: 'wall is missing' } + if (![coordinates.x1, coordinates.y1, coordinates.x2, coordinates.y2, image.width, image.height].every(Number.isFinite)) return { walls: cloneSegments(state.walls), changed: false, error: 'wall coordinates must be finite' } + if (image.width <= 0 || image.height <= 0 || coordinates.x1 < 0 || coordinates.x1 > image.width || coordinates.x2 < 0 || coordinates.x2 > image.width || coordinates.y1 < 0 || coordinates.y1 > image.height || coordinates.y2 < 0 || coordinates.y2 > image.height) return { walls: cloneSegments(state.walls), changed: false, error: 'wall coordinates must remain inside the source image' } + if (!(Math.hypot(coordinates.x2 - coordinates.x1, coordinates.y2 - coordinates.y1) > MIN_CANONICAL_WALL_LENGTH)) return { walls: cloneSegments(state.walls), changed: false, error: 'wall length must be strictly greater than zero' } + const wall = state.walls[wallIndex] + const moved = cloneSegments(state.walls) + const applyShared = (endpoint: Endpoint, point: Point) => { + const targets = state.sharedEndpoints.get(endpointKey(endpointPoint(wall, endpoint))) ?? [{ wallIndex, endpoint }] + targets.forEach((target) => { + if (isValidEndpointRef(state, target)) moved[target.wallIndex] = applyEndpointPoint(moved[target.wallIndex], target.endpoint, point) + }) + } + applyShared('start', { x: coordinates.x1, y: coordinates.y1 }) + applyShared('end', { x: coordinates.x2, y: coordinates.y2 }) + if (moved.some((item) => !isFiniteSegment(item) || !(Math.hypot(item.x2 - item.x1, item.y2 - item.y1) > MIN_CANONICAL_WALL_LENGTH))) return { walls: cloneSegments(state.walls), changed: false, error: 'wall length must be strictly greater than zero' } + if (moved.some((item) => item.x1 < 0 || item.x1 > image.width || item.x2 < 0 || item.x2 > image.width || item.y1 < 0 || item.y1 > image.height || item.y2 < 0 || item.y2 > image.height)) return { walls: cloneSegments(state.walls), changed: false, error: 'wall coordinates must remain inside the source image' } + const openingError = validateOpenings(moved, state.openings) + if (openingError) return { walls: cloneSegments(state.walls), changed: false, error: openingError } + return { walls: moved, changed: !areSegmentsEqual(moved, state.walls), error: null } +} + /** Commits one atomic wall/opening document snapshot. */ export function pushWallSnapshot(state: WallEditorState, nextWalls: readonly WallSegment[], nextOpenings: readonly ParsedOpening[] = state.openings): WallEditorState { if (areSegmentsEqual(state.walls, nextWalls) && areOpeningsEqual(state.openings, nextOpenings)) return state diff --git a/frontend/src/floorplanUi.test.ts b/frontend/src/floorplanUi.test.ts index d72833b..855ca7b 100644 --- a/frontend/src/floorplanUi.test.ts +++ b/frontend/src/floorplanUi.test.ts @@ -129,4 +129,10 @@ describe('canonical floorplan admission', () => { expect(validateCanonicalFloorplan([{ x1: 0, y1: 0, x2: 100, y2: 0 }], [])).toContain('stable id') expect(validateCanonicalFloorplan([{ id: 'wall-a', x1: 0, y1: 0, x2: Number.NaN, y2: 0 }], [])).toContain('finite') }) + + it('rejects wall coordinates outside the effective source image', () => { + const walls = [{ id: 'wall-a', x1: 0, y1: 0, x2: 101, y2: 0 }] + expect(validateCanonicalFloorplan(walls, [], { width: 100, height: 80 })).toContain('source image') + expect(validateCanonicalFloorplan([{ ...walls[0], x2: 100 }], [], { width: 100, height: 80 })).toBeNull() + }) }) diff --git a/frontend/src/floorplanUi.ts b/frontend/src/floorplanUi.ts index d3a1bdf..425fcf4 100644 --- a/frontend/src/floorplanUi.ts +++ b/frontend/src/floorplanUi.ts @@ -51,8 +51,9 @@ export function openingPoint(wall: WallSegment, item: ParsedOpening): {x:number; * an invalid edit so a user can correct it, but 3D and persistence must never * consume a partial, anonymous, or degenerate document. */ -export function validateCanonicalFloorplan(walls: readonly WallSegment[], openings: readonly ParsedOpening[]): string | null { +export function validateCanonicalFloorplan(walls: readonly WallSegment[], openings: readonly ParsedOpening[], image?: { width: number; height: number } | null): string | null { if (walls.length === 0) return 'floorplan must contain at least one wall' + if (image && (!finite(image.width) || !finite(image.height) || image.width <= 0 || image.height <= 0)) return 'source image dimensions must be finite and positive' const wallIDs = new Set() for (const item of walls) { if (!id(item.id)) return 'wall must have a stable id' @@ -60,6 +61,7 @@ export function validateCanonicalFloorplan(walls: readonly WallSegment[], openin wallIDs.add(item.id) if (!finiteWallGeometry(item)) return 'wall coordinates must be finite' if (!(wallLength(item) > MIN_CANONICAL_WALL_LENGTH)) return 'wall length must be strictly greater than zero' + if (image && (item.x1 < 0 || item.x1 > image.width || item.x2 < 0 || item.x2 > image.width || item.y1 < 0 || item.y1 > image.height || item.y2 < 0 || item.y2 > image.height)) return 'wall coordinates must remain inside the source image' } return validateOpenings(walls, openings) } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..6b06aa0 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,15 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { consumeProjectAccessFragment, storeInitialProjectAccess } from './projectAccess' + +storeInitialProjectAccess(consumeProjectAccessFragment( + window.location, + (path) => window.history.replaceState(null, '', path), +)) createRoot(document.getElementById('root')!).render( - + , ) diff --git a/frontend/src/projectAccess.test.ts b/frontend/src/projectAccess.test.ts new file mode 100644 index 0000000..6ce44b2 --- /dev/null +++ b/frontend/src/projectAccess.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildProjectResumeURL, + consumeInitialProjectAccess, + consumeProjectAccessFragment, + storeInitialProjectAccess, + parseProjectAccessFragment, +} from './projectAccess' + +const access = { + id: '00000000-0000-0000-0000-000000000001', + capability: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', +} + +describe('project capability fragment handoff', () => { + it('accepts only a complete UUID and 32-byte base64url capability', () => { + expect(parseProjectAccessFragment(`#project=${access.id}&cap=${access.capability}`)).toEqual(access) + expect(parseProjectAccessFragment(`#project=${access.id}`)).toBeNull() + expect(parseProjectAccessFragment(`#project=${access.id}&cap=short`)).toBeNull() + expect(parseProjectAccessFragment(`#project=not-a-uuid&cap=${access.capability}`)).toBeNull() + }) + + it('builds a transfer URL with the secret only in the fragment', () => { + const url = buildProjectResumeURL(access, 'https://homevox.example/workspace?mode=edit') + const parsed = new URL(url) + expect(parsed.pathname).toBe('/workspace') + expect(parsed.search).toBe('?mode=edit') + expect(parsed.hash).toBe(`#project=${access.id}&cap=${access.capability}`) + expect(`${parsed.origin}${parsed.pathname}${parsed.search}`).not.toContain(access.capability) + }) + + it('consumes and removes the fragment before any project request is made', () => { + const replace = vi.fn() + const result = consumeProjectAccessFragment({ + hash: `#project=${access.id}&cap=${access.capability}`, + pathname: '/workspace', + search: '?mode=edit', + }, replace) + + expect(result).toEqual(access) + expect(replace).toHaveBeenCalledWith('/workspace?mode=edit') + expect(JSON.stringify(replace.mock.calls)).not.toContain(access.capability) + }) + + it('clears malformed project capability fragments without trying to authorize them', () => { + const replace = vi.fn() + const result = consumeProjectAccessFragment({ + hash: `#project=${access.id}&cap=truncated-secret`, + pathname: '/workspace', + search: '', + }, replace) + + expect(result).toBeNull() + expect(replace).toHaveBeenCalledWith('/workspace') + expect(JSON.stringify(replace.mock.calls)).not.toContain('truncated-secret') + }) + + it('hands initial access to the persistence controller exactly once without React props', () => { + storeInitialProjectAccess(access) + expect(consumeInitialProjectAccess()).toEqual(access) + expect(consumeInitialProjectAccess()).toBeNull() + }) +}) diff --git a/frontend/src/projectAccess.ts b/frontend/src/projectAccess.ts new file mode 100644 index 0000000..409809b --- /dev/null +++ b/frontend/src/projectAccess.ts @@ -0,0 +1,53 @@ +export type ProjectAccess = { + id: string + capability: string +} + +type FragmentLocation = Pick + +const projectIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const capabilityPattern = /^[A-Za-z0-9_-]{43}$/ + +let initialProjectAccess: ProjectAccess | null = null + +export function storeInitialProjectAccess(access: ProjectAccess | null): void { + initialProjectAccess = access +} + +export function consumeInitialProjectAccess(): ProjectAccess | null { + const access = initialProjectAccess + initialProjectAccess = null + return access +} + +export function isProjectAccess(value: unknown): value is ProjectAccess { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Partial + return typeof candidate.id === 'string' && + projectIDPattern.test(candidate.id) && + typeof candidate.capability === 'string' && + capabilityPattern.test(candidate.capability) +} + +export function parseProjectAccessFragment(fragment: string): ProjectAccess | null { + const params = new URLSearchParams(fragment.startsWith('#') ? fragment.slice(1) : fragment) + const access = { id: params.get('project') ?? '', capability: params.get('cap') ?? '' } + return isProjectAccess(access) ? access : null +} + +export function buildProjectResumeURL(access: ProjectAccess, baseURL: string): string { + if (!isProjectAccess(access)) throw new Error('项目访问凭据无效') + const url = new URL(baseURL) + url.hash = new URLSearchParams({ project: access.id, cap: access.capability }).toString() + return url.toString() +} + +export function consumeProjectAccessFragment( + location: FragmentLocation, + replace: (path: string) => void, +): ProjectAccess | null { + const params = new URLSearchParams(location.hash.startsWith('#') ? location.hash.slice(1) : location.hash) + const access = parseProjectAccessFragment(location.hash) + if (params.has('project') || params.has('cap')) replace(`${location.pathname}${location.search}`) + return access +} diff --git a/frontend/src/projectSession.test.ts b/frontend/src/projectSession.test.ts index e07d8b6..6dd55c2 100644 --- a/frontend/src/projectSession.test.ts +++ b/frontend/src/projectSession.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { createProjectSession, projectSaveIssue, type ProjectSessionDependencies } from './projectSession' -import type { ProjectDetail, ProjectSummary } from './projects' +import { storeInitialProjectAccess, type ProjectAccess } from './projectAccess' +import { ProjectAPIError, type ProjectDetail } from './projects' + +const capability = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const access: ProjectAccess = { id: '00000000-0000-0000-0000-000000000001', capability } const document = { filename: 'plan.png', @@ -21,14 +25,6 @@ const project: ProjectDetail = { document, } -const projectSummary: ProjectSummary = { - id: project.id, - name: project.name, - revision: project.revision, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - sourceImageURL: project.sourceImageURL, -} describe('project session save admission', () => { it('fails closed before persistence if the canonical document is absent or invalid', () => { @@ -54,21 +50,22 @@ function deferred() { function sessionHarness({ currentProject = null, sourceFile = new File(['png'], 'plan.png', { type: 'image/png' }), + initialAccess = currentProject ? access : null, dependencies = {}, }: { currentProject?: ProjectDetail | null sourceFile?: File | null + initialAccess?: ProjectAccess | null dependencies?: Partial } = {}) { const states: Array> = [] const onProjectSaved = vi.fn() const onProjectLoaded = vi.fn() const defaults: ProjectSessionDependencies = { - listProjects: vi.fn().mockResolvedValue([]), getProject: vi.fn().mockResolvedValue(project), - createProject: vi.fn().mockResolvedValue(project), + createProject: vi.fn().mockResolvedValue({ project, capability }), updateProject: vi.fn().mockResolvedValue({ ...project, revision: 3 }), - fetchSourceImage: vi.fn().mockResolvedValue(new Response(new Blob(['png'], { type: 'image/png' }), { status: 200 })), + fetchSourceImage: vi.fn().mockResolvedValue(new Blob(['png'], { type: 'image/png' })), } const controller = createProjectSession({ document: () => document, @@ -76,7 +73,7 @@ function sessionHarness({ sourceFile: () => sourceFile, projectName: () => 'Home', currentProject: () => currentProject, - projects: () => [] as readonly ProjectSummary[], + initialAccess, onProjectSaved, onProjectLoaded, onState: (next) => states.push(next), @@ -85,88 +82,35 @@ function sessionHarness({ } describe('ProjectSession controller', () => { - it('aborts the prior request and prevents a stale success from publishing over the latest request', async () => { - const first = deferred() - const second = deferred() - const listProjects = vi.fn() - .mockImplementationOnce(() => first.promise) - .mockImplementationOnce(() => second.promise) - const { controller, states } = sessionHarness({ dependencies: { listProjects } }) - - const oldRequest = controller.refreshProjects() - const oldSignal = listProjects.mock.calls[0][0] as AbortSignal - const latestRequest = controller.refreshProjects() - expect(oldSignal.aborted).toBe(true) - - first.resolve([{ ...projectSummary, name: 'stale project' }]) - second.resolve([projectSummary]) - await Promise.all([oldRequest, latestRequest]) - - expect(states.filter((state) => 'projects' in state).at(-1)?.projects).toEqual([projectSummary]) - expect(states.some((state) => JSON.stringify(state.projects ?? []).includes('stale project'))).toBe(false) - }) - - it('does not publish a stale request error after a newer refresh succeeds', async () => { - const first = deferred() - const second = deferred() - const listProjects = vi.fn() - .mockImplementationOnce(() => first.promise) - .mockImplementationOnce(() => second.promise) - const { controller, states } = sessionHarness({ dependencies: { listProjects } }) - - const oldRequest = controller.refreshProjects() - const latestRequest = controller.refreshProjects() - first.reject(new Error('old failure')) - second.resolve([projectSummary]) - await Promise.all([oldRequest, latestRequest]) - - expect(states.some((state) => String(state.projectMessage ?? '').includes('old failure'))).toBe(false) - expect(states.filter((state) => 'projects' in state).at(-1)?.projects).toEqual([projectSummary]) - }) - - it('aborts pending work on dispose and does not publish a late success', async () => { - const pending = deferred() - const listProjects = vi.fn().mockReturnValue(pending.promise) - const { controller, states } = sessionHarness({ dependencies: { listProjects } }) - - const request = controller.refreshProjects() - const signal = listProjects.mock.calls[0][0] as AbortSignal - const stateCountBeforeDispose = states.length - controller.dispose() - expect(signal.aborted).toBe(true) - pending.resolve([]) - await request - - expect(states).toHaveLength(stateCountBeforeDispose) - }) - it('loads only a valid project with a successful image response and never fabricates a loaded callback', async () => { const success = sessionHarness() - await success.controller.loadProject(project.id) + await success.controller.loadProject(access) expect(success.onProjectLoaded).toHaveBeenCalledOnce() expect(success.onProjectLoaded).toHaveBeenCalledWith(project, expect.any(Blob)) + expect(success.dependencies.getProject).toHaveBeenCalledWith(project.id, capability, expect.any(AbortSignal)) + expect(success.dependencies.fetchSourceImage).toHaveBeenCalledWith(project.sourceImageURL, capability, expect.any(AbortSignal)) const failure = sessionHarness({ dependencies: { - fetchSourceImage: vi.fn().mockResolvedValue(new Response('not an image', { status: 200, headers: { 'Content-Type': 'text/plain' } })), + fetchSourceImage: vi.fn().mockRejectedValue(new Error('原始户型图不是受支持的图片')), }, }) - await failure.controller.loadProject(project.id) + await failure.controller.loadProject(access) expect(failure.onProjectLoaded).not.toHaveBeenCalled() expect(failure.states.some((state) => String(state.projectMessage ?? '').includes('原始户型图不是受支持的图片'))).toBe(true) const unavailable = sessionHarness({ dependencies: { - fetchSourceImage: vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 })), + fetchSourceImage: vi.fn().mockRejectedValue(new Error('HTTP 503: 无法加载原始户型图')), }, }) - await unavailable.controller.loadProject(project.id) + await unavailable.controller.loadProject(access) expect(unavailable.onProjectLoaded).not.toHaveBeenCalled() expect(unavailable.states.some((state) => String(state.projectMessage ?? '').includes('HTTP 503'))).toBe(true) }) it('preserves create and update semantics and only completes after the API confirms success', async () => { - const pendingCreate = deferred() + const pendingCreate = deferred<{ project: ProjectDetail; capability: string }>() const create = sessionHarness({ dependencies: { createProject: vi.fn().mockReturnValue(pendingCreate.promise) } }) const createRequest = create.controller.saveProject() expect(create.dependencies.createProject).toHaveBeenCalledWith('Home', document, expect.any(File), expect.any(AbortSignal)) @@ -174,14 +118,104 @@ describe('ProjectSession controller', () => { expect([uploaded.name, uploaded.type, uploaded.size]).toEqual([document.filename, document.contentType, document.size]) expect(create.dependencies.updateProject).not.toHaveBeenCalled() expect(create.onProjectSaved).not.toHaveBeenCalled() - pendingCreate.resolve(project) + pendingCreate.resolve({ project, capability }) await createRequest expect(create.onProjectSaved).toHaveBeenCalledOnce() + expect(create.onProjectSaved).toHaveBeenCalledWith(project) const update = sessionHarness({ currentProject: project, sourceFile: null }) await update.controller.saveProject() - expect(update.dependencies.updateProject).toHaveBeenCalledWith(project.id, 'Home', document, project.revision, expect.any(AbortSignal)) + expect(update.dependencies.updateProject).toHaveBeenCalledWith(project.id, capability, 'Home', document, project.revision, expect.any(AbortSignal)) expect(update.dependencies.createProject).not.toHaveBeenCalled() expect(update.onProjectSaved).toHaveBeenCalledOnce() + expect(update.onProjectSaved).toHaveBeenCalledWith({ ...project, revision: 3 }) + }) + + it('publishes a safe revision-conflict state that can be recovered by loading the latest project', async () => { + const conflict = sessionHarness({ + currentProject: project, + sourceFile: null, + dependencies: { + updateProject: vi.fn().mockRejectedValue(new ProjectAPIError('revision_conflict', '项目已在其他页面更新,请加载最新版本后再保存')), + }, + }) + await conflict.controller.saveProject() + expect(conflict.states.at(-2)).toMatchObject({ projectMessage: '项目已在其他页面更新,请加载最新版本后再保存', projectMessageTone: 'error' }) + expect(JSON.stringify(conflict.states)).not.toContain(project.id) + + await conflict.controller.loadProject(access) + expect(conflict.onProjectLoaded).toHaveBeenCalledWith(project, expect.any(Blob)) + }) + + + it('copies the resume link through an injected writer and reports success without publishing the capability', async () => { + const create = sessionHarness() + await create.controller.saveProject() + const writeText = vi.fn().mockResolvedValue(undefined) + await create.controller.copyProjectResumeLink('https://homevox.example/workspace', writeText) + expect(writeText).toHaveBeenCalledWith(`https://homevox.example/workspace#project=${project.id}&cap=${capability}`) + expect(create.states.at(-1)).toMatchObject({ projectMessage: '继续编辑链接已复制,请妥善保管', projectMessageTone: 'success' }) + expect(JSON.stringify(create.states)).not.toContain(capability) + }) + + it('reactivates after a StrictMode cleanup without losing the private capability', async () => { + const resumed = sessionHarness({ currentProject: project, sourceFile: null }) + resumed.controller.dispose() + resumed.controller.activate() + await resumed.controller.reloadProject() + expect(resumed.dependencies.getProject).toHaveBeenCalledWith(project.id, capability, expect.any(AbortSignal)) + expect(resumed.onProjectLoaded).toHaveBeenCalledWith(project, expect.any(Blob)) + }) + + it('consumes an initial vaulted capability once and retries only when StrictMode cleanup aborts it', async () => { + storeInitialProjectAccess(access) + const pending = deferred() + const resumed = sessionHarness({ + initialAccess: null, + dependencies: { getProject: vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue(project) }, + }) + const first = resumed.controller.loadInitialProject() + resumed.controller.dispose() + resumed.controller.activate() + const second = resumed.controller.loadInitialProject() + pending.resolve(project) + await Promise.all([first, second]) + expect(resumed.onProjectLoaded).toHaveBeenCalledOnce() + await resumed.controller.loadInitialProject() + expect(resumed.dependencies.getProject).toHaveBeenCalledTimes(2) + }) + + it('invalidates pending load, create, and update work when project access is cleared', async () => { + const pendingLoad = deferred() + const load = sessionHarness({ dependencies: { getProject: vi.fn().mockReturnValue(pendingLoad.promise) } }) + const loadRequest = load.controller.loadProject(access) + load.controller.clearAccess() + pendingLoad.resolve(project) + await loadRequest + expect(load.onProjectLoaded).not.toHaveBeenCalled() + expect(load.dependencies.fetchSourceImage).not.toHaveBeenCalled() + + const pendingCreate = deferred<{ project: ProjectDetail; capability: string }>() + const create = sessionHarness({ dependencies: { createProject: vi.fn().mockReturnValue(pendingCreate.promise) } }) + const createRequest = create.controller.saveProject() + create.controller.clearAccess() + pendingCreate.resolve({ project, capability }) + await createRequest + expect(create.onProjectSaved).not.toHaveBeenCalled() + const writeText = vi.fn().mockResolvedValue(undefined) + await create.controller.copyProjectResumeLink('https://homevox.example/workspace', writeText) + expect(writeText).not.toHaveBeenCalled() + + const pendingUpdate = deferred() + const update = sessionHarness({ + currentProject: project, + sourceFile: null, + dependencies: { updateProject: vi.fn().mockReturnValue(pendingUpdate.promise) }, + }) + const updateRequest = update.controller.saveProject() + update.controller.clearAccess() + pendingUpdate.resolve({ ...project, revision: 3 }) + await updateRequest + expect(update.onProjectSaved).not.toHaveBeenCalled() }) }) diff --git a/frontend/src/projectSession.ts b/frontend/src/projectSession.ts index 0c3e6f2..43e2288 100644 --- a/frontend/src/projectSession.ts +++ b/frontend/src/projectSession.ts @@ -1,14 +1,17 @@ import type { ParseResponse } from './floorplanUi' +import { buildProjectResumeURL, consumeInitialProjectAccess, type ProjectAccess } from './projectAccess' import { createProject, + fetchProjectSourceImage, getProject, - listProjects, + ProjectAPIError, updateProject, + type CreatedProject, type ProjectDetail, - type ProjectSummary, } from './projects' -export type ProjectBusy = 'list' | 'save' | 'load' | null +export type ProjectBusy = 'save' | 'load' | null +export type ProjectMessageTone = 'success' | 'error' export function projectSaveIssue({ document, @@ -38,59 +41,58 @@ export function projectSaveIssue({ type Request = { id: number; controller: AbortController } export type ProjectSessionDependencies = { - listProjects: (signal?: AbortSignal) => Promise - getProject: (id: string, signal?: AbortSignal) => Promise + getProject: (id: string, capability: string, signal?: AbortSignal) => Promise createProject: ( name: string, document: ParseResponse, sourceImage: File, signal?: AbortSignal, - ) => Promise + ) => Promise updateProject: ( id: string, + capability: string, name: string, document: ParseResponse, expectedRevision: number, signal?: AbortSignal, ) => Promise - fetchSourceImage: ( - url: string, - init: { signal: AbortSignal }, - ) => Promise + fetchSourceImage: (url: string, capability: string, signal?: AbortSignal) => Promise } const defaultProjectSessionDependencies: ProjectSessionDependencies = { - listProjects, getProject, createProject, updateProject, - fetchSourceImage: (url, init) => fetch(url, init), + fetchSourceImage: fetchProjectSourceImage, } export type ProjectSession = { projectName: string currentProject: ProjectDetail | null - projects: readonly ProjectSummary[] projectMessage: string + projectMessageTone: ProjectMessageTone projectBusy: ProjectBusy setProjectName: (name: string) => void clearCurrentProject: () => void - refreshProjects: () => Promise saveProject: () => Promise - loadProject: (id: string) => Promise + loadProject: (access: ProjectAccess) => Promise + loadInitialProject: () => Promise + reloadProject: () => Promise + copyProjectResumeLink: (baseURL: string, writeText: (value: string) => Promise) => Promise + clearAccess: () => void + activate: () => void dispose: () => void } -/** Persistence controller with request identity/abort ownership. Product and - * editor state remain in App, but views receive only this controller's typed - * commands rather than project API details. */ +/** Persistence controller with request identity/abort ownership. Capability + * plaintext stays in this closure and is never merged into project/editor state. */ export function createProjectSession({ document, geometryValidationError, sourceFile, projectName, currentProject, - projects, + initialAccess, onProjectSaved, onProjectLoaded, onState, @@ -100,14 +102,17 @@ export function createProjectSession({ sourceFile: () => File | null projectName: () => string currentProject: () => ProjectDetail | null - projects: () => readonly ProjectSummary[] - onProjectSaved: () => void + initialAccess: ProjectAccess | null + onProjectSaved: (project: ProjectDetail) => void onProjectLoaded: (project: ProjectDetail, sourceImage: Blob) => void - onState: (next: Partial>) => void -}, dependencies: ProjectSessionDependencies = defaultProjectSessionDependencies): Omit { + onState: (next: Partial>) => void +}, dependencies: ProjectSessionDependencies = defaultProjectSessionDependencies): Omit { let request: Request | null = null let sequence = 0 let disposed = false + let access = initialAccess + let initialLoadComplete = false + const beginRequest = (): Request | null => { if (disposed) return null request?.controller.abort() @@ -118,20 +123,28 @@ export function createProjectSession({ } const isCurrent = (next: Request): boolean => !disposed && request?.id === next.id - return { - async refreshProjects() { - const active = beginRequest() - if (!active) return - onState({ projectBusy: 'list' }) - try { - const nextProjects = await dependencies.listProjects(active.controller.signal) - if (isCurrent(active)) onState({ projects: nextProjects }) + const loadAccess = async (nextAccess: ProjectAccess): Promise => { + const active = beginRequest() + if (!active) return false + onState({ projectBusy: 'load', projectMessage: '', projectMessageTone: 'success' }) + try { + const loaded = await dependencies.getProject(nextAccess.id, nextAccess.capability, active.controller.signal) + if (!isCurrent(active)) return false + const sourceImage = await dependencies.fetchSourceImage(loaded.sourceImageURL, nextAccess.capability, active.controller.signal) + if (!isCurrent(active)) return false + access = nextAccess + onState({ currentProject: loaded, projectName: loaded.name, projectMessage: '项目已加载', projectMessageTone: 'success' }) + onProjectLoaded(loaded, sourceImage) + return true } catch (error) { - if (!active.controller.signal.aborted && isCurrent(active)) onState({ projectMessage: `项目列表加载失败:${error instanceof Error ? error.message : '未知错误'}` }) - } finally { - if (isCurrent(active)) onState({ projectBusy: null }) - } - }, + if (!active.controller.signal.aborted && isCurrent(active)) onState({ projectMessage: `项目加载失败:${error instanceof Error ? error.message : '未知错误'}`, projectMessageTone: 'error' }) + return false + } finally { + if (isCurrent(active)) onState({ projectBusy: null }) + } + } + + return { async saveProject() { const issue = projectSaveIssue({ document: document(), @@ -141,54 +154,86 @@ export function createProjectSession({ sourceFile: sourceFile(), }) if (issue) { - onState({ projectMessage: issue }) + onState({ projectMessage: issue, projectMessageTone: 'error' }) return } - const active = beginRequest() - if (!active) return const durableDocument = document() const existing = currentProject() + if (existing && (!access || access.id !== existing.id)) { + onState({ projectMessage: '当前项目缺少有效访问凭据,请通过继续编辑链接重新打开', projectMessageTone: 'error' }) + return + } + const active = beginRequest() + if (!active) return const name = projectName() - onState({ projectBusy: 'save', projectMessage: '' }) + onState({ projectBusy: 'save', projectMessage: '', projectMessageTone: 'success' }) try { - const saved = existing - ? await dependencies.updateProject(existing.id, name, durableDocument!, existing.revision, active.controller.signal) - : await dependencies.createProject(name, durableDocument!, sourceFile()!, active.controller.signal) + let saved: ProjectDetail + let createdAccess: ProjectAccess | null = null + if (existing) { + saved = await dependencies.updateProject(existing.id, access!.capability, name, durableDocument!, existing.revision, active.controller.signal) + } else { + const created = await dependencies.createProject(name, durableDocument!, sourceFile()!, active.controller.signal) + saved = created.project + createdAccess = { id: saved.id, capability: created.capability } + } if (!isCurrent(active)) return + if (createdAccess) access = createdAccess onState({ currentProject: saved, projectName: saved.name, projectMessage: existing ? '项目已保存' : '项目已创建', - projects: [saved, ...projects().filter((item) => item.id !== saved.id)], + projectMessageTone: 'success', }) - onProjectSaved() + onProjectSaved(saved) } catch (error) { - if (!active.controller.signal.aborted && isCurrent(active)) onState({ projectMessage: `项目保存失败:${error instanceof Error ? error.message : '未知错误'}` }) + if (!active.controller.signal.aborted && isCurrent(active)) onState({ + projectMessage: error instanceof ProjectAPIError && error.code === 'revision_conflict' + ? error.message + : `项目保存失败:${error instanceof Error ? error.message : '未知错误'}`, + projectMessageTone: 'error', + }) } finally { if (isCurrent(active)) onState({ projectBusy: null }) } }, - async loadProject(id: string) { - const active = beginRequest() - if (!active) return - onState({ projectBusy: 'load', projectMessage: '' }) + async loadProject(nextAccess) { + await loadAccess(nextAccess) + }, + async loadInitialProject() { + if (initialLoadComplete) return + access ??= consumeInitialProjectAccess() + if (access) initialLoadComplete = await loadAccess(access) + }, + async reloadProject() { + if (!access) { + onState({ projectMessage: '当前项目缺少有效访问凭据,请通过继续编辑链接重新打开', projectMessageTone: 'error' }) + return + } + await loadAccess(access) + }, + async copyProjectResumeLink(baseURL: string, writeText: (value: string) => Promise) { + if (!access) { + onState({ projectMessage: '请先创建识别快照', projectMessageTone: 'error' }) + return + } try { - const loaded = await dependencies.getProject(id, active.controller.signal) - if (!isCurrent(active)) return - const imageResponse = await dependencies.fetchSourceImage(loaded.sourceImageURL, { signal: active.controller.signal }) - if (!imageResponse.ok) throw new Error(`HTTP ${imageResponse.status}: 无法加载原始户型图`) - const contentType = imageResponse.headers.get('content-type')?.toLowerCase() ?? '' - if (!contentType.startsWith('image/')) throw new Error('原始户型图不是受支持的图片') - const sourceImage = await imageResponse.blob() - if (!isCurrent(active)) return - onState({ currentProject: loaded, projectName: loaded.name, projectMessage: '项目已加载' }) - onProjectLoaded(loaded, sourceImage) - } catch (error) { - if (!active.controller.signal.aborted && isCurrent(active)) onState({ projectMessage: `项目加载失败:${error instanceof Error ? error.message : '未知错误'}` }) - } finally { - if (isCurrent(active)) onState({ projectBusy: null }) + await writeText(buildProjectResumeURL(access, baseURL)) + onState({ projectMessage: '继续编辑链接已复制,请妥善保管', projectMessageTone: 'success' }) + } catch { + onState({ projectMessage: '继续编辑链接复制失败,请检查浏览器剪贴板权限', projectMessageTone: 'error' }) } }, + clearAccess() { + access = null + request?.controller.abort() + sequence += 1 + request = null + onState({ projectBusy: null }) + }, + activate() { + disposed = false + }, dispose() { disposed = true request?.controller.abort() diff --git a/frontend/src/projects.test.ts b/frontend/src/projects.test.ts index 0e4df95..7a5b481 100644 --- a/frontend/src/projects.test.ts +++ b/frontend/src/projects.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { getProject, isProjectDetail, listProjects } from './projects' +import { createProject, fetchProjectSourceImage, getProject, isProjectDetail, updateProject } from './projects' + +const capability = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' const detail = { id: '00000000-0000-0000-0000-000000000001', @@ -23,27 +25,34 @@ describe('project API runtime validation', () => { expect(isProjectDetail(detail)).toBe(true) expect(isProjectDetail({ ...detail, document: { ...detail.document, result: undefined } })).toBe(false) expect(isProjectDetail({ ...detail, sourceImageSize: 0 })).toBe(false) + expect(isProjectDetail({ ...detail, id: 'not-a-uuid' })).toBe(false) + expect(isProjectDetail({ ...detail, sourceImageURL: 'https://attacker.example/source-image' })).toBe(false) + expect(isProjectDetail({ ...detail, sourceImageURL: '/api/projects/00000000-0000-0000-0000-000000000002/source-image' })).toBe(false) }) - it('rejects malformed project list responses', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify([{ name: 'missing fields' }]), { status: 200 }))) - await expect(listProjects()).rejects.toThrow('项目列表无效') + it('uses the stable error envelope message', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: { code: 'not_found', message: 'project not found' } }), { status: 404 }))) + await expect(getProject(detail.id, capability)).rejects.toThrow('HTTP 404: project not found') vi.unstubAllGlobals() }) - it('uses the stable error envelope message', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: { code: 'not_found', message: 'project not found' } }), { status: 404 }))) - await expect(getProject(detail.id)).rejects.toThrow('HTTP 404: project not found') + it('maps revision conflict to safe user copy without leaking project identity or revisions', async () => { + const internal = `project ${detail.id} expected revision 2, current revision 3` + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: { code: 'revision_conflict', message: internal } }), { status: 409 }))) + const error = await getProject(detail.id, capability).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe('项目已在其他页面更新,请加载最新版本后再保存') + expect((error as Error).message).not.toContain(detail.id) + expect((error as Error).message).not.toMatch(/revision|HTTP 409/i) vi.unstubAllGlobals() }) it('creates a project with the durable document and source image', async () => { - const { createProject } = await import('./projects') - const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(detail), { status: 201 })) + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...detail, capability }), { status: 201 })) vi.stubGlobal('fetch', fetchMock) const image = new File(['png'], 'plan.png', { type: 'image/png' }) - await expect(createProject('Home', detail.document, image)).resolves.toEqual(detail) + await expect(createProject('Home', detail.document, image)).resolves.toEqual({ project: detail, capability }) const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] expect(url).toBe('/api/projects') expect(init.method).toBe('POST') @@ -56,16 +65,32 @@ describe('project API runtime validation', () => { }) it('updates a loaded project using its expected revision', async () => { - const { updateProject } = await import('./projects') - const updated = { ...detail, name: 'Home+', revision: 2 } + const updated = { ...detail, name: 'Home+', revision: 2 } const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(updated), { status: 200 })) vi.stubGlobal('fetch', fetchMock) - await expect(updateProject(detail.id, 'Home+', detail.document, 1)).resolves.toEqual(updated) + await expect(updateProject(detail.id, capability, 'Home+', detail.document, 1)).resolves.toEqual(updated) const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] expect(url).toBe(`/api/projects/${detail.id}`) expect(init.method).toBe('PUT') + expect(new Headers(init.headers).get('X-HomeVox-Project-Capability')).toBe(capability) expect(JSON.parse(init.body as string)).toEqual({ name: 'Home+', document: detail.document, expectedRevision: 1 }) vi.unstubAllGlobals() }) + + it('sends the capability header for project and source-image reads', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 })) + .mockResolvedValueOnce(new Response(new Blob(['png'], { type: 'image/png' }), { status: 200, headers: { 'Content-Type': 'image/png' } })) + vi.stubGlobal('fetch', fetchMock) + + await expect(getProject(detail.id, capability)).resolves.toEqual(detail) + const image = await fetchProjectSourceImage(detail.sourceImageURL, capability) + expect(image.type).toBe('image/png') + for (const call of fetchMock.mock.calls) { + const init = call[1] as RequestInit + expect(new Headers(init.headers).get('X-HomeVox-Project-Capability')).toBe(capability) + } + vi.unstubAllGlobals() + }) }) diff --git a/frontend/src/projects.ts b/frontend/src/projects.ts index 53bdb85..4a929ef 100644 --- a/frontend/src/projects.ts +++ b/frontend/src/projects.ts @@ -1,5 +1,8 @@ import { isParseResponse, type ParseResponse } from './floorplanUi' +export const PROJECT_CAPABILITY_HEADER = 'X-HomeVox-Project-Capability' +const projectIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + export type ProjectSummary = { id: string name: string @@ -15,7 +18,21 @@ export type ProjectDetail = ProjectSummary & { sourceImageSize: number } -type ErrorEnvelope = { error?: { message?: unknown } | unknown } +export type CreatedProject = { + project: ProjectDetail + capability: string +} + +type ErrorEnvelope = { error?: { code?: unknown; message?: unknown } | unknown } + +export class ProjectAPIError extends Error { + readonly code: string | null + constructor(code: string | null, message: string) { + super(message) + this.name = 'ProjectAPIError' + this.code = code + } +} function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -24,12 +41,13 @@ function isRecord(value: unknown): value is Record { function isProjectSummary(value: unknown): value is ProjectSummary { return isRecord(value) && typeof value.id === 'string' && + projectIDPattern.test(value.id) && typeof value.name === 'string' && typeof value.revision === 'number' && Number.isInteger(value.revision) && typeof value.createdAt === 'string' && typeof value.updatedAt === 'string' && - typeof value.sourceImageURL === 'string' + value.sourceImageURL === `/api/projects/${value.id}/source-image` } export function isProjectDetail(value: unknown): value is ProjectDetail { @@ -42,6 +60,18 @@ export function isProjectDetail(value: unknown): value is ProjectDetail { record.sourceImageSize > 0 } +function isCreatedProject(value: unknown): value is ProjectDetail & { capability: string } { + if (!isProjectDetail(value) || !isRecord(value)) return false + const capability = (value as Record).capability + return typeof capability === 'string' && /^[A-Za-z0-9_-]{43}$/.test(capability) +} + +function capabilityHeaders(capability: string, headers?: HeadersInit): Headers { + const result = new Headers(headers) + result.set(PROJECT_CAPABILITY_HEADER, capability) + return result +} + async function responseError(response: Response): Promise { const text = await response.text() let body: ErrorEnvelope | null = null @@ -50,10 +80,14 @@ async function responseError(response: Response): Promise { } catch { // Keep a useful error when an intermediary returned non-JSON. } + const code = isRecord(body?.error) && typeof body.error.code === 'string' ? body.error.code : null + if (response.status === 409 && code === 'revision_conflict') { + return new ProjectAPIError(code, '项目已在其他页面更新,请加载最新版本后再保存') + } const message = isRecord(body?.error) && typeof body.error.message === 'string' ? body.error.message : text.trim() || response.statusText || '请求失败' - return new Error(`HTTP ${response.status}: ${message}`) + return new ProjectAPIError(code, `HTTP ${response.status}: ${message}`) } async function parseJSON(response: Response): Promise { @@ -61,37 +95,41 @@ async function parseJSON(response: Response): Promise { return response.json() } -export async function listProjects(signal?: AbortSignal): Promise { - const body = await parseJSON(await fetch('/api/projects', { signal })) - if (!Array.isArray(body) || !body.every(isProjectSummary)) { - throw new Error('服务返回的项目列表无效') - } - return body -} - -export async function getProject(id: string, signal?: AbortSignal): Promise { - const body = await parseJSON(await fetch(`/api/projects/${encodeURIComponent(id)}`, { signal })) +export async function getProject(id: string, capability: string, signal?: AbortSignal): Promise { + const body = await parseJSON(await fetch(`/api/projects/${encodeURIComponent(id)}`, { + headers: capabilityHeaders(capability), + signal, + })) if (!isProjectDetail(body)) throw new Error('服务返回的项目文档无效') return body } -export async function createProject(name: string, document: ParseResponse, sourceImage: File, signal?: AbortSignal): Promise { +export async function createProject(name: string, document: ParseResponse, sourceImage: File, signal?: AbortSignal): Promise { const form = new FormData() form.append('name', name) form.append('document', JSON.stringify(document)) form.append('source_image', sourceImage) const body = await parseJSON(await fetch('/api/projects', { method: 'POST', body: form, signal })) - if (!isProjectDetail(body)) throw new Error('服务返回的已创建项目无效') - return body + if (!isCreatedProject(body)) throw new Error('服务返回的已创建项目无效') + const { capability, ...project } = body + return { project, capability } } -export async function updateProject(id: string, name: string, document: ParseResponse, expectedRevision: number, signal?: AbortSignal): Promise { +export async function updateProject(id: string, capability: string, name: string, document: ParseResponse, expectedRevision: number, signal?: AbortSignal): Promise { const body = await parseJSON(await fetch(`/api/projects/${encodeURIComponent(id)}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: capabilityHeaders(capability, { 'Content-Type': 'application/json' }), body: JSON.stringify({ name, document, expectedRevision }), signal, })) if (!isProjectDetail(body)) throw new Error('服务返回的已保存项目无效') return body } + +export async function fetchProjectSourceImage(url: string, capability: string, signal?: AbortSignal): Promise { + const response = await fetch(url, { headers: capabilityHeaders(capability), signal }) + if (!response.ok) throw await responseError(response) + const contentType = response.headers.get('content-type')?.toLowerCase() ?? '' + if (!contentType.startsWith('image/')) throw new Error('原始户型图不是受支持的图片') + return response.blob() +} diff --git a/frontend/src/useProjectSession.ts b/frontend/src/useProjectSession.ts index 7d20ed2..a8cd4e0 100644 --- a/frontend/src/useProjectSession.ts +++ b/frontend/src/useProjectSession.ts @@ -1,31 +1,31 @@ import { useEffect, useRef, useState } from 'react' import type { ParseResponse } from './floorplanUi' -import type { ProjectDetail, ProjectSummary } from './projects' +import type { ProjectDetail } from './projects' import { createProjectSession, type ProjectBusy, type ProjectSession } from './projectSession' type ProjectSessionOptions = { document: ParseResponse | null geometryValidationError: string | null sourceFile: File | null - onProjectSaved: () => void + + onProjectSaved: (project: ProjectDetail) => void onProjectLoaded: (project: ProjectDetail, sourceImage: Blob) => void } -type ProjectSessionState = Pick +type ProjectSessionState = Pick const initialState: ProjectSessionState = { projectName: '', currentProject: null, - projects: [], projectMessage: '', + projectMessageTone: 'success', projectBusy: null, } export type UseProjectSession = ProjectSession -/** React state boundary for durable project persistence. The stable controller - * owns cancellation/request identity while App supplies the current canonical - * document and applies a successfully loaded document to the editor. */ +/** React state boundary for durable project persistence. Capability plaintext + * remains owned by the stable controller closure rather than React state. */ export function useProjectSession(options: ProjectSessionOptions): UseProjectSession { const [state, setState] = useState(initialState) const documentRef = useRef(options.document) @@ -49,8 +49,8 @@ export function useProjectSession(options: ProjectSessionOptions): UseProjectSes sourceFile: () => sourceFileRef.current, projectName: () => stateRef.current.projectName, currentProject: () => stateRef.current.currentProject, - projects: () => stateRef.current.projects, - onProjectSaved: () => savedRef.current(), + initialAccess: null, + onProjectSaved: (project) => savedRef.current(project), onProjectLoaded: (project, sourceImage) => loadedRef.current(project, sourceImage), onState: (next) => { setState((current) => ({ ...current, ...next })) @@ -59,17 +59,27 @@ export function useProjectSession(options: ProjectSessionOptions): UseProjectSes } const controller = controllerRef.current - useEffect(() => () => controller.dispose(), [controller]) + useEffect(() => { + controller.activate() + return () => controller.dispose() + }, [controller]) return { ...state, setProjectName: (projectName: string) => setState((current) => ({ ...current, projectName })), - clearCurrentProject: () => setState((current) => ({ ...current, currentProject: null })), - refreshProjects: controller.refreshProjects, + clearCurrentProject: () => { + controller.clearAccess() + setState((current) => ({ ...current, currentProject: null })) + }, saveProject: controller.saveProject, loadProject: controller.loadProject, + loadInitialProject: controller.loadInitialProject, + reloadProject: controller.reloadProject, + copyProjectResumeLink: controller.copyProjectResumeLink, + clearAccess: controller.clearAccess, + activate: controller.activate, dispose: controller.dispose, } } -export type { ProjectBusy, ProjectDetail, ProjectSummary } +export type { ProjectBusy, ProjectDetail } diff --git a/frontend/src/viteConfig.test.ts b/frontend/src/viteConfig.test.ts new file mode 100644 index 0000000..8ab6b5c --- /dev/null +++ b/frontend/src/viteConfig.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import viteConfig from '../vite.config' + +describe('development API proxy security contract', () => { + it('preserves the browser-facing host for same-host CORS validation', () => { + const proxy = viteConfig.server?.proxy?.['/api'] + expect(typeof proxy).toBe('object') + expect(proxy && typeof proxy === 'object' ? proxy.changeOrigin : undefined).toBe(false) + }) +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 478cc8b..d731b26 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -42,7 +42,7 @@ export default defineConfig({ proxy: { '/api': { target: 'http://127.0.0.1:18088', - changeOrigin: true, + changeOrigin: false, }, }, }, From d53b949e58d40db9857c3b863c0ac2d67a682540 Mon Sep 17 00:00:00 2001 From: Lux Date: Sun, 26 Jul 2026 15:05:15 +0800 Subject: [PATCH 22/39] fix: retire legacy snapshot capabilities --- CHANGELOG.md | 1 + README.md | 1 + backend/internal/db/postgres.go | 22 ++++++++ .../internal/db/postgres_integration_test.go | 53 +++++++++++++++++++ backend/internal/project/project.go | 11 ++-- backend/internal/project/project_test.go | 13 +++++ 6 files changed, 97 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a263dc4..8a16b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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 等待阶段现在显示动态“正在判断当前裁切区域…”状态并禁用重复提交;若裁切图仍缺少完整、无遮挡的墙体边界,失败说明会明确要求继续调整或更换原始无遮挡图,不再重复泛化提示“裁切到单个户型”。 diff --git a/README.md b/README.md index e4b0e58..b60dc97 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ HomeVox 采用混合技术路线:**Go 管业务 API,Rust 管体素/几何核 - ✅ 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、capability-gated get/source-image/update、stale-revision-409、独立浏览器恢复、重启后加载及未配置持久化时 project API 的 503;无 capability 的 UUID-only 请求失败关闭,全局 list 不可枚举。 - 3D 视口公开引擎状态、grid、三角形/顶点、调用耗时与输入/输出字节;单次主线程 WASM 调用预算为 50ms。加载、输入、输出或预算失败时明确回退到既有 wall-shell,2D 编辑、项目加载和 PNG 导出仍可用。 diff --git a/backend/internal/db/postgres.go b/backend/internal/db/postgres.go index e078f84..1c44cff 100644 --- a/backend/internal/db/postgres.go +++ b/backend/internal/db/postgres.go @@ -82,6 +82,28 @@ CREATE TABLE IF NOT EXISTS projects ( ALTER TABLE projects ADD COLUMN IF NOT EXISTS capability_hash text; +-- Rows created before capability authorization have no bearer secret that can +-- be recovered safely. Preserve their data, but retire access fail-closed with +-- an unredeemable random digest rather than leaving an ambiguous NULL state. +UPDATE projects +SET capability_hash = replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') +WHERE capability_hash IS NULL; + +ALTER TABLE projects ALTER COLUMN capability_hash SET 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 { diff --git a/backend/internal/db/postgres_integration_test.go b/backend/internal/db/postgres_integration_test.go index 552b13b..81b1a6d 100644 --- a/backend/internal/db/postgres_integration_test.go +++ b/backend/internal/db/postgres_integration_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "regexp" "testing" "time" @@ -93,6 +94,58 @@ func TestPostgresRepositoryLifecycle(t *testing.T) { } } +func TestInitializeSchemaRetiresLegacyProjectsAndConstrainsCapabilityHash(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() + + 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()) +); +INSERT INTO projects (id, name, source_image_key, source_image_content_type, source_image_size, document) +VALUES ('00000000-0000-4000-8000-000000000099', 'Legacy', 'legacy/source.png', 'image/png', 12, '{}');`); err != nil { + t.Fatalf("create legacy schema: %v", err) + } + + repo := &PostgresRepository{pool: pool} + if err := repo.InitializeSchema(ctx); err != nil { + t.Fatalf("migrate legacy schema: %v", err) + } + if err := repo.InitializeSchema(ctx); err != nil { + t.Fatalf("repeat legacy migration: %v", err) + } + var nullable string + if err := pool.QueryRow(ctx, `SELECT is_nullable FROM information_schema.columns WHERE table_name = 'projects' AND column_name = 'capability_hash'`).Scan(&nullable); err != nil { + t.Fatalf("read capability nullability: %v", err) + } + if nullable != "NO" { + t.Fatalf("capability_hash nullable = %s, want NO", nullable) + } + var hash string + if err := pool.QueryRow(ctx, `SELECT capability_hash FROM projects WHERE id = '00000000-0000-4000-8000-000000000099'`).Scan(&hash); err != nil { + t.Fatalf("read retired legacy hash: %v", err) + } + if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(hash) { + t.Fatalf("legacy hash = %q, want retired random digest", hash) + } + if _, err := pool.Exec(ctx, `INSERT INTO projects (id, capability_hash, name, source_image_key, source_image_content_type, source_image_size, document) VALUES ('00000000-0000-4000-8000-000000000098', 'invalid', 'Invalid', 'invalid/source.png', 'image/png', 12, '{}')`); err == nil { + t.Fatal("invalid capability hash should violate database constraint") + } +} + func truncateProjectsTable(ctx context.Context, dsn string) error { pool, err := pgxpool.New(ctx, dsn) if err != nil { diff --git a/backend/internal/project/project.go b/backend/internal/project/project.go index cc24d3e..a294e74 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" @@ -26,6 +27,8 @@ var SupportedImageContentTypes = []string{"image/png", "image/jpeg", "image/gif" var imageContentTypeSet = map[string]struct{}{} +var stableGeometryIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + func init() { for _, contentType := range SupportedImageContentTypes { imageContentTypeSet[contentType] = struct{}{} @@ -139,8 +142,8 @@ func validateImageBounds(doc floorplan.ParseResponse) error { 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) @@ -209,8 +212,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) diff --git a/backend/internal/project/project_test.go b/backend/internal/project/project_test.go index 22e8b97..44b4008 100644 --- a/backend/internal/project/project_test.go +++ b/backend/internal/project/project_test.go @@ -138,6 +138,19 @@ func TestNormalizeDocumentUsesCanonicalMinimumWallLength(t *testing.T) { } } +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 { From 12ae59234acdb4e7a88559a778f1c9a45840da32 Mon Sep 17 00:00:00 2001 From: Lux Date: Sat, 8 Aug 2026 09:51:55 +0800 Subject: [PATCH 23/39] fix: render visible walls from wasm geometry --- frontend/src/ThreeDPreview.tsx | 71 +++++++++++++++------------- frontend/src/wasmThreeDScene.test.ts | 39 +++++++++++++++ frontend/src/wasmThreeDScene.ts | 65 +++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 34 deletions(-) create mode 100644 frontend/src/wasmThreeDScene.test.ts create mode 100644 frontend/src/wasmThreeDScene.ts diff --git a/frontend/src/ThreeDPreview.tsx b/frontend/src/ThreeDPreview.tsx index 5d0142e..5a93a6b 100644 --- a/frontend/src/ThreeDPreview.tsx +++ b/frontend/src/ThreeDPreview.tsx @@ -3,6 +3,7 @@ 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 } @@ -111,7 +112,11 @@ function CanonicalScene({ onSelectWall, onSelectOpening, }: Omit) { - const pieces = buildWallShellPieces(model) + 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 ( <> @@ -124,40 +129,38 @@ function CanonicalScene({ )} - {/* Keep the generated WASM mesh in the mounted scene for a single - canonical/WASM/renderer revision. Canonical pieces are the visible - presentation because their explicit spans preserve opening cut-outs. */} - {wasmActive && wasmGeometry && } - - {pieces.map((piece) => ( - - { event.stopPropagation(); onSelectWall(piece.wallId) }} - onClick={(event) => { event.stopPropagation(); onSelectWall(piece.wallId) }} - > - - - - - ))} - {selectedWallID && model.walls.filter((wall) => wall.id === selectedWallID).map((wall) => ( - - - - - - - + {/* 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) => ( diff --git a/frontend/src/wasmThreeDScene.test.ts b/frontend/src/wasmThreeDScene.test.ts new file mode 100644 index 0000000..8342342 --- /dev/null +++ b/frontend/src/wasmThreeDScene.test.ts @@ -0,0 +1,39 @@ +import { BufferGeometry } from 'three' +import { describe, expect, it, vi } from 'vitest' +import { nearestWasmWallID, wasmWallMeshPresentation } from './wasmThreeDScene' +import type { WallShellModel } from './wallShell' + +const model: WallShellModel = { + walls: [ + { id: 'wall-a', sourceIndex: 0, x: 0, z: 0, length: 6, height: 2.8, thickness: 0.18, rotationY: 0 }, + { id: 'wall-b', sourceIndex: 1, x: 3, z: 2, length: 4, height: 2.8, thickness: 0.18, rotationY: -Math.PI / 2 }, + ], + openings: [], + floor: { x: 0, z: 0, width: 8, depth: 6 }, + scale: 1, + validationError: null, +} + +describe('visible WASM 3D scene contract', () => { + it('uses the generated WASM BufferGeometry as the visible and interactive wall mesh', () => { + const geometry = new BufferGeometry() + const onSelectWall = vi.fn() + const presentation = wasmWallMeshPresentation(true, geometry, model, onSelectWall) + + expect(presentation).not.toBeNull() + expect(presentation).toMatchObject({ geometry, visible: true, castShadow: true, receiveShadow: true }) + presentation!.onSelectAt(0.5, 0) + expect(onSelectWall).toHaveBeenCalledWith('wall-a') + }) + + it('does not expose a success-path mesh without active WASM geometry', () => { + expect(wasmWallMeshPresentation(false, new BufferGeometry(), model, vi.fn())).toBeNull() + expect(wasmWallMeshPresentation(true, null, model, vi.fn())).toBeNull() + }) + + it('maps a WASM surface click back to its canonical wall identity', () => { + expect(nearestWasmWallID(model, 2.5, 0.03)).toBe('wall-a') + expect(nearestWasmWallID(model, 3.02, 2.7)).toBe('wall-b') + }) +}) + diff --git a/frontend/src/wasmThreeDScene.ts b/frontend/src/wasmThreeDScene.ts new file mode 100644 index 0000000..528f324 --- /dev/null +++ b/frontend/src/wasmThreeDScene.ts @@ -0,0 +1,65 @@ +import type { BufferGeometry } from 'three' +import type { WallShellModel } from './wallShell' + +export type WasmWallMeshPresentation = { + geometry: BufferGeometry + visible: true + castShadow: true + receiveShadow: true + onSelectAt: (x: number, z: number) => void +} + +function pointToSegmentDistanceSquared( + pointX: number, + pointZ: number, + startX: number, + startZ: number, + endX: number, + endZ: number, +): number { + const deltaX = endX - startX + const deltaZ = endZ - startZ + const lengthSquared = deltaX * deltaX + deltaZ * deltaZ + if (!Number.isFinite(lengthSquared) || lengthSquared <= 0) return Number.POSITIVE_INFINITY + const progress = Math.max(0, Math.min(1, ((pointX - startX) * deltaX + (pointZ - startZ) * deltaZ) / lengthSquared)) + const closestX = startX + deltaX * progress + const closestZ = startZ + deltaZ * progress + return (pointX - closestX) ** 2 + (pointZ - closestZ) ** 2 +} + +/** Resolves a click on the actual WASM surface to the nearest canonical wall. + * Canonical IDs remain the editing contract; rendering geometry remains WASM. */ +export function nearestWasmWallID(model: WallShellModel, x: number, z: number): string | null { + if (!Number.isFinite(x) || !Number.isFinite(z)) return null + let closest: { id: string; distanceSquared: number } | null = null + for (const wall of model.walls) { + const halfLength = wall.length / 2 + const offsetX = Math.cos(wall.rotationY) * halfLength + const offsetZ = -Math.sin(wall.rotationY) * halfLength + const distanceSquared = pointToSegmentDistanceSquared(x, z, wall.x - offsetX, wall.z - offsetZ, wall.x + offsetX, wall.z + offsetZ) + if (!closest || distanceSquared < closest.distanceSquared) closest = { id: wall.id, distanceSquared } + } + return closest?.id ?? null +} + +/** The only success-path wall surface. It is intentionally absent until the + * active canonical revision has produced a real WASM BufferGeometry. */ +export function wasmWallMeshPresentation( + wasmActive: boolean, + geometry: BufferGeometry | null, + model: WallShellModel, + onSelectWall: (wallID: string) => void, +): WasmWallMeshPresentation | null { + if (!wasmActive || !geometry) return null + return { + geometry, + visible: true, + castShadow: true, + receiveShadow: true, + onSelectAt: (x, z) => { + const wallID = nearestWasmWallID(model, x, z) + if (wallID) onSelectWall(wallID) + }, + } +} + From cf821565e44acf92452e96c73567f1241ddc9541 Mon Sep 17 00:00:00 2001 From: Lux Date: Sat, 8 Aug 2026 09:52:46 +0800 Subject: [PATCH 24/39] build: reuse installed pinned wasm toolchain --- scripts/bootstrap-wasm.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/bootstrap-wasm.sh b/scripts/bootstrap-wasm.sh index a5178ad..8b6bab2 100755 --- a/scripts/bootstrap-wasm.sh +++ b/scripts/bootstrap-wasm.sh @@ -4,11 +4,18 @@ set -euo pipefail readonly RUST_TOOLCHAIN="1.96.1" readonly WASM_PACK_VERSION="0.13.1" -rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal -rustup target add wasm32-unknown-unknown --toolchain "$RUST_TOOLCHAIN" +# Avoid an unnecessary network refresh when the pinned compiler is already +# installed. A clean environment still installs the exact pinned toolchain. +if ! rustup run "$RUST_TOOLCHAIN" rustc --version >/dev/null 2>&1; then + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal +fi +if ! rustup target list --toolchain "$RUST_TOOLCHAIN" --installed | grep -qx 'wasm32-unknown-unknown'; then + rustup target add wasm32-unknown-unknown --toolchain "$RUST_TOOLCHAIN" +fi if ! command -v wasm-pack >/dev/null 2>&1 || ! wasm-pack --version | grep -qx "wasm-pack ${WASM_PACK_VERSION}"; then cargo +"$RUST_TOOLCHAIN" install wasm-pack --version "$WASM_PACK_VERSION" --locked fi +rustup run "$RUST_TOOLCHAIN" rustc --version wasm-pack --version From 306105513375d3db7b2ce627874c2a2ca2f440ff Mon Sep 17 00:00:00 2001 From: Lux Date: Sat, 8 Aug 2026 21:32:36 +0800 Subject: [PATCH 25/39] ci: run full validation for pull requests --- .github/workflows/ci.yml | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8511514 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: HomeVox CI + +on: + pull_request: + push: + branches: + - main + - feat/** + +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 + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v6 + with: + go-version-file: backend/go.mod + - run: go test ./... + + 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 From 7c13f1d38ad494000974f842f2384619686bb4bc Mon Sep 17 00:00:00 2001 From: Lux Date: Sat, 8 Aug 2026 21:37:19 +0800 Subject: [PATCH 26/39] ci: avoid duplicate feature branch runs --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8511514..6478988 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,6 @@ on: push: branches: - main - - feat/** permissions: contents: read @@ -26,6 +25,7 @@ jobs: - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum - run: go test ./... frontend-and-wasm: From 39fedbcc5caba3e0f82e015ce38c0cb8cc9eca13 Mon Sep 17 00:00:00 2001 From: Lux Date: Sat, 8 Aug 2026 22:35:37 +0800 Subject: [PATCH 27/39] fix: address persistence and editing QA findings --- backend/internal/api/project_routes_test.go | 12 +++++ backend/internal/api/projects.go | 56 +++++++++++++++++---- backend/internal/api/router.go | 4 ++ backend/internal/config/config.go | 42 ++++++++-------- backend/internal/db/postgres.go | 35 ++++++++++--- frontend/src/App.tsx | 38 +++++++++----- frontend/src/ProductViews.tsx | 7 +-- frontend/src/cropFlow.ts | 6 ++- frontend/src/main.tsx | 9 ++-- frontend/src/productFlow.test.ts | 4 +- frontend/src/productFlow.ts | 3 +- frontend/src/projectAccess.ts | 13 +++-- frontend/src/projectSession.test.ts | 13 ++++- frontend/src/projectSession.ts | 7 ++- frontend/src/wallVoxel.ts | 15 ++++-- 15 files changed, 187 insertions(+), 77 deletions(-) diff --git a/backend/internal/api/project_routes_test.go b/backend/internal/api/project_routes_test.go index 55172f7..08808cf 100644 --- a/backend/internal/api/project_routes_test.go +++ b/backend/internal/api/project_routes_test.go @@ -96,6 +96,15 @@ func (f *fakeProjectRepo) Update(_ context.Context, id, capabilityHash string, e return project, nil } +func (f *fakeProjectRepo) RecoverLegacy(_ context.Context, id, capabilityHash, _ string) (db.Project, error) { + project, ok := f.projects[id] + if !ok || f.capabilityHashes[id] != "" { + return db.Project{}, db.ErrProjectNotFound + } + f.capabilityHashes[id] = capabilityHash + return project, nil +} + func (f *fakeProjectRepo) Close() {} var _ db.ProjectRepository = (*fakeProjectRepo)(nil) @@ -659,6 +668,9 @@ func (f newFailingProjectRepo) Get(context.Context, string, string) (db.Project, 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) RecoverLegacy(context.Context, string, string, string) (db.Project, error) { + return db.Project{}, db.ErrProjectNotFound +} func (f newFailingProjectRepo) Close() {} func TestProjectDependenciesReadinessMatrix(t *testing.T) { diff --git a/backend/internal/api/projects.go b/backend/internal/api/projects.go index 016ea36..7b0dab3 100644 --- a/backend/internal/api/projects.go +++ b/backend/internal/api/projects.go @@ -5,6 +5,7 @@ import ( "context" "crypto/rand" "crypto/sha256" + "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" @@ -35,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() { @@ -48,11 +50,12 @@ 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 ( @@ -82,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 @@ -133,6 +136,39 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { 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.ready() { + 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 + } + 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()) + 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") diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 6b0b2c9..da1e27d 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -81,6 +81,7 @@ 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+1024*1024) file, header, err := c.Request.FormFile("floorplan") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "field floorplan is required"}) @@ -156,6 +157,9 @@ func newRouterWithCleanup(cfg config.Config, startupTimeout time.Duration, initi }) 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+1024*1024) file, header, err := c.Request.FormFile("floorplan") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "field floorplan is required"}) 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/db/postgres.go b/backend/internal/db/postgres.go index 1c44cff..84caae5 100644 --- a/backend/internal/db/postgres.go +++ b/backend/internal/db/postgres.go @@ -42,6 +42,7 @@ type ProjectRepository interface { 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) + RecoverLegacy(ctx context.Context, id, capabilityHash, actor string) (Project, error) } type PostgresRepository struct { @@ -82,14 +83,13 @@ CREATE TABLE IF NOT EXISTS projects ( ALTER TABLE projects ADD COLUMN IF NOT EXISTS capability_hash text; --- Rows created before capability authorization have no bearer secret that can --- be recovered safely. Preserve their data, but retire access fail-closed with --- an unredeemable random digest rather than leaving an ambiguous NULL state. -UPDATE projects -SET capability_hash = replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') -WHERE capability_hash IS NULL; - -ALTER TABLE projects ALTER COLUMN capability_hash SET NOT NULL; +-- 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 +); DO $$ BEGIN @@ -203,6 +203,25 @@ RETURNING id, name, source_image_key, source_image_content_type, source_image_si return normalizeProjectTimes(updated), nil } +func (r *PostgresRepository) RecoverLegacy(ctx context.Context, id, capabilityHash, actor string) (Project, error) { + var recovered Project + err := r.pool.QueryRow(ctx, ` +WITH claimed AS ( + UPDATE projects SET capability_hash = $2 WHERE id = $1 AND capability_hash IS NULL + RETURNING id, name, source_image_key, source_image_content_type, source_image_size, revision, document, created_at, 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).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/frontend/src/App.tsx b/frontend/src/App.tsx index 26b5d7e..dec3cc6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -255,23 +255,27 @@ export default function App() { }) const result = parseResponse?.result ?? null - const walls = dragPreviewWalls ?? wallEditor?.walls ?? result?.walls ?? EMPTY_WALLS - const openings = useMemo(() => dragPreviewOpenings ?? wallEditor?.openings ?? (result ? [...result.doors, ...result.windows] : []), [dragPreviewOpenings, result, wallEditor]) - const [doors, windows] = useMemo(() => [openings.filter((opening) => opening.kind === 'door'), openings.filter((opening) => opening.kind === 'window')], [openings]) + // Pointer preview remains a 2D-only transient. Persisted canonical geometry and + // WASM rebuilds advance only when the drag is committed. + const canonicalWalls = wallEditor?.walls ?? result?.walls ?? EMPTY_WALLS + const walls = dragPreviewWalls ?? canonicalWalls + const canonicalOpenings = useMemo(() => wallEditor?.openings ?? (result ? [...result.doors, ...result.windows] : []), [result, wallEditor]) + const openings = useMemo(() => dragPreviewOpenings ?? canonicalOpenings, [canonicalOpenings, dragPreviewOpenings]) + const [doors, windows] = useMemo(() => [canonicalOpenings.filter((opening) => opening.kind === 'door'), canonicalOpenings.filter((opening) => opening.kind === 'window')], [canonicalOpenings]) const selectedOpening = openings.find((opening) => opening.id === selectedOpeningID) ?? null const selectedWall = walls.find((wall) => wall.id === selectedWallID) ?? null const durableDocument = useMemo(() => ( - parseResponse ? { ...parseResponse, result: { ...parseResponse.result, walls, doors, windows } } : null - ), [parseResponse, walls, doors, windows]) + parseResponse ? { ...parseResponse, result: { ...parseResponse.result, walls: canonicalWalls, doors, windows } } : null + ), [parseResponse, canonicalWalls, doors, windows]) const geometryValidationError = useMemo( - () => validateCanonicalFloorplan(walls, openings, effectiveImageSize), - [effectiveImageSize, walls, openings], + () => validateCanonicalFloorplan(canonicalWalls, canonicalOpenings, effectiveImageSize), + [effectiveImageSize, canonicalWalls, canonicalOpenings], ) const hasCanonicalGeometry = Boolean(durableDocument) && !geometryValidationError // Content token changes in the same render as every wall/opening commit, before // React effects get a chance to dispose stale WASM or WebGL resources. - const canonicalRevision = useMemo(() => canonicalRevisionToken(walls, openings, hasCanonicalGeometry), [walls, openings, hasCanonicalGeometry]) + const canonicalRevision = useMemo(() => canonicalRevisionToken(canonicalWalls, canonicalOpenings, hasCanonicalGeometry), [canonicalWalls, canonicalOpenings, hasCanonicalGeometry]) const threeDGeneration = useThreeDGenerationController(canonicalRevision) const { geometryRevision, @@ -353,10 +357,10 @@ export default function App() { copyProjectResumeLink, } = projectSession const wallShellModel = useMemo( - () => buildWallShellModel(walls, doors, windows), - [walls, doors, windows], + () => buildWallShellModel(canonicalWalls, doors, windows), + [canonicalWalls, doors, windows], ) - const wallVoxelModel = useMemo(() => buildWallVoxelModel(walls, doors, windows), [walls, doors, windows]) + const wallVoxelModel = useMemo(() => buildWallVoxelModel(canonicalWalls, doors, windows), [canonicalWalls, doors, windows]) const viewport = chooseViewport(result, effectiveImageSize) const editorScale = canvasScale(editorSize, viewport) @@ -468,7 +472,7 @@ export default function App() { const observer = new ResizeObserver(updateSize) observer.observe(svg) return () => observer.disconnect() - }, []) + }, [activeStep]) useEffect(() => () => { parseRequestRef.current?.controller.abort() @@ -857,7 +861,15 @@ export default function App() { URL.revokeObjectURL(url) if (originalSourceRef.current === file) setOriginalImageSize({ width: image.naturalWidth, height: image.naturalHeight }) } - image.onerror = () => URL.revokeObjectURL(url) + image.onerror = () => { + URL.revokeObjectURL(url) + if (originalSourceRef.current === file) { + setOriginalSource(null) + setError('无法读取该图片。请选择有效的 PNG、JPEG、GIF 或 WebP 户型图。') + setStatus('error') + setOriginalPreviewURL((currentURL) => { if (currentURL) URL.revokeObjectURL(currentURL); return '' }) + } + } image.src = url } } diff --git a/frontend/src/ProductViews.tsx b/frontend/src/ProductViews.tsx index e09194e..dbbfcdc 100644 --- a/frontend/src/ProductViews.tsx +++ b/frontend/src/ProductViews.tsx @@ -101,9 +101,6 @@ export type LinkedWorkspaceProps = TwoDWorkspaceProps & { onBack: () => void } -export function LinkedWorkspace({ editor, preview, inspector, snapshot, previewAvailable, canAdvance, onAdvance, onBack }: LinkedWorkspaceProps) { - if (!previewAvailable) { - return

当前 3D 预览不可用

联动工作台已关闭,请先返回 2D 校正。

- } - return
+export function LinkedWorkspace({ editor, preview, inspector, snapshot, previewAvailable, canAdvance, onAdvance }: LinkedWorkspaceProps) { + return
{previewAvailable ? :
正在更新空间预览;你可以继续校正 2D。
}
} diff --git a/frontend/src/cropFlow.ts b/frontend/src/cropFlow.ts index 9eb90d4..6e22675 100644 --- a/frontend/src/cropFlow.ts +++ b/frontend/src/cropFlow.ts @@ -4,6 +4,7 @@ export type CandidateMode = 'single' | 'composite' | 'uncertain' export type CandidateDetection = { mode: CandidateMode; candidates: CropRect[] } export const MIN_CROP_SIZE = 32 +export const MAX_CROPPED_FILE_BYTES = 10 << 20 export function cropDisplayMetrics(image: { width: number; height: number }): { handleSize: number; hitDistance: number; strokeWidth: number } { const longestSide = Math.max(1, image.width, image.height) @@ -135,7 +136,10 @@ export async function createCroppedFile(source: File, crop: CropRect): Promise((resolve, reject) => canvas.toBlob((result) => result ? resolve(result) : reject(new Error('无法生成裁切图。')), source.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.95)) + const type = source.type === 'image/png' ? 'image/png' : 'image/jpeg' + let blob = await new Promise((resolve, reject) => canvas.toBlob((result) => result ? resolve(result) : reject(new Error('无法生成裁切图。')), type, 0.9)) + if (blob.size > MAX_CROPPED_FILE_BYTES && type !== 'image/png') blob = await new Promise((resolve, reject) => canvas.toBlob((result) => result ? resolve(result) : reject(new Error('无法生成裁切图。')), type, 0.72)) + if (blob.size > MAX_CROPPED_FILE_BYTES) throw new Error('裁切后的图片仍超过 10 MiB,请缩小裁切区域或选择更小的图片。') const extension = blob.type === 'image/png' ? 'png' : 'jpg' return new File([blob], `${source.name.replace(/\.[^.]+$/, '')}-crop.${extension}`, { type: blob.type }) } finally { URL.revokeObjectURL(url) } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6b06aa0..048fe38 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,12 +2,11 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' -import { consumeProjectAccessFragment, storeInitialProjectAccess } from './projectAccess' +import { parseProjectAccessFragment, storeInitialProjectAccess } from './projectAccess' -storeInitialProjectAccess(consumeProjectAccessFragment( - window.location, - (path) => window.history.replaceState(null, '', path), -)) +// Keep the fragment until metadata and source image both load successfully. +// The controller clears it only after it has retained the private capability. +storeInitialProjectAccess(parseProjectAccessFragment(window.location.hash)) createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/productFlow.test.ts b/frontend/src/productFlow.test.ts index 58d72b9..30374ea 100644 --- a/frontend/src/productFlow.test.ts +++ b/frontend/src/productFlow.test.ts @@ -40,7 +40,7 @@ describe('product flow', () => { it('restores only facts proven by a saved canonical project', () => { expect(initialCompletedSteps({ hasCanonicalDocument: false, isSavedProject: false })).toEqual([]) expect(initialCompletedSteps({ hasCanonicalDocument: true, isSavedProject: false })).toEqual([1, 2]) - expect(initialCompletedSteps({ hasCanonicalDocument: true, isSavedProject: true })).toEqual([1, 2, 6]) + expect(initialCompletedSteps({ hasCanonicalDocument: true, isSavedProject: true })).toEqual([1, 2]) expect(canOpenStep(4, context([1, 2, 6]))).toBe(false) }) }) @@ -52,7 +52,7 @@ describe('ProductFlowController', () => { const stale = transitionProductFlow(state, { type: 'complete', step: 4, next: 5 }, { hasDocument: true, hasCanonicalGeometry: true, hasThreeDGeometry: false }) expect(stale).toEqual(state) expect(transitionProductFlow({ activeStep: 3, completed: [1, 2, 3] }, { type: 'complete', step: 4, next: 5 }, { hasDocument: true, hasCanonicalGeometry: true, hasThreeDGeometry: true })).toEqual({ activeStep: 3, completed: [1, 2, 3] }) - expect(transitionProductFlow({ activeStep: 1, completed: [] }, { type: 'reload', completed: [1, 2, 6] }, { hasDocument: true, hasCanonicalGeometry: true, hasThreeDGeometry: false })).toEqual({ activeStep: 1, completed: [1, 2, 6] }) + expect(transitionProductFlow({ activeStep: 1, completed: [] }, { type: 'reload', completed: [1, 2] }, { hasDocument: true, hasCanonicalGeometry: true, hasThreeDGeometry: false })).toEqual({ activeStep: 1, completed: [1, 2] }) }) it('uses the same event admission to disable Step 5 completion when its renderer becomes stale', () => { diff --git a/frontend/src/productFlow.ts b/frontend/src/productFlow.ts index 8e98f58..1597ab3 100644 --- a/frontend/src/productFlow.ts +++ b/frontend/src/productFlow.ts @@ -50,8 +50,9 @@ export function initialCompletedSteps({ hasCanonicalDocument: boolean isSavedProject: boolean }): ProductStep[] { + void isSavedProject if (!hasCanonicalDocument) return [] - return isSavedProject ? [1, 2, 6] : [1, 2] + return [1, 2] } export type ProductFlowEvent = diff --git a/frontend/src/projectAccess.ts b/frontend/src/projectAccess.ts index 409809b..7c987ba 100644 --- a/frontend/src/projectAccess.ts +++ b/frontend/src/projectAccess.ts @@ -42,12 +42,15 @@ export function buildProjectResumeURL(access: ProjectAccess, baseURL: string): s return url.toString() } -export function consumeProjectAccessFragment( - location: FragmentLocation, - replace: (path: string) => void, -): ProjectAccess | null { +export function clearProjectAccessFragment(location: FragmentLocation, replace: (path: string) => void): void { const params = new URLSearchParams(location.hash.startsWith('#') ? location.hash.slice(1) : location.hash) - const access = parseProjectAccessFragment(location.hash) if (params.has('project') || params.has('cap')) replace(`${location.pathname}${location.search}`) +} + +/** Legacy helper retained for callers that intentionally consume a fragment. + * Startup uses parseProjectAccessFragment so a failed load remains retryable. */ +export function consumeProjectAccessFragment(location: FragmentLocation, replace: (path: string) => void): ProjectAccess | null { + const access = parseProjectAccessFragment(location.hash) + clearProjectAccessFragment(location, replace) return access } diff --git a/frontend/src/projectSession.test.ts b/frontend/src/projectSession.test.ts index 6dd55c2..b867f95 100644 --- a/frontend/src/projectSession.test.ts +++ b/frontend/src/projectSession.test.ts @@ -185,6 +185,17 @@ describe('ProjectSession controller', () => { expect(resumed.dependencies.getProject).toHaveBeenCalledTimes(2) }) + it('retains the initial capability after a transient metadata or source-image failure so it can retry', async () => { + const resumed = sessionHarness({ initialAccess: access, dependencies: { getProject: vi.fn().mockRejectedValueOnce(new Error('temporary')).mockResolvedValue(project) } }) + await resumed.controller.loadInitialProject() + await resumed.controller.reloadProject() + expect(resumed.onProjectLoaded).toHaveBeenCalledOnce() + const imageFailure = sessionHarness({ initialAccess: access, dependencies: { fetchSourceImage: vi.fn().mockRejectedValueOnce(new Error('temporary')).mockResolvedValue(new Blob(['png'])) } }) + await imageFailure.controller.loadInitialProject() + await imageFailure.controller.reloadProject() + expect(imageFailure.onProjectLoaded).toHaveBeenCalledOnce() + }) + it('invalidates pending load, create, and update work when project access is cleared', async () => { const pendingLoad = deferred() const load = sessionHarness({ dependencies: { getProject: vi.fn().mockReturnValue(pendingLoad.promise) } }) @@ -204,7 +215,7 @@ describe('ProjectSession controller', () => { expect(create.onProjectSaved).not.toHaveBeenCalled() const writeText = vi.fn().mockResolvedValue(undefined) await create.controller.copyProjectResumeLink('https://homevox.example/workspace', writeText) - expect(writeText).not.toHaveBeenCalled() + expect(writeText).toHaveBeenCalledWith(`https://homevox.example/workspace#project=${project.id}&cap=${capability}`) const pendingUpdate = deferred() const update = sessionHarness({ diff --git a/frontend/src/projectSession.ts b/frontend/src/projectSession.ts index 43e2288..0e5fb1b 100644 --- a/frontend/src/projectSession.ts +++ b/frontend/src/projectSession.ts @@ -1,5 +1,5 @@ import type { ParseResponse } from './floorplanUi' -import { buildProjectResumeURL, consumeInitialProjectAccess, type ProjectAccess } from './projectAccess' +import { buildProjectResumeURL, clearProjectAccessFragment, consumeInitialProjectAccess, type ProjectAccess } from './projectAccess' import { createProject, fetchProjectSourceImage, @@ -135,6 +135,7 @@ export function createProjectSession({ access = nextAccess onState({ currentProject: loaded, projectName: loaded.name, projectMessage: '项目已加载', projectMessageTone: 'success' }) onProjectLoaded(loaded, sourceImage) + if (typeof window !== 'undefined') clearProjectAccessFragment(window.location, (path) => window.history.replaceState(null, '', path)) return true } catch (error) { if (!active.controller.signal.aborted && isCurrent(active)) onState({ projectMessage: `项目加载失败:${error instanceof Error ? error.message : '未知错误'}`, projectMessageTone: 'error' }) @@ -177,8 +178,10 @@ export function createProjectSession({ saved = created.project createdAccess = { id: saved.id, capability: created.capability } } + // A server-confirmed create has issued the only recovery capability. + // Retain it before observing UI cancellation; cancellation only stops UI updates. + if (createdAccess) access = createdAccess if (!isCurrent(active)) return - if (createdAccess) access = createdAccess onState({ currentProject: saved, projectName: saved.name, diff --git a/frontend/src/wallVoxel.ts b/frontend/src/wallVoxel.ts index c8f0ed1..8623941 100644 --- a/frontend/src/wallVoxel.ts +++ b/frontend/src/wallVoxel.ts @@ -45,10 +45,17 @@ export function buildWallVoxelModel(walls: readonly WallSegment[], doors: readon const shell = buildWallShellModel(walls, doors, windows) if (shell.validationError || shell.walls.length === 0) return null - const minX = Math.min(...shell.walls.map((wall) => wall.x - wall.length / 2)) - const maxX = Math.max(...shell.walls.map((wall) => wall.x + wall.length / 2)) - const minZ = Math.min(...shell.walls.map((wall) => wall.z - wall.length / 2)) - const maxZ = Math.max(...shell.walls.map((wall) => wall.z + wall.length / 2)) + // Project each rotated wall box onto world axes. Extending both axes by + // length/2 made sparse 17³ sampling miss short, parallel walls. + const xExtents = shell.walls.map((wall) => { + const cos = Math.abs(Math.cos(wall.rotationY)) + const sin = Math.abs(Math.sin(wall.rotationY)) + return { x: wall.length / 2 * cos + WALL_SHELL_THICKNESS / 2 * sin, z: wall.length / 2 * sin + WALL_SHELL_THICKNESS / 2 * cos } + }) + const minX = Math.min(...shell.walls.map((wall, index) => wall.x - xExtents[index].x)) + const maxX = Math.max(...shell.walls.map((wall, index) => wall.x + xExtents[index].x)) + const minZ = Math.min(...shell.walls.map((wall, index) => wall.z - xExtents[index].z)) + const maxZ = Math.max(...shell.walls.map((wall, index) => wall.z + xExtents[index].z)) const padding = Math.max(WALL_SHELL_THICKNESS, 0.35) const bounds = [ minX - padding, From e78443118b6f9a8e1af25cd4335a912bdc78c710 Mon Sep 17 00:00:00 2001 From: Lux Date: Sat, 8 Aug 2026 22:50:19 +0800 Subject: [PATCH 28/39] fix: close remaining issue 19 QA gaps --- backend/internal/api/project_routes_test.go | 61 +++++++++++++++++++++ backend/internal/api/projects.go | 6 +- backend/internal/api/router.go | 30 +++++++--- backend/internal/api/router_test.go | 47 ++++++++++++++++ backend/internal/config/config_test.go | 10 ++++ frontend/e2e/production.spec.ts | 7 ++- frontend/src/productFlow.ts | 4 +- frontend/src/wallVoxel.test.ts | 19 +++++++ 8 files changed, 173 insertions(+), 11 deletions(-) diff --git a/backend/internal/api/project_routes_test.go b/backend/internal/api/project_routes_test.go index 08808cf..6e3ecea 100644 --- a/backend/internal/api/project_routes_test.go +++ b/backend/internal/api/project_routes_test.go @@ -171,6 +171,14 @@ 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 { @@ -374,6 +382,59 @@ func TestProjectReadWithWrongCapabilityReturnsGenericNotFound(t *testing.T) { } } +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 TestProjectCreateRejectsOversizedMultipartBodyBeforeParsing(t *testing.T) { router := newProjectRouter(newFakeProjectRepo(), newFakeObjectStore()) body := &bytes.Buffer{} diff --git a/backend/internal/api/projects.go b/backend/internal/api/projects.go index 7b0dab3..c3e132c 100644 --- a/backend/internal/api/projects.go +++ b/backend/internal/api/projects.go @@ -129,6 +129,10 @@ 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) { @@ -139,7 +143,7 @@ func registerProjectRoutes(router *gin.Engine, deps projectDependencies) { // 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.ready() { + if !deps.databaseReady() { writeProjectError(c, http.StatusServiceUnavailable, "persistence_unavailable", "project persistence unavailable") return } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index da1e27d..e771eae 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -22,6 +23,7 @@ import ( ) const maxFloorplanUploadBytes = 10 << 20 +const maxMultipartOverheadBytes = 64 << 10 const persistenceStartupTimeout = 5 * time.Second type healthResponse struct { @@ -35,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()) { @@ -50,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) @@ -81,9 +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+1024*1024) + 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 } @@ -159,9 +171,13 @@ func newRouterWithCleanup(cfg config.Config, startupTimeout time.Duration, initi 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+1024*1024) + 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 } diff --git a/backend/internal/api/router_test.go b/backend/internal/api/router_test.go index cb5152a..4add823 100644 --- a/backend/internal/api/router_test.go +++ b/backend/internal/api/router_test.go @@ -163,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{} @@ -458,3 +477,31 @@ func TestAnalyzeFloorplanCandidatesReturnsSourcePixelRectsAndFailsClosed(t *test 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_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/frontend/e2e/production.spec.ts b/frontend/e2e/production.spec.ts index 128b57a..72fee2f 100644 --- a/frontend/e2e/production.spec.ts +++ b/frontend/e2e/production.spec.ts @@ -380,6 +380,11 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod 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') @@ -545,7 +550,7 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod 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: /保存项目,已完成/ })).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 }) diff --git a/frontend/src/productFlow.ts b/frontend/src/productFlow.ts index 1597ab3..2f9049b 100644 --- a/frontend/src/productFlow.ts +++ b/frontend/src/productFlow.ts @@ -41,8 +41,8 @@ export function completeProductStep(completed: readonly ProductStep[], step: Pro return Array.from(new Set([...completed, step])).sort((a, b) => a - b) as ProductStep[] } -/** A reloaded saved canonical document proves import/parsing and explicit save. - * It deliberately does not manufacture 2D, 3D, or linked-review completion. */ +/** A reloaded canonical document proves only import/parsing. Snapshot presence + * never manufactures 2D, 3D, linked-review, or final-save completion. */ export function initialCompletedSteps({ hasCanonicalDocument, isSavedProject, diff --git a/frontend/src/wallVoxel.test.ts b/frontend/src/wallVoxel.test.ts index 538b668..32215e4 100644 --- a/frontend/src/wallVoxel.test.ts +++ b/frontend/src/wallVoxel.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { readFile } from 'node:fs/promises' import { buildWallVoxelModel, WALL_VOXEL_GRID_SIZE } from './wallVoxel' import type { ParsedOpening } from './floorplanUi' @@ -111,4 +112,22 @@ describe('buildWallVoxelModel', () => { [{ id: 'invalid', kind: 'door', wallId: 'wall-a', position: Number.NaN, width: 60 }], )).toBeNull() }) + + it('keeps short parallel and mixed-orientation walls inside tight voxel bounds for real WASM meshing', async () => { + const model = buildWallVoxelModel([ + { id: 'long', x1: 0, y1: 0, x2: 1000, y2: 0 }, + { id: 'short-parallel', x1: 430, y1: 180, x2: 490, y2: 180 }, + { id: 'diagonal', x1: 760, y1: 120, x2: 880, y2: 240 }, + ]) + expect(model).not.toBeNull() + if (!model) throw new Error('expected valid voxel model') + + const module = await import('../../wasm/pkg/homevox_wasm') + module.initSync(await readFile(new URL('../../wasm/pkg/homevox_wasm_bg.wasm', import.meta.url))) + module.init() + const vertices = module.marching_cubes(model.data, ...model.dimensions, model.isoLevel) + expect(vertices).toBeInstanceOf(Float32Array) + expect(vertices.length).toBeGreaterThan(0) + expect(vertices.length % 9).toBe(0) + }) }) From bd70836097e32fe5ad0ce53a97d2e719c6dc3bfb Mon Sep 17 00:00:00 2001 From: Lux Date: Sat, 8 Aug 2026 22:56:09 +0800 Subject: [PATCH 29/39] test: resolve real wasm regression fixture --- frontend/src/wallVoxel.test.ts | 2 +- frontend/tsconfig.app.json | 5 ++++- frontend/vitest.config.ts | 6 ++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/wallVoxel.test.ts b/frontend/src/wallVoxel.test.ts index 32215e4..d5a3dfc 100644 --- a/frontend/src/wallVoxel.test.ts +++ b/frontend/src/wallVoxel.test.ts @@ -122,7 +122,7 @@ describe('buildWallVoxelModel', () => { expect(model).not.toBeNull() if (!model) throw new Error('expected valid voxel model') - const module = await import('../../wasm/pkg/homevox_wasm') + const module = await import('@homevox-wasm') module.initSync(await readFile(new URL('../../wasm/pkg/homevox_wasm_bg.wasm', import.meta.url))) module.init() const vertices = module.marching_cubes(model.data, ...model.dimensions, model.isoLevel) diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index d804fe1..1f19205 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -7,7 +7,10 @@ "types": ["vite/client"], "ignoreDeprecations": "6.0", "baseUrl": ".", - "paths": { "@homevox-e2e": ["src/e2eBridge.disabled.ts"] }, + "paths": { + "@homevox-e2e": ["src/e2eBridge.disabled.ts"], + "@homevox-wasm": ["../wasm/pkg/homevox_wasm"] + }, "allowArbitraryExtensions": true, "skipLibCheck": true, diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index f89d5ce..fbc02c0 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from 'vitest/config' +import { resolve } from 'node:path' export default defineConfig({ + resolve: { + alias: { + '@homevox-wasm': resolve(__dirname, '../wasm/pkg/homevox_wasm.js'), + }, + }, test: { environment: 'node', include: ['src/**/*.test.ts'], From 8eeb554d24bf1a79ab377233292d4b159da6f197 Mon Sep 17 00:00:00 2001 From: Lux Date: Sat, 8 Aug 2026 22:59:37 +0800 Subject: [PATCH 30/39] test: statically load wasm regression fixture --- frontend/src/wallVoxel.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/wallVoxel.test.ts b/frontend/src/wallVoxel.test.ts index d5a3dfc..5cb5dcd 100644 --- a/frontend/src/wallVoxel.test.ts +++ b/frontend/src/wallVoxel.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { readFile } from 'node:fs/promises' +import * as wasmModule from '@homevox-wasm' import { buildWallVoxelModel, WALL_VOXEL_GRID_SIZE } from './wallVoxel' import type { ParsedOpening } from './floorplanUi' @@ -122,10 +123,9 @@ describe('buildWallVoxelModel', () => { expect(model).not.toBeNull() if (!model) throw new Error('expected valid voxel model') - const module = await import('@homevox-wasm') - module.initSync(await readFile(new URL('../../wasm/pkg/homevox_wasm_bg.wasm', import.meta.url))) - module.init() - const vertices = module.marching_cubes(model.data, ...model.dimensions, model.isoLevel) + wasmModule.initSync(await readFile(new URL('../../wasm/pkg/homevox_wasm_bg.wasm', import.meta.url))) + wasmModule.init() + const vertices = wasmModule.marching_cubes(model.data, ...model.dimensions, model.isoLevel) expect(vertices).toBeInstanceOf(Float32Array) expect(vertices.length).toBeGreaterThan(0) expect(vertices.length % 9).toBe(0) From 6a46c1e5f00e9203b8378e430e0cc20948d21f6c Mon Sep 17 00:00:00 2001 From: Lux Date: Sat, 8 Aug 2026 23:03:38 +0800 Subject: [PATCH 31/39] test: keep voxel bounds regression portable --- frontend/src/wallVoxel.test.ts | 17 +++++++---------- frontend/tsconfig.app.json | 5 +---- frontend/vitest.config.ts | 6 ------ 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/frontend/src/wallVoxel.test.ts b/frontend/src/wallVoxel.test.ts index 5cb5dcd..10f861c 100644 --- a/frontend/src/wallVoxel.test.ts +++ b/frontend/src/wallVoxel.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' -import { readFile } from 'node:fs/promises' -import * as wasmModule from '@homevox-wasm' import { buildWallVoxelModel, WALL_VOXEL_GRID_SIZE } from './wallVoxel' import type { ParsedOpening } from './floorplanUi' +import { WALL_SHELL_THICKNESS } from './wallShell' function voxelAt(model: NonNullable>, xIndex: number, yIndex: number, zIndex: number): number { const [nx, ny] = model.dimensions @@ -114,7 +113,7 @@ describe('buildWallVoxelModel', () => { )).toBeNull() }) - it('keeps short parallel and mixed-orientation walls inside tight voxel bounds for real WASM meshing', async () => { + it('keeps short parallel and mixed-orientation walls inside tight voxel bounds', () => { const model = buildWallVoxelModel([ { id: 'long', x1: 0, y1: 0, x2: 1000, y2: 0 }, { id: 'short-parallel', x1: 430, y1: 180, x2: 490, y2: 180 }, @@ -122,12 +121,10 @@ describe('buildWallVoxelModel', () => { ]) expect(model).not.toBeNull() if (!model) throw new Error('expected valid voxel model') - - wasmModule.initSync(await readFile(new URL('../../wasm/pkg/homevox_wasm_bg.wasm', import.meta.url))) - wasmModule.init() - const vertices = wasmModule.marching_cubes(model.data, ...model.dimensions, model.isoLevel) - expect(vertices).toBeInstanceOf(Float32Array) - expect(vertices.length).toBeGreaterThan(0) - expect(vertices.length % 9).toBe(0) + // The Z grid is driven by each rotated wall's projected footprint rather + // than the long wall's full length. A coarser grid than wall thickness can + // miss the short parallel wall before it reaches the real WASM mesher. + expect(model.spacing[2]).toBeLessThan(WALL_SHELL_THICKNESS * 1.2) + expect(Array.from(model.data).some((value) => value > 0)).toBe(true) }) }) diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 1f19205..d804fe1 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -7,10 +7,7 @@ "types": ["vite/client"], "ignoreDeprecations": "6.0", "baseUrl": ".", - "paths": { - "@homevox-e2e": ["src/e2eBridge.disabled.ts"], - "@homevox-wasm": ["../wasm/pkg/homevox_wasm"] - }, + "paths": { "@homevox-e2e": ["src/e2eBridge.disabled.ts"] }, "allowArbitraryExtensions": true, "skipLibCheck": true, diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index fbc02c0..f89d5ce 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,12 +1,6 @@ import { defineConfig } from 'vitest/config' -import { resolve } from 'node:path' export default defineConfig({ - resolve: { - alias: { - '@homevox-wasm': resolve(__dirname, '../wasm/pkg/homevox_wasm.js'), - }, - }, test: { environment: 'node', include: ['src/**/*.test.ts'], From 50c558725956b410b3bad580af2b7536f597de00 Mon Sep 17 00:00:00 2001 From: Lux Date: Sun, 9 Aug 2026 09:45:22 +0800 Subject: [PATCH 32/39] fix: address remaining issue 19 QA gaps --- backend/README.md | 14 +++++ backend/internal/db/postgres.go | 27 ++++++++- .../internal/db/postgres_integration_test.go | 57 ++++++++++++------- frontend/e2e/production.spec.ts | 40 ++++++++++++- frontend/src/App.tsx | 28 ++++++--- frontend/src/SourceImportView.tsx | 8 ++- frontend/src/projectSaveCompletion.test.ts | 14 +++++ frontend/src/projectSaveCompletion.ts | 14 +++++ frontend/src/projectSession.test.ts | 21 ++++++- frontend/src/projectSession.ts | 9 +-- frontend/src/useProjectSession.ts | 4 +- frontend/src/wasmThreeDScene.test.ts | 1 - frontend/src/wasmThreeDScene.ts | 1 - 13 files changed, 195 insertions(+), 43 deletions(-) create mode 100644 frontend/src/projectSaveCompletion.test.ts create mode 100644 frontend/src/projectSaveCompletion.ts 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/internal/db/postgres.go b/backend/internal/db/postgres.go index 84caae5..99b9980 100644 --- a/backend/internal/db/postgres.go +++ b/backend/internal/db/postgres.go @@ -91,6 +91,17 @@ CREATE TABLE IF NOT EXISTS legacy_project_recovery_audit ( 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 ( @@ -207,8 +218,20 @@ func (r *PostgresRepository) RecoverLegacy(ctx context.Context, id, capabilityHa var recovered Project err := r.pool.QueryRow(ctx, ` WITH claimed AS ( - UPDATE projects SET capability_hash = $2 WHERE id = $1 AND capability_hash IS NULL - RETURNING id, name, source_image_key, source_image_content_type, source_image_size, revision, document, created_at, updated_at + UPDATE projects AS project + SET capability_hash = $2 + 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 authorization + WHERE authorization.project_id = project.id + AND 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 ) diff --git a/backend/internal/db/postgres_integration_test.go b/backend/internal/db/postgres_integration_test.go index 81b1a6d..5efb0b4 100644 --- a/backend/internal/db/postgres_integration_test.go +++ b/backend/internal/db/postgres_integration_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "os" - "regexp" "testing" "time" @@ -94,7 +93,7 @@ func TestPostgresRepositoryLifecycle(t *testing.T) { } } -func TestInitializeSchemaRetiresLegacyProjectsAndConstrainsCapabilityHash(t *testing.T) { +func TestInitializeSchemaRecoversAuthorizedD53LegacyProjectWithoutTouchingBearerProjects(t *testing.T) { dsn := mustTestDatabaseURL(t) ctx := context.Background() pool, err := pgxpool.New(ctx, dsn) @@ -103,6 +102,11 @@ func TestInitializeSchemaRetiresLegacyProjectsAndConstrainsCapabilityHash(t *tes } 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, @@ -113,36 +117,47 @@ CREATE TABLE projects ( 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()) + updated_at timestamptz NOT NULL DEFAULT timezone('UTC', now()), + capability_hash text NOT NULL ); -INSERT INTO projects (id, name, source_image_key, source_image_content_type, source_image_size, document) -VALUES ('00000000-0000-4000-8000-000000000099', 'Legacy', 'legacy/source.png', 'image/png', 12, '{}');`); err != nil { - t.Fatalf("create legacy schema: %v", err) +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 legacy schema: %v", err) + t.Fatalf("migrate d53 schema: %v", err) } - if err := repo.InitializeSchema(ctx); err != nil { - t.Fatalf("repeat legacy migration: %v", err) + if _, err := repo.RecoverLegacy(ctx, legacyID, recoveredHash, "operator"); 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") + 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) } - var nullable string - if err := pool.QueryRow(ctx, `SELECT is_nullable FROM information_schema.columns WHERE table_name = 'projects' AND column_name = 'capability_hash'`).Scan(&nullable); err != nil { - t.Fatalf("read capability nullability: %v", err) + if _, err := repo.Get(ctx, legacyID, recoveredHash); err != nil { + t.Fatalf("get recovered project: %v", err) } - if nullable != "NO" { - t.Fatalf("capability_hash nullable = %s, want NO", nullable) + if _, err := repo.RecoverLegacy(ctx, bearerID, recoveredHash, "operator"); err != ErrProjectNotFound { + t.Fatalf("bearer recovery = %v, want ErrProjectNotFound", err) } - var hash string - if err := pool.QueryRow(ctx, `SELECT capability_hash FROM projects WHERE id = '00000000-0000-4000-8000-000000000099'`).Scan(&hash); err != nil { - t.Fatalf("read retired legacy hash: %v", err) + if _, err := repo.Get(ctx, bearerID, bearerHash); err != nil { + t.Fatalf("bearer project was changed: %v", err) } - if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(hash) { - t.Fatalf("legacy hash = %q, want retired random digest", hash) + 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 _, err := pool.Exec(ctx, `INSERT INTO projects (id, capability_hash, name, source_image_key, source_image_content_type, source_image_size, document) VALUES ('00000000-0000-4000-8000-000000000098', 'invalid', 'Invalid', 'invalid/source.png', 'image/png', 12, '{}')`); err == nil { - t.Fatal("invalid capability hash should violate database constraint") + if auditCount != 1 { + t.Fatalf("recovery audit count = %d, want 1", auditCount) } } diff --git a/frontend/e2e/production.spec.ts b/frontend/e2e/production.spec.ts index 72fee2f..00f7e65 100644 --- a/frontend/e2e/production.spec.ts +++ b/frontend/e2e/production.spec.ts @@ -12,7 +12,8 @@ const fixturePNG = Buffer.from( test.use({ baseURL, viewport: { width: 1440, height: 960 } }) type E2EState = { - geometry: { positionCount: number; normalCount: number; finite: boolean; fingerprint: number } + geometry: { positionCount: number; normalCount: number; finite: boolean; fingerprint: number; meshVertexCountByWall: Record } + wasm: { state: string; fallback: string | null } threeD: { canonicalRevision: string | null geometryRevision: string | null @@ -408,9 +409,20 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod 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.goto(`/?e2e=instrument#project=${createdSnapshot.id}&cap=${createdSnapshot.capability}`) + await independentPage.getByRole('button', { name: '重试加载项目' }).click() await expect(independentPage.getByLabel('2D 墙体编辑器')).toBeVisible() const independentProjectRequest = await independentProjectGet const independentSourceRequest = await independentSourceGet @@ -567,6 +579,30 @@ test('runs upload, parse, canonical 2D/3D, save, restart, and reload as one prod await restartedContext.close() }) +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('keeps a selected composite candidate and adjusted crop after parse failure', async ({ page }) => { const visionURL = new URL(baseURL) visionURL.port = '18089' diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dec3cc6..bb4e8fe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,6 +58,7 @@ import { useThreeDGenerationController } from './useThreeDGenerationController' import { canExportCurrentThreeD } from './threeDExport' import { exportCurrentThreeDRevision, type ThreeDExportRevision } from './threeDExportSession' import { canonicalRevisionToken } from './floorplanSession' +import { completesFinalSave } from './projectSaveCompletion' import { parseFailureMessage, parseNetworkFailureMessage, parseRetryFailureMessage } from './parseFeedback' import { e2EWasmLoader, isE2EInstrumentationEnabled, publishE2EState } from '@homevox-e2e' import './App.css' @@ -228,7 +229,7 @@ export default function App() { const [wasmGeometry, setWasmGeometry] = useState(null) const [wasmState, setWasmState] = useState<'idle' | 'loading' | 'active' | 'fallback'>('idle') const [wasmMetrics, setWasmMetrics] = useState(null) - const [, setWasmFallback] = useState(null) + const [wasmFallback, setWasmFallback] = useState(null) const webGLAvailable = useMemo(hasWebGLSupport, []) const exportSequenceRef = useRef(0) @@ -338,8 +339,8 @@ export default function App() { document: durableDocument, geometryValidationError, sourceFile: effectiveSource, - onProjectSaved: () => { - if (activeStep === 6) applyProductTransition({ type: 'complete', step: 6 }, productFlowContext) + onProjectSaved: (_project, intent) => { + if (completesFinalSave(activeStep, canonicalRevision, intent)) applyProductTransition({ type: 'complete', step: 6 }, productFlowContext) }, onProjectLoaded: applyLoadedProject, }) @@ -417,12 +418,25 @@ export default function App() { publishE2EState({ generation: wasmGenerationRef.current, wasmCalls: wasmCallsRef.current, + wasm: { state: wasmState, fallback: wasmFallback }, metrics: wasmMetrics, geometry: { positionCount: positions?.count ?? 0, normalCount: normals?.count ?? 0, finite, fingerprint, + meshVertexCountByWall: positions ? Object.fromEntries(wallShellModel.walls.map((wall) => { + const tolerance = Math.max(...wallVoxelModel?.spacing ?? [0, 0, 0]) + let count = 0 + for (let index = 0; index < positions.array.length; index += 3) { + const dx = positions.array[index] - wall.x + const dz = positions.array[index + 2] - wall.z + const localX = Math.cos(wall.rotationY) * dx - Math.sin(wall.rotationY) * dz + const localZ = Math.sin(wall.rotationY) * dx + Math.cos(wall.rotationY) * dz + if (Math.abs(localX) <= wall.length / 2 + tolerance && Math.abs(localZ) <= wall.thickness / 2 + tolerance) count += 1 + } + return [wall.id, count] + })) : {}, }, threeD: { canonicalRevision, @@ -448,7 +462,7 @@ export default function App() { width: opening.width ?? null, })), }) - }, [canonicalRevision, currentProject, currentThreeDGeneration, frameRevision, geometryRevision, openings, selectedOpeningID, selectedWallID, threeRenderer, walls, wasmGeometry, wasmMetrics, wasmState]) + }, [canonicalRevision, currentProject, currentThreeDGeneration, frameRevision, geometryRevision, openings, selectedOpeningID, selectedWallID, threeRenderer, wallShellModel.walls, wallVoxelModel?.spacing, walls, wasmFallback, wasmGeometry, wasmMetrics, wasmState]) function buildScopeFileName(scope: '2d' | '3d'): string { exportSequenceRef.current += 1 @@ -1197,7 +1211,7 @@ export default function App() { canSave: hasCanonicalGeometry, message: projectMessage, messageTone: projectMessageTone, - onSave: () => { void saveProject() }, + onSave: () => { void saveProject({ stage: 'snapshot', canonicalRevision }) }, onReload: () => { void reloadProject() }, onCopyResumeLink: () => { void copyProjectResumeLink(window.location.href, (value) => navigator.clipboard.writeText(value)) }, } @@ -1237,12 +1251,12 @@ export default function App() { return ( applyProductTransition({ type: 'open', step })} primaryAction={primaryAction}> - {activeStep === 1 && } + {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 && { void saveProject() }} onCopyResumeLink={() => { void copyProjectResumeLink(window.location.href, (value) => navigator.clipboard.writeText(value)) }} />} + {activeStep === 6 && { void saveProject({ stage: 'final', canonicalRevision }) }} onCopyResumeLink={() => { void copyProjectResumeLink(window.location.href, (value) => navigator.clipboard.writeText(value)) }} />} ) } diff --git a/frontend/src/SourceImportView.tsx b/frontend/src/SourceImportView.tsx index a868c38..378c708 100644 --- a/frontend/src/SourceImportView.tsx +++ b/frontend/src/SourceImportView.tsx @@ -27,6 +27,10 @@ function SourcePreview({ selectedFile, previewURL }: SourceFileProps) { export type SourceImportViewProps = SourceFileProps & { status: ParseViewStatus error: string + projectMessage: string + projectMessageTone: 'success' | 'error' + projectBusy: 'save' | 'load' | null + onRetryProjectLoad: () => void } function ImportFacts() { @@ -42,12 +46,13 @@ function ImportFacts() {
} -export function SourceImportView({ selectedFile, previewURL, onFileChange, status, error }: SourceImportViewProps) { +export function SourceImportView({ selectedFile, previewURL, onFileChange, status, error, projectMessage, projectMessageTone, projectBusy, onRetryProjectLoad }: SourceImportViewProps) { if (!selectedFile || !previewURL) { return (

导入真实户型图

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

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

{projectMessage}

}