diff --git a/CHANGELOG b/CHANGELOG index 45fdb47..8c542d7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-23 05:10] FIX: v2.12 全部 2D 画布上下颠倒修复(组合/编辑/效果图/AI变体)。根因: 四画布(WindoorCanvas/CombineCanvas/RenderCanvas/variantRenderer)的 ty 映射按 y 向上渲染, 而 v2.11 已实证模型坐标 y 向下(屏幕系) → 样板内部布局整体颠倒(31337 亮子被画到底部而 3D 在顶部, 用户报"方向反了"); 统一去翻转: ty 改直接映射+toModel 反演同步+ty(y1)/ty(y0) 顶底序全部归位, WindoorCanvas 同步修正尺寸手柄位置(右下角)/中梃竖向拖动 dy 符号/扇符号与尺寸标注位置; 验证: 组合页 2D 与 3D 方向一致(704 亮子均在上), 设计图纸画布 704 布局=官方平面图(左上亮子+左下扇+右整列), ui-shots 6 页无报错 (Files: frontend/src/components/CombineCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/components/RenderCanvas.vue, frontend/src/components/variantRenderer.ts, frontend/scripts/verify-2d.mjs, shots/*) + - [2026-09-23 04:45] FIX: v2.11 3D 全开/全关状态错误根治——分割树重放语义破译与扇五金解析重建(官方参考图逐样本实证)。①polyId 语义破译: {idx,pos}=(分裂子树节点id,子区域序), 节点id=sp 0基序号(节点0=根), child0=法线指向侧, 同一中梃跨区域记录多条, 分割线锚点=line.pt(所辖梃段中点)作定位兜底; replayShape 重写为树重放+regions/leafRefs/resolveRef 返回; 704(左下平开扇)/31594(7格双纱扇)/31729(上滑扇) 全部引用逐项精确命中官方平面图, 全量 798 扇矩形 100% 有效(validate3d 0 无效几何); ②坐标系修正: 模型坐标 y 向下(屏幕系), 3D 构建翻转 y, 布局上下关系与原图一致; ③扇开合五金解析: kind 由类型判定(Slide/DoubleSash=推拉), 铰链侧=hm.openDirection(left/right/up=上悬), 内外开=hm.openToward, 全开幅=od(90°, 平开扇垂直于墙面), 替换原 cos(od) 猜铰链侧与 32/40° 写死开幅; ④移除"滑扇附着上半区"兜底(v2.7 在错误重放上得出, 实为固定扇被误驱动开合), 2D 编辑器加梃写回 polyId 同步树寻址(leafRefs); ⑤验证: 真实浏览器截图(全关=官方图纸布局/46% 绕左铰链外开/100% 全开垂直)+ui-shots 6 页无报错 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorThreed.vue, frontend/src/components/WindoorCanvas.vue, frontend/scripts/validate3d.cjs, frontend/scripts/analyze-split-tree.mjs, frontend/scripts/analyze-split-order.mjs, shots/*) - [2026-09-23 03:26] FIX: v2.10 3D 预览"窗户嵌入墙体内"双重根因修复。①室外墙体定位: 原墙体(3×型深厚)与窗体同在 z=0 居中, 整窗埋入墙厚内部(洞口内只见深处玻璃); 改为墙厚 2.4×型深(≈240 实墙比例)且外墙面退至窗框外表面之后(窗框外露 30% 型深, 对齐外平开窗贴墙安装), 窗洞净空由固定 0.05 改为自适应外框面宽 fwMax+0.015 安装缝(防宽面型材边缘被墙面遮挡); ②平开扇铰链偏移符号修复: 扇子元素为面板中心局部坐标, 绕铰链边旋转应平移 +(cxr-hingeX)(原负号致面板整体多偏移一个扇宽——关闭态扇面板跑出框外右侧、开启态扇悬空脱离窗框, 实测样板704 偏移恰为扇宽 1137mm 与截图吻合); ③草地垫对齐新墙面、网格降至墙脚消除穿墙线; 真实浏览器三态截图验证(45°全关/正面全关/46%开合: 框体凸出外墙、扇全在框内、开扇贴框外旋✓) (Files: frontend/src/components/WindoorThreed.vue, frontend/scripts/verify-wall.mjs, shots/verify-wall-*.png) diff --git a/frontend/scripts/verify-2d.mjs b/frontend/scripts/verify-2d.mjs new file mode 100644 index 0000000..6b4f994 --- /dev/null +++ b/frontend/scripts/verify-2d.mjs @@ -0,0 +1,21 @@ +// 2D 画布方向验证: 打开样板库点首卡片标题 → 画布对话框载入 704, 截 2D 画布对照官方参考图(亮子应在顶部): node verify-2d.mjs +import puppeteer from 'puppeteer-core' +const browser = await puppeteer.launch({ + executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', + headless: 'new', + args: ['--window-size=1440,900', '--enable-unsafe-swiftshader'], + defaultViewport: { width: 1440, height: 900 }, +}) +const page = await browser.newPage() +page.on('pageerror', e => console.log(`[JS异常] ${String(e).slice(0, 200)}`)) +const token = process.argv[2] +await page.goto(`http://localhost:8000/templates?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' }) +await new Promise(r => setTimeout(r, 1500)) +// 点击第一个样板的标题(避开官方图预览) +const cap = await page.$('.sample-card .cap, .card .cap, .cap') +await cap.click() +await new Promise(r => setTimeout(r, 1800)) +const box = await (await page.$('.canvas-box')).boundingBox() +await page.screenshot({ path: 'D:/Code/huamenchuang/shots/verify-2d-canvas.png', clip: box }) +console.log('✓ 设计图纸 2D 画布截图完成') +await browser.close() diff --git a/frontend/src/components/CombineCanvas.vue b/frontend/src/components/CombineCanvas.vue index 7c54b89..c7ed59b 100644 --- a/frontend/src/components/CombineCanvas.vue +++ b/frontend/src/components/CombineCanvas.vue @@ -54,7 +54,7 @@ let guides: { v: number[]; h: number[] } = { v: [], h: [] } function toModel(sx: number, sy: number): Pt { const { scale, midX, midY, W, H } = lastT - return { x: (sx - W / 2 - view.cx) / scale + midX, y: (H / 2 + view.cy - sy) / scale + midY } + return { x: (sx - W / 2 - view.cx) / scale + midX, y: (sy - H / 2 - view.cy) / scale + midY } } // ---------- 几何 ---------- @@ -202,7 +202,8 @@ function render() { const midX = (gb.x0 + gb.x1) / 2, midY = (gb.y0 + gb.y1) / 2 lastT = { scale, midX, midY, W, H } const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx - const ty = (y: number) => H / 2 - (y - midY) * scale + view.cy + // 模型坐标 y 向下(屏幕系): 直接映射, 不做上下翻转 + const ty = (y: number) => H / 2 + (y - midY) * scale + view.cy ctx.lineJoin = 'round' for (const shape of props.shapes) { @@ -225,7 +226,7 @@ function render() { const bb = bboxOf(rs.flat()) ctx.strokeStyle = sel ? '#409eff' : '#5c6166' ctx.lineWidth = 3 - ctx.strokeRect(tx(bb.x0), ty(bb.y1), (bb.x1 - bb.x0) * scale, (bb.y1 - bb.y0) * scale) + ctx.strokeRect(tx(bb.x0), ty(bb.y0), (bb.x1 - bb.x0) * scale, (bb.y1 - bb.y0) * scale) } // 中梃(按重放区域裁剪近似:画在其基底 bbox 内) const rb = rs.length ? bboxOf(rs.flat()) : null @@ -254,7 +255,7 @@ function render() { if (selectedUid.value !== null) { const bb = unitBBox(selectedUid.value) if (bb) { - const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y1), y1 = ty(bb.y0) + const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y0), y1 = ty(bb.y1) ctx.strokeStyle = '#409eff' ctx.setLineDash([6, 4]) ctx.lineWidth = 1.5 diff --git a/frontend/src/components/RenderCanvas.vue b/frontend/src/components/RenderCanvas.vue index 1160759..8d8f3ae 100644 --- a/frontend/src/components/RenderCanvas.vue +++ b/frontend/src/components/RenderCanvas.vue @@ -35,7 +35,7 @@ function render() { const scale = Math.min((W - pad * 2) / gw, (H - pad * 2) / gh) const ox = (W - gw * scale) / 2, oy = (H - gh * scale) / 2 const tx = (x: number) => ox + (x - gb.x0) * scale - const ty = (y: number) => oy + (gb.y1 - y) * scale + const ty = (y: number) => oy + (y - gb.y0) * scale const tint = props.tint ?? '#cfe3ee' // 外墙投影 @@ -44,7 +44,7 @@ function render() { ctx.shadowBlur = 26 ctx.shadowOffsetY = 10 ctx.fillStyle = '#fdfdfc' - ctx.fillRect(tx(gb.x0) - 14, ty(gb.y1) - 14, gw * scale + 28, gh * scale + 28) + ctx.fillRect(tx(gb.x0) - 14, ty(gb.y0) - 14, gw * scale + 28, gh * scale + 28) ctx.restore() // 窗格玻璃:渐变 + 高光 @@ -52,7 +52,7 @@ function render() { for (const region of replayShape(shape).regions) { const rpts = regionPoints(region) const bb = bboxOf(rpts) - const gx = tx(bb.x0), gy = ty(bb.y1), gw2 = (bb.x1 - bb.x0) * scale, gh2 = (bb.y1 - bb.y0) * scale + const gx = tx(bb.x0), gy = ty(bb.y0), gw2 = (bb.x1 - bb.x0) * scale, gh2 = (bb.y1 - bb.y0) * scale const grad = ctx.createLinearGradient(gx, gy, gx + gw2, gy + gh2) grad.addColorStop(0, tint) grad.addColorStop(0.45, shade(tint, 18)) @@ -89,7 +89,7 @@ function render() { const outer = bboxOf(pts) ctx.strokeStyle = '#4a4e52' ctx.lineWidth = 5 - ctx.strokeRect(tx(outer.x0) - 4, ty(outer.y1) - 4, (outer.x1 - outer.x0) * scale + 8, (outer.y1 - outer.y0) * scale + 8) + ctx.strokeRect(tx(outer.x0) - 4, ty(outer.y0) - 4, (outer.x1 - outer.x0) * scale + 8, (outer.y1 - outer.y0) * scale + 8) } /** 颜色明度调整(+亮/-暗) */ diff --git a/frontend/src/components/WindoorCanvas.vue b/frontend/src/components/WindoorCanvas.vue index 69720b7..ab8b2f7 100644 --- a/frontend/src/components/WindoorCanvas.vue +++ b/frontend/src/components/WindoorCanvas.vue @@ -66,7 +66,7 @@ let lastTransform = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 } function toModel(sx: number, sy: number): Pt { const { scale, midX, midY, W, H } = lastTransform - return { x: (sx - W / 2 - view.cx) / scale + midX, y: (H / 2 + view.cy - sy) / scale + midY } + return { x: (sx - W / 2 - view.cx) / scale + midX, y: (sy - H / 2 - view.cy) / scale + midY } } // ============ 分割树重放 ============ @@ -125,7 +125,7 @@ function onSizeInput() { function hitResizeHandle(sx: number, sy: number): 'w' | 'h' | 'wh' | null { const { scale, midX, midY, W, H } = lastTransform const x1 = W / 2 + (modelBox.x1 - midX) * scale + view.cx - const y1 = H / 2 - (modelBox.y0 - midY) * scale + view.cy + const y1 = H / 2 + (modelBox.y1 - midY) * scale + view.cy const TOL = 8 const nearRight = Math.abs(sx - x1) < TOL && sy <= y1 + TOL const nearBottom = Math.abs(sy - y1) < TOL && sx <= x1 + TOL @@ -139,7 +139,7 @@ function hitResizeHandle(sx: number, sy: number): 'w' | 'h' | 'wh' | null { function hitMullion(sx: number, sy: number): { shapeIdx: number; spIdx: number } | null { const { scale, midX, midY, W, H } = lastTransform const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx - const ty = (y: number) => H / 2 - (y - midY) * scale + view.cy + const ty = (y: number) => H / 2 + (y - midY) * scale + view.cy let best: { shapeIdx: number; spIdx: number; d: number } | null = null replays.forEach((rp, shapeIdx) => { rp.clips.forEach(({ sp, bb }, spIdx) => { @@ -183,8 +183,9 @@ function render() { const scale = fit * view.scale const midX = (modelBox.x0 + modelBox.x1) / 2, midY = (modelBox.y0 + modelBox.y1) / 2 lastTransform = { scale, midX, midY, W, H } + // 模型坐标 y 向下(屏幕系): 直接映射, 不做上下翻转 const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx - const ty = (y: number) => H / 2 - (y - midY) * scale + view.cy + const ty = (y: number) => H / 2 + (y - midY) * scale + view.cy const glassAlpha = 'rgba(211,221,218,0.6)' const profile = '#5c6166' @@ -193,7 +194,7 @@ function render() { dd.sm.forEach((shape, si) => drawFrame(ctx, shape, replays[si], si, tx, ty, { glassAlpha, profile })) if (props.editable) { - const x1 = tx(modelBox.x1), y1 = ty(modelBox.y0) + const x1 = tx(modelBox.x1), y1 = ty(modelBox.y1) ctx.fillStyle = '#409eff' ctx.fillRect(x1 - 5, y1 - 5, 10, 10) ctx.strokeStyle = '#fff' @@ -233,7 +234,7 @@ function drawFrame( // 外框 if (regions.length) { const bb = bboxOf(regions.flat()) - const x0 = tx(bb.x0), y0 = ty(bb.y1), x1 = tx(bb.x1), y1 = ty(bb.y0) + const x0 = tx(bb.x0), y0 = ty(bb.y0), x1 = tx(bb.x1), y1 = ty(bb.y1) const ew = shape.fm?.ew ?? [60, 60, 60, 60] const avg = ew.reduce((a, b) => a + b, 0) / ew.length ctx.strokeStyle = cm.bn ?? color.profile @@ -248,7 +249,7 @@ function drawFrame( // 扇(推断: 有效bd → polyId挂接区域 → 帧bbox按扇数均分) — 共享推断模块 const glyphs = inferSashGlyphs(shape, rp).sashes for (const g of glyphs) { - const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y1), by1 = ty(g.y0) + const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y0), by1 = ty(g.y1) const w = bx1 - bx0, h = by1 - by0 if (w < 4 || h < 4) continue ctx.save() @@ -296,7 +297,7 @@ function drawFrame( // 尺寸标注 if (regions.length) { const bb = bboxOf(regions.flat()) - const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y1), y1 = ty(bb.y0) + const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y0), y1 = ty(bb.y1) const wmm = Math.round(bb.x1 - bb.x0), hmm = Math.round(bb.y1 - bb.y0) ctx.fillStyle = '#7a8087' ctx.font = '11px sans-serif' @@ -322,7 +323,7 @@ function drawBar( bb: { x0: number; y0: number; x1: number; y1: number }, tx: (x: number) => number, ty: (y: number) => number, isSelected: boolean, profile: string, ) { - const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y1), y1 = ty(bb.y0) + const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y0), y1 = ty(bb.y1) ctx.save() if (axis === 'v') { const px = tx(pos) @@ -502,7 +503,7 @@ function onMove(e: MouseEvent) { const bb = clip?.bb ?? modelBox const { scale } = lastTransform const axis = spAxis(sp) - const delta = axis === 'v' ? (e.clientX - moveDrag.startX) / scale : -(e.clientY - moveDrag.startY) / scale + const delta = axis === 'v' ? (e.clientX - moveDrag.startX) / scale : (e.clientY - moveDrag.startY) / scale const lo = (axis === 'v' ? bb.x0 : bb.y0) + 30 const hi = (axis === 'v' ? bb.x1 : bb.y1) - 30 const newPos = Math.min(hi, Math.max(lo, moveDrag.startPos + delta)) diff --git a/frontend/src/components/variantRenderer.ts b/frontend/src/components/variantRenderer.ts index 76f79a9..c3982cb 100644 --- a/frontend/src/components/variantRenderer.ts +++ b/frontend/src/components/variantRenderer.ts @@ -89,14 +89,14 @@ export function renderAI( const ox = (W - gw * scale) / 2 const oy = floorY - gh * scale - H * 0.12 const tx = (x: number) => ox + (x - gb.x0) * scale - const ty = (y: number) => oy + (gb.y1 - y) * scale + const ty = (y: number) => oy + (y - gb.y0) * scale // 窗洞 ctx.save() ctx.shadowColor = 'rgba(0,0,0,0.4)' ctx.shadowBlur = 30 ctx.fillStyle = time === 'night' ? '#16233a' : sky[1] - ctx.fillRect(tx(gb.x0) - 10, ty(gb.y1) - 10, gw * scale + 20, gh * scale + 20) + ctx.fillRect(tx(gb.x0) - 10, ty(gb.y0) - 10, gw * scale + 20, gh * scale + 20) ctx.restore() // 玻璃 @@ -104,7 +104,7 @@ export function renderAI( for (const region of replayShape(shape).regions) { const rpts = regionPoints(region) const bb = bboxOf(rpts) - const gx = tx(bb.x0), gy = ty(bb.y1), w = (bb.x1 - bb.x0) * scale, h = (bb.y1 - bb.y0) * scale + const gx = tx(bb.x0), gy = ty(bb.y0), w = (bb.x1 - bb.x0) * scale, h = (bb.y1 - bb.y0) * scale const g = ctx.createLinearGradient(gx, gy, gx, gy + h) g.addColorStop(0, sky[0]); g.addColorStop(0.55, sky[1]); g.addColorStop(1, sky[2]) ctx.save() @@ -150,10 +150,10 @@ export function renderAI( // 外框 ctx.strokeStyle = pal.frame ctx.lineWidth = 7 - ctx.strokeRect(tx(gb.x0) - 4, ty(gb.y1) - 4, gw * scale + 8, gh * scale + 8) + ctx.strokeRect(tx(gb.x0) - 4, ty(gb.y0) - 4, gw * scale + 8, gh * scale + 8) ctx.strokeStyle = 'rgba(255,255,255,0.35)' ctx.lineWidth = 1.4 - ctx.strokeRect(tx(gb.x0) - 1, ty(gb.y1) - 1, gw * scale + 2, gh * scale + 2) + ctx.strokeRect(tx(gb.x0) - 1, ty(gb.y0) - 1, gw * scale + 2, gh * scale + 2) // 地板光束 if (time !== 'dusk') { diff --git a/shots/01-templates.png b/shots/01-templates.png index bb22641..b4f5fdb 100644 Binary files a/shots/01-templates.png and b/shots/01-templates.png differ diff --git a/shots/02-combine-two.png b/shots/02-combine-two.png index 1550d43..6eb3055 100644 Binary files a/shots/02-combine-two.png and b/shots/02-combine-two.png differ diff --git a/shots/03-combine-selected.png b/shots/03-combine-selected.png index e83c9c8..d1b2c9a 100644 Binary files a/shots/03-combine-selected.png and b/shots/03-combine-selected.png differ diff --git a/shots/04-combine-3d.png b/shots/04-combine-3d.png index e83c9c8..d1b2c9a 100644 Binary files a/shots/04-combine-3d.png and b/shots/04-combine-3d.png differ diff --git a/shots/verify-2d-canvas.png b/shots/verify-2d-canvas.png new file mode 100644 index 0000000..8412f89 Binary files /dev/null and b/shots/verify-2d-canvas.png differ