fix: v2.12 全部 2D 画布上下颠倒修复(组合/编辑/效果图/AI变体)

- 根因: 四画布 ty 按 y 向上渲染, 而模型坐标 y 向下(v2.11 实证) → 样板内部布局颠倒
- 统一去翻转: ty 直接映射 + toModel 反演同步 + 顶底序归位
- WindoorCanvas: 尺寸手柄(右下角)/中梃竖向拖动 dy 符号/扇符号与标注位置同步修正
- 验证: 组合页 2D 与 3D 方向一致, 设计图纸 704 布局=官方平面图, ui-shots 6 页无报错
This commit is contained in:
HHY
2026-09-23 04:23:04 -07:00
parent c2c6359a65
commit c780618a17
11 changed files with 48 additions and 23 deletions
+21
View File
@@ -0,0 +1,21 @@
// 2D 画布方向验证: 打开样板库点首卡片标题 → 画布对话框载入 704, 截 2D 画布对照官方参考图(亮子应在顶部): node verify-2d.mjs <token>
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()
+5 -4
View File
@@ -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
+4 -4
View File
@@ -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)
}
/** 颜色明度调整(+亮/-暗) */
+11 -10
View File
@@ -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))
+5 -5
View File
@@ -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') {