feat: v0.6 分割树架构——横梃竖梃完整编辑

- 逆向确认原站设计思路: 基底多边形+mm.sp分割树(polyId指向作用区域),
  运行时重放得到最终窗格(254套样本验证重放语义)
- 前端 replayShape: 分割树重放还原真实网格(平开1矩形→4-7格)
- 中梃编辑: 点击选中高亮/拖动移动/删除(直接操作sp源真值后重放)
- 加梃升级: polyId.idx 精确指向被点窗格
- Python报价算料同语义重放: 中梃长度精确到实际作用跨度
- 方案文档§3.3: 原站设计思路+编辑器完成设计
This commit is contained in:
HHY
2026-09-20 11:40:21 -07:00
parent 6ec30c198f
commit 78563267c4
6 changed files with 463 additions and 162 deletions
+221 -139
View File
@@ -11,6 +11,7 @@
controls-position="right" style="width:104px" @change="onSizeInput" /></span>
<el-button size="small" :type="tool === 'v' ? 'primary' : ''" @click="arm('v')">+竖梃</el-button>
<el-button size="small" :type="tool === 'h' ? 'primary' : ''" @click="arm('h')">+横梃</el-button>
<el-button size="small" :type="selected ? 'danger' : ''" :disabled="!selected" @click="deleteSelected">删除梃</el-button>
<el-button size="small" :disabled="!undoStack.length" @click="undo">撤销</el-button>
</template>
<el-button size="small" text @click="reset">重置视图</el-button>
@@ -20,18 +21,24 @@
<script setup lang="ts">
/**
* 二维窗型画布(自研实现)
* 查看:渲染玻璃区域/外框/扇开向/尺寸标注;滚轮缩放、拖拽平移、双击复位
* 编辑(editable):
* - 尺寸:W/H 输入框 或 拖拽外框右缘/下缘/右下角手柄 → 全部几何按比例缩放(型材料宽不变)
* - 分格:+竖梃/+横梃 → 点击玻璃区域完成分割(两次交叉点法),支持撤销
* - exportDrawing() 取回修改后的 drawing_data;change 事件通知父组件
* 二维窗型画布(自研实现 · 分割树架构)
*
* 数据模型(逆向自原站 shapeManager 序列化格式):
* polygon.polygon = 基底多边形; mm.sp[] = 分割树(polyId.idx 指向作用区域);
* 运行时按序重放 sp 得到最终窗格; 子形状(Sash/DoubleSash)经 polyId 挂到窗格上。
*
* 查看: 玻璃区域/中梃(按目标区域裁剪)/扇开向/尺寸标注; 滚轮缩放、拖拽平移、双击复位
* 编辑(editable):
* - 尺寸: W/H 输入框或拖拽外框右缘/下缘/右下角手柄 → 全部几何按比例缩放(料宽不变)
* - 加梃: +竖梃/+横梃 → 点击玻璃区域, 追加 sp(指向被点区域)并重放
* - 改梃: 直接点击中梃选中(高亮) → 拖动移动(line.pt 更新后重放) → 删除梃或 Del 键删除
* - 撤销栈; exportDrawing() 取回 drawing_data; change 事件通知父组件
*/
import { onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
bboxOf, parseDrawing, regionPoints,
type DrawingData, type FrameShape, type Pt, type Segment, type SashShape,
bboxOf, parseDrawing, regionPoints, replayShape, spAxis, spPos,
type DrawingData, type FrameShape, type Pt, type Segment, type SashShape, type SplitLine,
} from '../types/drawing'
const props = defineProps<{ drawingData: string | object; editable?: boolean }>()
@@ -48,9 +55,13 @@ const undoStack: string[] = []
const tool = ref<'' | 'v' | 'h'>('')
const sizeW = ref(0)
const sizeH = ref(0)
let resizeDrag: null | { axis: 'w' | 'h' | 'wh'; startW: number; startH: number; startX: number; startY: number; snapshot: string } = null
let resizeDrag: null | { axis: 'w' | 'h' | 'wh'; startW: number; startH: number; startX: number; startY: number } = null
let moveDrag: null | { shapeIdx: number; spIdx: number; startPos: number; startX: number; startY: number; snapshot: string } = null
const selected = ref<{ shapeIdx: number; spIdx: number } | null>(null)
type Replay = { regions: Segment[][]; clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] }
let replays: Replay[] = []
// 几何转换缓存(render 时更新,命中测试用)
let lastTransform = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
function toModel(sx: number, sy: number): Pt {
@@ -58,7 +69,18 @@ function toModel(sx: number, sy: number): Pt {
return { x: (sx - W / 2 - view.cx) / scale + midX, y: (H / 2 + view.cy - sy) / scale + midY }
}
// ============ 参数化尺寸缩放:全部几何按比例缩放,型材料宽(ew)不变 ============
// ============ 分割树重放 ============
function rebuildReplays() {
replays = dd ? dd.sm.map(s => replayShape(s)) : []
}
function replayOne(shapeIdx: number) {
if (!dd) return
replays[shapeIdx] = replayShape(dd.sm[shapeIdx])
}
// ============ 参数化尺寸缩放: 几何按比例缩放, 料宽(ew)不变 ============
function scaleAll(sx: number, sy: number) {
if (!dd) return
@@ -82,6 +104,7 @@ function resizeTo(newW: number, newH: number, silent = false) {
if (w < 10 || h < 10) return
if (Math.abs(newW - w) < 0.5 && Math.abs(newH - h) < 0.5) { syncSizeInputs(); return }
scaleAll(newW / w, newH / h)
rebuildReplays()
computeModelBox()
syncSizeInputs()
render()
@@ -99,7 +122,6 @@ function onSizeInput() {
resizeTo(sizeW.value, sizeH.value)
}
/** 命中测试:屏幕点是否落在整体外框右缘/下缘/右下角的拖拽缩放带 */
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
@@ -113,6 +135,34 @@ function hitResizeHandle(sx: number, sy: number): 'w' | 'h' | 'wh' | null {
return 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
let best: { shapeIdx: number; spIdx: number; d: number } | null = null
replays.forEach((rp, shapeIdx) => {
rp.clips.forEach(({ sp, bb }, spIdx) => {
const axis = spAxis(sp)
const pos = spPos(sp)
const p1 = axis === 'v' ? { x: pos, y: bb.y0 } : { x: bb.x0, y: pos }
const p2 = axis === 'v' ? { x: pos, y: bb.y1 } : { x: bb.x1, y: pos }
const ax = tx(p1.x), ay = ty(p1.y), bx = tx(p2.x), by = ty(p2.y)
const vx = bx - ax, vy = by - ay
const len2 = vx * vx + vy * vy
if (len2 < 4) return
let t = ((sx - ax) * vx + (sy - ay) * vy) / len2
t = Math.max(0, Math.min(1, t))
const dx = sx - (ax + t * vx), dy = sy - (ay + t * vy)
const d = Math.sqrt(dx * dx + dy * dy)
if (d < 7 && (!best || d < best.d)) best = { shapeIdx, spIdx, d }
})
})
if (!best) return null
const b = best as { shapeIdx: number; spIdx: number; d: number }
return { shapeIdx: b.shapeIdx, spIdx: b.spIdx }
}
// ============ 渲染 ============
function render() {
@@ -140,9 +190,8 @@ function render() {
const profile = '#5c6166'
ctx.lineJoin = 'round'
for (const shape of dd.sm) drawFrame(ctx, shape, tx, ty, { glassAlpha, profile })
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)
ctx.fillStyle = '#409eff'
@@ -150,29 +199,20 @@ function render() {
ctx.strokeStyle = '#fff'
ctx.lineWidth = 1.5
ctx.strokeRect(x1 - 5, y1 - 5, 10, 10)
ctx.setLineDash([4, 4])
ctx.strokeStyle = 'rgba(64,158,255,0.55)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x1 + 6, y1); ctx.lineTo(x1 + 28, y1)
ctx.moveTo(x1, y1 + 6); ctx.lineTo(x1, y1 + 28)
ctx.stroke()
ctx.setLineDash([])
}
info.value = dd.sm.length > 1 ? `窗型 · ${dd.sm.length} 个窗体组合` : '窗型'
}
function drawFrame(
ctx: CanvasRenderingContext2D, shape: FrameShape,
ctx: CanvasRenderingContext2D, shape: FrameShape, rp: Replay, shapeIdx: number,
tx: (x: number) => number, ty: (y: number) => number,
color: { glassAlpha: string; profile: string },
) {
const regionsRaw = shape.polygon?.polygon ?? []
const regions = regionsRaw.map(regionPoints).filter(pts => pts.length >= 3)
const regions = rp.regions.map(regionPoints).filter(pts => pts.length >= 3)
const cm = shape.cm ?? {}
// 玻璃区域
// 玻璃区域(重放结果)
for (const pts of regions) {
ctx.beginPath()
pts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
@@ -180,13 +220,16 @@ function drawFrame(
ctx.fillStyle = cm.g ?? color.glassAlpha
ctx.fill()
ctx.strokeStyle = cm.bn ?? color.profile
ctx.lineWidth = 1.6
ctx.stroke()
ctx.strokeStyle = 'rgba(255,255,255,0.65)'
ctx.lineWidth = 0.8
ctx.lineWidth = 1.4
ctx.stroke()
}
// 中梃(每条按目标区域 bbox 裁剪后绘制, 双线示意型材; 选中高亮)
rp.clips.forEach(({ sp, bb }, spIdx) => {
const isSel = selected.value?.shapeIdx === shapeIdx && selected.value.spIdx === spIdx
drawBar(ctx, spAxis(sp), spPos(sp), bb, tx, ty, isSel, color.profile)
})
// 外框
if (regions.length) {
const bb = bboxOf(regions.flat())
@@ -202,8 +245,7 @@ function drawFrame(
ctx.strokeRect(x0 + inset, y0 + inset, (x1 - x0) - inset * 2, (y1 - y0) - inset * 2)
}
// ---- 扇样式推断(真实分格还原)----
// 优先级: 有效bd > polyId映射区域(双扇再对半) > 帧bbox按扇数竖向均分
// 扇(推断: 有效bd → polyId区域(双扇对半) → 帧bbox按扇数均分)
const regionBBs = regions.map(pts => bboxOf(pts))
type Glyph = { x0: number; y0: number; x1: number; y1: number; od: number; openToward?: string; isDoor?: boolean }
const glyphs: Glyph[] = []
@@ -214,8 +256,7 @@ function drawFrame(
}
return !bd.hidden && r.x1 - r.x0 > 10 && r.y1 - r.y0 > 10 ? r : null
}
const pushGlyphs = (r: { x0: number; y0: number; x1: number; y1: number },
entries: any[], sub: SashShape) => {
const pushGlyphs = (r: { x0: number; y0: number; x1: number; y1: number }, entries: any[], sub: SashShape) => {
for (const e of entries) {
glyphs.push({
x0: r.x0, y0: r.y0, x1: r.x1, y1: r.y1,
@@ -248,7 +289,6 @@ function drawFrame(
unmatched.push({ sub, entries })
}
}
// 兜底:帧 bbox 按剩余扇数竖向均分
if (unmatched.length && regions.length) {
const fb = bboxOf(regions.flat())
const K = unmatched.reduce((s, u) => s + u.entries.length, 0)
@@ -266,7 +306,6 @@ function drawFrame(
}
}
}
// 绘制扇:虚线内框 + 开向三角(inward 空心 / outward 实心)
for (const g of glyphs) {
const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y1), by1 = ty(g.y0)
const w = bx1 - bx0, h = by1 - by0
@@ -303,7 +342,7 @@ function drawFrame(
ctx.restore()
}
// 尺寸标注(宽/高,随几何自动更新)
// 尺寸标注
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)
@@ -326,7 +365,97 @@ function drawFrame(
}
}
// ============ 编辑:区域分割(两次交叉点法) ============
/** 画一根中梃(按bbox裁剪的双线) */
function drawBar(
ctx: CanvasRenderingContext2D, axis: 'v' | 'h', pos: number,
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)
ctx.save()
if (axis === 'v') {
const px = tx(pos)
if (px < x0 - 1 || px > x1 + 1) { ctx.restore(); return }
ctx.strokeStyle = isSelected ? '#409eff' : profile
ctx.lineWidth = 3
ctx.beginPath(); ctx.moveTo(px, y0); ctx.lineTo(px, y1); ctx.stroke()
ctx.strokeStyle = 'rgba(255,255,255,0.55)'
ctx.lineWidth = 1
ctx.beginPath(); ctx.moveTo(px, y0); ctx.lineTo(px, y1); ctx.stroke()
} else {
const py = ty(pos)
if (py < y0 - 1 || py > y1 + 1) { ctx.restore(); return }
ctx.strokeStyle = isSelected ? '#409eff' : profile
ctx.lineWidth = 3
ctx.beginPath(); ctx.moveTo(x0, py); ctx.lineTo(x1, py); ctx.stroke()
ctx.strokeStyle = 'rgba(255,255,255,0.55)'
ctx.lineWidth = 1
ctx.beginPath(); ctx.moveTo(x0, py); ctx.lineTo(x1, py); ctx.stroke()
}
ctx.restore()
}
// ============ 编辑操作 ============
function onClick(e: MouseEvent) {
if (!props.editable || !dd) return
const rect = cvRef.value!.getBoundingClientRect()
const sx = e.clientX - rect.left, sy = e.clientY - rect.top
// 加梃模式
if (tool.value) {
const click = toModel(sx, sy)
undoStack.push(JSON.stringify(dd))
const axis = tool.value
for (let si = 0; si < dd.sm.length; si++) {
const rp = replays[si]
for (let ri = 0; ri < rp.regions.length; ri++) {
const pts = regionPoints(rp.regions[ri])
if (!pointInPoly(click, pts)) continue
const shape = dd.sm[si]
const pos = axis === 'v' ? click.x : click.y
const bb = bboxOf(pts)
const centroid = { x: (bb.x0 + bb.x1) / 2, y: (bb.y0 + bb.y1) / 2 }
const mid: Pt = axis === 'v' ? { x: pos, y: centroid.y } : { x: centroid.x, y: pos }
const norm = axis === 'v' ? { x: 1, y: 0 } : { x: 0, y: -1 }
const mm = (shape.mm ??= { sp: [] })
mm.sp = mm.sp ?? []
mm.sp.push({
line: { pt: mid, norm, name: 'line' },
polyId: { idx: ri, pos: rp.regions.length },
alignType: 0, equalSplit: 0, kw: axis === 'v', ws: false, reinforced: false, db: false,
} as any)
replayOne(si)
selected.value = null
render()
emit('change')
return
}
}
ElMessage.warning('请点击玻璃区域')
return
}
// 选择模式: 点中梃选中/取消
const m = hitMullion(sx, sy)
selected.value = m
render()
}
function deleteSelected() {
if (!selected.value || !dd) return
undoStack.push(JSON.stringify(dd))
const { shapeIdx, spIdx } = selected.value
const sp = dd.sm[shapeIdx]?.mm?.sp?.[spIdx]
if (sp) {
dd.sm[shapeIdx]!.mm!.sp!.splice(spIdx, 1)
replayOne(shapeIdx)
selected.value = null
computeModelBox()
syncSizeInputs()
render()
emit('change')
ElMessage.success('已删除中梃')
}
}
function pointInPoly(p: Pt, poly: Pt[]): boolean {
let inside = false
@@ -338,93 +467,13 @@ function pointInPoly(p: Pt, poly: Pt[]): boolean {
return inside
}
function splitPoly(region: Segment[], axis: 'v' | 'h', pos: number):
{ a: Segment[]; b: Segment[] } | null {
const pts = regionPoints(region)
const n = pts.length
const get = (p: Pt) => (axis === 'v' ? p.x : p.y)
const crossings: { i: number; p: Pt }[] = []
for (let i = 0; i < n; i++) {
const a = pts[i], b = pts[(i + 1) % n]
const da = get(a) - pos, db = get(b) - pos
if ((da < 0 && db > 0) || (da > 0 && db < 0)) {
const t = da / (da - db)
crossings.push({
i,
p: axis === 'v'
? { x: pos, y: a.y + (b.y - a.y) * t }
: { x: a.x + (b.x - a.x) * t, y: pos },
})
}
}
if (crossings.length !== 2) return null
const [c1, c2] = crossings
if (c1.i === c2.i) return null
const collect = (from: typeof c1, to: typeof c2): Pt[] => {
const ps: Pt[] = [from.p]
for (let k = (from.i + 1) % n; ; k = (k + 1) % n) {
ps.push(pts[k])
if (k === to.i) break
}
ps.push(to.p)
return ps.filter((p, i) => i === 0 || Math.abs(p.x - ps[i - 1].x) > 0.01 || Math.abs(p.y - ps[i - 1].y) > 0.01)
}
const toSegs = (ps: Pt[]): Segment[] => {
const segs: Segment[] = []
for (let i = 0; i < ps.length; i++) segs.push(mkSeg(ps[i], ps[(i + 1) % ps.length]))
return segs
}
const polyA = collect(c1, c2)
const polyB = collect(c2, c1)
if (polyA.length < 3 || polyB.length < 3) return null
return { a: toSegs(polyA), b: toSegs(polyB) }
}
function mkSeg(a: Pt, b: Pt): Segment { return { ps: a, pe: b, name: 'segment' } }
function onClick(e: MouseEvent) {
if (!props.editable || !tool.value || !dd) return
const rect = cvRef.value!.getBoundingClientRect()
const click = toModel(e.clientX - rect.left, e.clientY - rect.top)
undoStack.push(JSON.stringify(dd))
const axis = tool.value
for (const shape of dd.sm) {
const polyWrap = shape.polygon
const regions = polyWrap?.polygon ?? []
for (let ri = 0; ri < regions.length; ri++) {
const pts = regionPoints(regions[ri])
if (!pointInPoly(click, pts)) continue
const pos = axis === 'v' ? click.x : click.y
const split = splitPoly(regions[ri], axis, pos)
if (!split) { undoStack.pop(); ElMessage.warning('分割位置太靠近边缘'); return }
const newRegions = [split.a, split.b]
regions.splice(ri, 1, ...newRegions)
// 追加分割线记录(与 shapeManager 的 mm.sp 同构)
const centroid = pts.reduce((s, p) => ({ x: s.x + p.x / pts.length, y: s.y + p.y / pts.length }), { x: 0, y: 0 })
const mid: Pt = axis === 'v' ? { x: pos, y: centroid.y } : { x: centroid.x, y: pos }
const norm = axis === 'v' ? { x: 1, y: 0 } : { x: 0, y: -1 }
const mm = (shape.mm ??= { sp: [] })
mm.sp = mm.sp ?? []
mm.sp.push({
line: { pt: mid, norm, name: 'line' },
polyId: { idx: ri, pos: -1 },
alignType: 0, equalSplit: 0, kw: axis === 'v', ws: false, reinforced: false, db: false,
} as any)
computeModelBox()
syncSizeInputs()
render()
emit('change')
return
}
}
}
function arm(t: 'v' | 'h') { tool.value = tool.value === t ? '' : t; info.value = tool.value ? '点击玻璃区域完成分割' : '' }
function arm(t: 'v' | 'h') { tool.value = tool.value === t ? '' : t; info.value = tool.value ? '点击玻璃区域完成加梃' : '' }
function undo() {
if (!undoStack.length || !dd) return
dd = JSON.parse(undoStack.pop()!)
selected.value = null
rebuildReplays()
computeModelBox()
syncSizeInputs()
render()
@@ -436,7 +485,7 @@ function exportDrawing(): string | null {
}
defineExpose({ exportDrawing })
// ============ 视图控制与交互 ============
// ============ 视图控制 ============
function computeModelBox() {
if (!dd) return
@@ -466,34 +515,58 @@ function onWheel(e: WheelEvent) {
function onDown(e: MouseEvent) {
const rect = cvRef.value!.getBoundingClientRect()
const sx = e.clientX - rect.left, sy = e.clientY - rect.top
if (props.editable && !tool.value) {
const hit = hitResizeHandle(e.clientX - rect.left, e.clientY - rect.top)
const hit = hitResizeHandle(sx, sy)
if (hit) {
undoStack.push(JSON.stringify(dd))
resizeDrag = { axis: hit, startW: modelBox.x1 - modelBox.x0, startH: modelBox.y1 - modelBox.y0,
startX: e.clientX, startY: e.clientY, snapshot: '' }
startX: e.clientX, startY: e.clientY }
return
}
const m = hitMullion(sx, sy)
if (m) {
// 开始拖动中梃
const sp = dd?.sm[m.shapeIdx]?.mm?.sp?.[m.spIdx]
if (sp) {
undoStack.push(JSON.stringify(dd))
moveDrag = { shapeIdx: m.shapeIdx, spIdx: m.spIdx, startPos: spPos(sp), startX: e.clientX, startY: e.clientY, snapshot: '' }
selected.value = m
return
}
}
selected.value = null
}
drag = { x: e.clientX, y: e.clientY }
}
function onMove(e: MouseEvent) {
// 拖拽缩放
if (resizeDrag) {
const dpx = e.clientX - resizeDrag.startX
const dpy = e.clientY - resizeDrag.startY
const { scale } = lastTransform
const dw = resizeDrag.axis === 'h' ? 0 : dpx / scale
const dh = resizeDrag.axis === 'w' ? 0 : -dpy / scale
resizeTo(resizeDrag.startW + dw, resizeDrag.startH + dh, true) // 拖拽中静默,松手后统一提交
const rect = cvRef.value?.getBoundingClientRect()
// 拖动中梃
if (moveDrag && dd) {
const sp = dd.sm[moveDrag.shapeIdx]?.mm?.sp?.[moveDrag.spIdx]
const rp = replays[moveDrag.shapeIdx]
if (sp && rp) {
const clip = rp.clips.find(c => c.sp === sp)
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 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))
if (axis === 'v') sp.line.pt.x = newPos; else sp.line.pt.y = newPos
replayOne(moveDrag.shapeIdx)
render()
}
return
}
// 光标提示
if (props.editable && !tool.value && cvRef.value) {
const rect = cvRef.value.getBoundingClientRect()
const hit = hitResizeHandle(e.clientX - rect.left, e.clientY - rect.top)
cvRef.value.style.cursor = hit === 'wh' ? 'nwse-resize' : hit === 'w' ? 'ew-resize' : hit === 'h' ? 'ns-resize' : 'grab'
if (props.editable && !tool.value && rect) {
const sx = e.clientX - rect.left, sy = e.clientY - rect.top
const hit = hitResizeHandle(sx, sy)
cvRef.value!.style.cursor = hit === 'wh' ? 'nwse-resize' : hit === 'w' ? 'ew-resize' : hit === 'h' ? 'ns-resize'
: hitMullion(sx, sy) ? 'move' : 'grab'
}
if (!drag) return
view.cx += e.clientX - drag.x
@@ -504,14 +577,21 @@ function onMove(e: MouseEvent) {
function onUp() {
if (resizeDrag) {
// 拖拽过程中 resizeTo 走 silent;松手时统一判定是否真变了
const changed = Math.abs((modelBox.x1 - modelBox.x0) - resizeDrag.startW) > 0.5 ||
Math.abs((modelBox.y1 - modelBox.y0) - resizeDrag.startH) > 0.5
if (!changed) undoStack.pop() // 尺寸未变,撤销点作废
if (!changed) undoStack.pop()
resizeDrag = null
if (changed) emit('change')
return
}
if (moveDrag) {
const sp = dd?.sm[moveDrag.shapeIdx]?.mm?.sp?.[moveDrag.spIdx]
const changed = sp && Math.abs(spPos(sp) - moveDrag.startPos) > 0.5
if (!changed) undoStack.pop()
else { replayOne(moveDrag.shapeIdx); render(); emit('change') }
moveDrag = null
return
}
drag = null
}
@@ -520,6 +600,8 @@ onMounted(() => {
try {
dd = props.drawingData ? parseDrawing(props.drawingData) : null
undoStack.length = 0
selected.value = null
rebuildReplays()
computeModelBox()
syncSizeInputs()
reset()
@@ -539,6 +621,6 @@ onMounted(() => {
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
canvas:active { cursor: grabbing; }
.hud { position: absolute; right: 10px; top: 10px; display: flex; gap: 8px; align-items: center;
background: rgba(255,255,255,0.92); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; }
background: rgba(255,255,255,0.92); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; flex-wrap: wrap; }
.dim-input { display: inline-flex; align-items: center; gap: 4px; color: #409eff; font-weight: 600; }
</style>