Files
menchuang/frontend/src/components/WindoorCanvas.vue
T
HHY 6a5a3d1ed3 feat: v2.15 图纸封面图一致性收尾——官方 PNG 剩余元素补齐
- 分段尺寸标注: 按重放区域分割点生成蓝色分段+总尺寸双排标注(端部刻度/左侧旋转文字)
- 玻璃配置第二行: fl.spet.sp(如 5+12+5)画于编号下方
- 中梃与外框交点白色连接件方块
- 验证: 704 画布与官方封面图逐元素一致, ui-shots 6 页无报错
2026-09-23 09:59:31 -07:00

893 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="canvas-wrap" ref="wrapRef">
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset" @click="onClick" />
<div class="hud">
<span v-if="info">{{ info }}</span>
<template v-if="editable">
<span class="dim-input">W <el-input-number v-model="sizeW" :min="300" :step="10" size="small"
controls-position="right" style="width:104px" @change="onSizeInput" /></span>
<span class="dim-input">H <el-input-number v-model="sizeH" :min="300" :step="10" size="small"
controls-position="right" style="width:104px" @change="onSizeInput" /></span>
<template v-if="selRegion">
<span class="dim-input">格W <el-input-number v-model="regW" :min="100" :step="10" size="small"
controls-position="right" style="width:92px" @change="onRegionSize" /></span>
<span class="dim-input">格H <el-input-number v-model="regH" :min="100" :step="10" size="small"
controls-position="right" style="width:92px" @change="onRegionSize" /></span>
</template>
<template v-else-if="selected">
<span class="dim-input">梃位 <el-input-number v-model="mulPos" :min="0" :step="10" size="small"
controls-position="right" style="width:92px" @change="onMullionPos" /></span>
</template>
<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>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 二维窗型画布(自研实现 · 分割树架构)
*
* 数据模型(逆向自原站 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, inferSashGlyphs, 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 }>()
const cvRef = ref<HTMLCanvasElement>()
const wrapRef = ref<HTMLDivElement>()
const info = ref('')
const emit = defineEmits<{ (e: 'change'): void }>()
let view = { scale: 1, cx: 0, cy: 0 }
let drag: { x: number; y: number } | null = null
let dd: DrawingData | null = null
let modelBox = { x0: 0, y0: 0, x1: 100, y1: 100 }
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 } = 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)
// 细节编辑: 选中的玻璃格(可输入格宽高) ; selRegion 与 selected(梃) 互斥
const selRegion = ref<{ shapeIdx: number; ri: number } | null>(null)
const regW = ref(0)
const regH = ref(0)
const mulPos = ref(0)
/** 选中格 bbox */
function regionBb(shapeIdx: number, ri: number) {
const r = replays[shapeIdx]?.regions[ri]
if (!r) return null
const pts = regionPoints(r)
return pts.length >= 3 ? bboxOf(pts) : null
}
function syncRegionInputs() {
if (!selRegion.value) return
const bb = regionBb(selRegion.value.shapeIdx, selRegion.value.ri)
if (bb) { regW.value = Math.round(bb.x1 - bb.x0); regH.value = Math.round(bb.y1 - bb.y0) }
}
function syncMullionPos() {
if (!selected.value || !dd) return
const sp = dd.sm[selected.value.shapeIdx]?.mm?.sp?.[selected.value.spIdx]
if (!sp) return
const axis = spAxis(sp)
mulPos.value = Math.round(spPos(sp) - (axis === 'v' ? modelBox.x0 : modelBox.y0))
}
/** 共线同位组: 同一物理梃被记录为多条共线分段, 移动须成组 */
function mullionGroup(shapeIdx: number, axis: 'v' | 'h', pos: number) {
return (dd?.sm[shapeIdx]?.mm?.sp ?? []).filter(s => spAxis(s) === axis && Math.abs(spPos(s) - pos) < 0.5)
}
/** 组增量移动(返回实际位移): 限制在组内各段裁剪 bbox 内缩 30mm 的交集 */
function moveGroup(shapeIdx: number, axis: 'v' | 'h', pos: number, delta: number): number {
const group = mullionGroup(shapeIdx, axis, pos)
if (!group.length) return 0
let lo = -Infinity, hi = Infinity
for (const s of group) {
const clip = replays[shapeIdx]?.clips.find(c => c.sp === s)
const bb = clip?.bb ?? modelBox
if (axis === 'v') { lo = Math.max(lo, bb.x0 + 30); hi = Math.min(hi, bb.x1 - 30) }
else { lo = Math.max(lo, bb.y0 + 30); hi = Math.min(hi, bb.y1 - 30) }
}
if (lo > hi) return 0
const applied = Math.min(hi, Math.max(lo, pos + delta)) - pos
for (const s of group) {
if (axis === 'v') s.line.pt.x += applied
else s.line.pt.y += applied
}
return applied
}
/** 格尺寸编辑: 输入目标宽/高, 平移该格的边界中梃(优先右/下边界, 其次左/上) */
function onRegionSize() {
if (!selRegion.value || !dd) return
const { shapeIdx, ri } = selRegion.value
const bb = regionBb(shapeIdx, ri)
if (!bb) return
const sps = dd.sm[shapeIdx]?.mm?.sp ?? []
const curW = bb.x1 - bb.x0, curH = bb.y1 - bb.y0
const targetW = Math.max(100, Math.round(regW.value))
const targetH = Math.max(100, Math.round(regH.value))
let moved = false
if (Math.abs(targetW - curW) >= 1) {
const edge = sps.find(s => spAxis(s) === 'v' && Math.abs(spPos(s) - bb.x1) < 1.5)
?? sps.find(s => spAxis(s) === 'v' && Math.abs(spPos(s) - bb.x0) < 1.5)
if (edge) {
const sign = Math.abs(spPos(edge) - bb.x1) < 1.5 ? 1 : -1
moveGroup(shapeIdx, 'v', spPos(edge), (targetW - curW) * sign)
moved = true
}
}
if (Math.abs(targetH - curH) >= 1) {
const edge = sps.find(s => spAxis(s) === 'h' && Math.abs(spPos(s) - bb.y1) < 1.5)
?? sps.find(s => spAxis(s) === 'h' && Math.abs(spPos(s) - bb.y0) < 1.5)
if (edge) {
const sign = Math.abs(spPos(edge) - bb.y1) < 1.5 ? 1 : -1
moveGroup(shapeIdx, 'h', spPos(edge), (targetH - curH) * sign)
moved = true
}
}
if (!moved) { syncRegionInputs(); ElMessage.warning('该格贴外框, 请先加分格梃再调整'); return }
replayOne(shapeIdx)
computeModelBox()
syncSizeInputs()
syncRegionInputs()
render()
emit('change')
}
/** 梃位精确编辑: 距左边缘(竖梃)/距上边缘(横梃)距离 */
function onMullionPos() {
if (!selected.value || !dd) return
const sp = dd.sm[selected.value.shapeIdx]?.mm?.sp?.[selected.value.spIdx]
if (!sp) return
const axis = spAxis(sp)
const target = mulPos.value + (axis === 'v' ? modelBox.x0 : modelBox.y0)
const applied = moveGroup(selected.value.shapeIdx, axis, spPos(sp), target - spPos(sp))
replayOne(selected.value.shapeIdx)
syncMullionPos()
render()
emit('change')
if (Math.abs(applied - (target - spPos(sp))) > 0.5) ElMessage.warning('超出可移动范围')
}
type Replay = ReturnType<typeof replayShape>
let replays: Replay[] = []
// ============ 图纸样式(对齐源站设计图渲染) ============
const STYLE = {
profile: '#8f8fa6', // 型材紫灰主色
profileDark: '#585866', // 型材外轮廓
highlight: '#ffffff', // 型材高光
glass: '#2fb3ab', // 玻璃 teal
glassHi: '#49c6be',
glassDeep: '#1f8b85',
label: '#39414c', // 玻璃编号文字
dim: '#2b6cb0', // 尺寸标注蓝
symbol: '#3a4048', // 开向符号
}
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: (sy - H / 2 - view.cy) / scale + midY }
}
// ============ 分割树重放 ============
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
const sc = (p: Pt) => { p.x *= sx; p.y *= sy }
for (const shape of dd.sm) {
for (const r of (shape.polygon?.polygon ?? [])) {
for (const seg of r) { sc(seg.ps); sc(seg.pe) }
}
for (const s of ((shape.mm ??= { sp: [] }).sp ?? [])) sc(s.line.pt)
for (const sub of (shape.sm ?? []) as SashShape[]) {
if (sub.bd) { sc(sub.bd.from); sc(sub.bd.to) }
}
}
}
function resizeTo(newW: number, newH: number, silent = false) {
if (!dd) return
newW = Math.max(300, Math.round(newW))
newH = Math.max(300, Math.round(newH))
const w = modelBox.x1 - modelBox.x0, h = modelBox.y1 - modelBox.y0
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()
if (!silent) emit('change')
}
function syncSizeInputs() {
sizeW.value = Math.round(modelBox.x1 - modelBox.x0)
sizeH.value = Math.round(modelBox.y1 - modelBox.y0)
}
function onSizeInput() {
if (!dd) return
undoStack.push(JSON.stringify(dd))
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
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
if (nearRight && nearBottom) return 'wh'
if (nearRight) return 'w'
if (nearBottom) return 'h'
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() {
const cv = cvRef.value, wrap = wrapRef.value
if (!cv || !wrap) return
const dpr = window.devicePixelRatio || 1
cv.width = wrap.clientWidth * dpr
cv.height = wrap.clientHeight * dpr
const ctx = cv.getContext('2d')!
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, cv.width, cv.height)
if (!dd) return
const W = wrap.clientWidth, H = wrap.clientHeight
const pad = 48
const bw = modelBox.x1 - modelBox.x0, bh = modelBox.y1 - modelBox.y0
const fit = Math.min((W - pad * 2) / (bw || 1), (H - pad * 2) / (bh || 1))
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
ctx.lineJoin = 'round'
dd.sm.forEach((shape, si) => drawFrame(ctx, shape, replays[si], si, tx, ty, scale))
if (props.editable) {
const x1 = tx(modelBox.x1), y1 = ty(modelBox.y1)
ctx.fillStyle = '#409eff'
ctx.fillRect(x1 - 5, y1 - 5, 10, 10)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 1.5
ctx.strokeRect(x1 - 5, y1 - 5, 10, 10)
}
info.value = dd.sm.length > 1 ? `窗型 · ${dd.sm.length} 个窗体组合` : '窗型'
}
function drawFrame(
ctx: CanvasRenderingContext2D, shape: FrameShape, rp: Replay, shapeIdx: number,
tx: (x: number) => number, ty: (y: number) => number, scale: number,
) {
const regions = rp.regions.map(regionPoints).filter(pts => pts.length >= 3)
// 型材视觉宽度: 60mm 料宽映射像素, 限幅
const lw = Math.max(3, Math.min(10, 60 * scale))
const frameBb = regions.length ? bboxOf(regions.flat()) : null
// 1) 玻璃格: teal 渐变(官方图纸配色)
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))))
ctx.closePath()
const bb = bboxOf(pts)
const grad = ctx.createLinearGradient(tx(bb.x0), ty(bb.y0), tx(bb.x1), ty(bb.y1))
grad.addColorStop(0, STYLE.glassHi)
grad.addColorStop(0.5, STYLE.glass)
grad.addColorStop(1, STYLE.glassDeep)
ctx.fillStyle = grad
ctx.fill()
ctx.strokeStyle = STYLE.profileDark
ctx.lineWidth = 1.2
ctx.stroke()
}
// 2) 中梃(按目标区域裁剪, 型材三段线; 选中高亮) + 与外框交点白块(官方连接件符号)
for (let spIdx = 0; spIdx < rp.clips.length; spIdx++) {
const { sp, bb } = rp.clips[spIdx]
const isSel = selected.value?.shapeIdx === shapeIdx && selected.value.spIdx === spIdx
drawBar(ctx, spAxis(sp), spPos(sp), bb, tx, ty, scale, isSel)
}
if (frameBb) {
const fx0 = Math.round(frameBb.x0), fy0 = Math.round(frameBb.y0)
const fx1 = Math.round(frameBb.x1), fy1 = Math.round(frameBb.y1)
const dot = Math.max(3, lw * 0.5)
ctx.fillStyle = '#f2f2f4'
ctx.strokeStyle = STYLE.profileDark
ctx.lineWidth = 1
for (const { sp, bb } of rp.clips) {
const axis = spAxis(sp), pos = spPos(sp)
const ends = axis === 'v' ? [[pos, bb.y0], [pos, bb.y1]] : [[bb.x0, pos], [bb.x1, pos]]
for (const [ex, ey] of ends) {
const onFrame = (Math.round(ex) === fx0 || Math.round(ex) === fx1) && ey >= fy0 - 2 && ey <= fy1 + 2
const onFrameY = (Math.round(ey) === fy0 || Math.round(ey) === fy1) && ex >= fx0 - 2 && ex <= fx1 + 2
if (!(onFrame || onFrameY)) continue
ctx.beginPath()
ctx.rect(tx(ex) - dot / 2, ty(ey) - dot / 2, dot, dot)
ctx.fill()
ctx.stroke()
}
}
}
// 3) 外框型材 + 四角角码
if (regions.length) {
const bb = bboxOf(regions.flat())
drawProfileRect(ctx, tx(bb.x0), ty(bb.y0), (bb.x1 - bb.x0) * scale, (bb.y1 - bb.y0) * scale, lw)
const cs = Math.max(4, lw * 0.8)
const cx0 = tx(bb.x0), cy0 = ty(bb.y0), cx1 = tx(bb.x1), cy1 = ty(bb.y1)
ctx.fillStyle = '#f2f2f4'
ctx.strokeStyle = STYLE.profileDark
ctx.lineWidth = 1
for (const [ax, ay] of [[cx0, cy0], [cx1, cy0], [cx0, cy1], [cx1, cy1]]) {
ctx.beginPath()
ctx.rect(ax - cs / 2, ay - cs / 2, cs, cs)
ctx.fill()
ctx.stroke()
}
}
// 4) 扇: 扇料框 + 官方开向符号 + 把手
const glyphs = inferSashGlyphs(shape, rp).sashes
const ewAvg = ((shape.fm?.ew ?? [60, 60, 60, 60]).reduce((a, b) => a + b, 0)) / 4
const fw = Math.max(2.5, Math.min(lw, ewAvg * scale))
glyphs.forEach((g, gi) => {
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 < 6 || h < 6) return
// 扇料框(细一档的型材)
drawProfileRect(ctx, bx0, by0, w, h, fw)
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
// 开向符号: 平开=虚线三角(apex 指铰链侧), 内开内倒=对角交叉, 推拉=水平滑行箭头
ctx.strokeStyle = STYLE.symbol
ctx.lineWidth = 1.2
if (g.kind === 'sliding') {
const arrowDir = gi % 2 === 0 ? 1 : -1
const L = Math.min(w, h) * 0.3
ctx.setLineDash([])
ctx.beginPath()
ctx.moveTo(cx - L * arrowDir, cy - h * 0.06)
ctx.lineTo(cx + L * arrowDir, cy - h * 0.06)
ctx.lineTo(cx + (L - 8) * arrowDir, cy - h * 0.06 - 6)
ctx.moveTo(cx + L * arrowDir, cy - h * 0.06)
ctx.lineTo(cx + (L - 8) * arrowDir, cy - h * 0.06 + 6)
ctx.stroke()
} else {
ctx.setLineDash([6, 5])
ctx.beginPath()
const dir = g.openDirection ?? 'left'
if (dir.startsWith('up')) {
ctx.moveTo(bx0, by1); ctx.lineTo(cx, by0); ctx.lineTo(bx1, by1)
} else if (dir.startsWith('right')) {
ctx.moveTo(bx0, by0); ctx.lineTo(bx1, cy); ctx.lineTo(bx0, by1)
} else {
ctx.moveTo(bx1, by0); ctx.lineTo(bx0, cy); ctx.lineTo(bx1, by1)
}
ctx.stroke()
ctx.setLineDash([])
}
// 把手: 铰链对面侧(白条+描边+上下锁点)
const dir = g.openDirection ?? 'left'
const hl = Math.max(8, h * (g.isDoor ? 0.36 : 0.2))
const hw2 = Math.max(3.5, lw * 0.55)
ctx.fillStyle = '#f4f4f6'
ctx.strokeStyle = STYLE.profileDark
ctx.lineWidth = 1
ctx.beginPath()
if (g.kind === 'casement') {
if (dir.startsWith('left')) ctx.roundRect(bx1 - hw2 - 2, cy - hl / 2, hw2, hl, 2)
else if (dir.startsWith('right')) ctx.roundRect(bx0 + 2, cy - hl / 2, hw2, hl, 2)
else ctx.roundRect(cx - hl / 2, by1 - hw2 - 2, hl, hw2, 2)
} else {
ctx.roundRect(cx - hw2 / 2, cy - hl / 2, hw2, hl, 2)
}
ctx.fill()
ctx.stroke()
// 锁点(把手两端小方块)
const dot = Math.max(2.5, hw2 * 0.55)
ctx.fillStyle = '#ffffff'
if (g.kind === 'casement' && !dir.startsWith('up')) {
const dx = dir.startsWith('left') ? bx1 - hw2 / 2 - 2 : bx0 + hw2 / 2 + 2
for (const oy of [cy - hl / 3, cy + hl / 3]) {
ctx.beginPath(); ctx.rect(dx - dot / 2, oy - dot / 2, dot, dot); ctx.fill(); ctx.stroke()
}
}
})
// 5) 玻璃编号: 固定格(frame.mm.fls) + 扇格(sash.mm.fls), 经 polyId 树寻址定位; 次行=玻璃配置(spet.sp)
ctx.fillStyle = STYLE.label
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const drawCellLabel = (text: string, bb: { x0: number; y0: number; x1: number; y1: number }, subText?: string) => {
const fs = Math.max(10, Math.min(16, (bb.x1 - bb.x0) * scale * 0.09))
const cx = tx((bb.x0 + bb.x1) / 2), cy = ty((bb.y0 + bb.y1) / 2)
ctx.font = `bold ${fs}px sans-serif`
ctx.fillText(text, cx, subText ? cy - fs * 0.6 : cy)
if (subText) {
ctx.font = `${Math.max(9, fs * 0.78)}px sans-serif`
ctx.fillText(subText, cx, cy + fs * 0.7)
}
}
for (const sub of (shape.sm ?? []) as SashShape[]) {
if (sub.enabled === false) continue
const text = ((sub.mm as any)?.fls as any)?.fls?.[0]?.ser?.text
if (!text || sub.polyId?.idx == null) continue
const segs = rp.resolveRef?.(sub.polyId.idx, sub.polyId.pos)
if (segs) {
const subText = ((sub.mm as any)?.fls as any)?.fls?.[0]?.spet?.sp || undefined
drawCellLabel(text, bboxOf(regionPoints(segs)), subText)
}
}
for (const fl of ((shape.mm as any)?.fls as any)?.fls ?? []) {
const text = fl?.ser?.text
if (!text || fl.pid?.idx == null) continue
const segs = rp.resolveRef?.(fl.pid.idx, fl.pid.pos)
if (segs) drawCellLabel(text, bboxOf(regionPoints(segs)), fl?.spet?.sp || undefined)
}
ctx.textBaseline = 'alphabetic'
// 6) 选中玻璃格高亮: 蓝虚线框 + 格尺寸
if (selRegion.value && selRegion.value.shapeIdx === shapeIdx) {
const r = rp.regions[selRegion.value.ri]
if (r) {
const bb = bboxOf(regionPoints(r))
const x0 = tx(bb.x0), y0 = ty(bb.y0), x1 = tx(bb.x1), y1 = ty(bb.y1)
ctx.strokeStyle = '#409eff'
ctx.setLineDash([6, 4])
ctx.lineWidth = 2
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0)
ctx.setLineDash([])
ctx.fillStyle = '#409eff'
ctx.font = 'bold 12px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.fillText(`${Math.round(bb.x1 - bb.x0)} × ${Math.round(bb.y1 - bb.y0)} mm`, (x0 + x1) / 2, y0 - 8)
}
}
// 7) 尺寸标注(官方蓝色): 分段(每格宽高) + 总尺寸
if (frameBb) {
const x0 = tx(frameBb.x0), y0 = ty(frameBb.y0), x1 = tx(frameBb.x1), y1 = ty(frameBb.y1)
const xs = new Set<number>(), ys = new Set<number>()
for (const pts of regions) {
const b = bboxOf(pts)
xs.add(Math.round(b.x0)); xs.add(Math.round(b.x1))
ys.add(Math.round(b.y0)); ys.add(Math.round(b.y1))
}
const xsA = [...xs].sort((a, b) => a - b)
const ysA = [...ys].sort((a, b) => a - b)
ctx.strokeStyle = STYLE.dim
ctx.fillStyle = STYLE.dim
ctx.lineWidth = 1
ctx.font = '10px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const tick = (x: number, y: number, vertical: boolean) => {
ctx.beginPath()
if (vertical) { ctx.moveTo(x - 3, y); ctx.lineTo(x + 3, y) }
else { ctx.moveTo(x, y - 3); ctx.lineTo(x, y + 3) }
ctx.stroke()
}
// 底部: 分段宽 + 总宽
const segY = y1 + 14
ctx.beginPath()
for (let i = 0; i < xsA.length - 1; i++) {
const a = tx(xsA[i]), b = tx(xsA[i + 1])
if (b - a < 16) continue
ctx.moveTo(a, segY); ctx.lineTo(b, segY)
tick(a, segY, false); tick(b, segY, false)
ctx.fillText(String(xsA[i + 1] - xsA[i]), (a + b) / 2, segY - 8)
}
ctx.stroke()
const dimY = segY + (xsA.length > 2 ? 14 : 0)
ctx.beginPath(); ctx.moveTo(x0, dimY); ctx.lineTo(x1, dimY)
tick(x0, dimY, false); tick(x1, dimY, false)
ctx.stroke()
ctx.fillText(`${Math.round(frameBb.x1 - frameBb.x0)}`, (x0 + x1) / 2, dimY - 8)
// 左侧: 分段高 + 总高(文字旋转)
const segX = x0 - 14
ctx.beginPath()
for (let i = 0; i < ysA.length - 1; i++) {
const a = ty(ysA[i]), b = ty(ysA[i + 1])
if (b - a < 16) continue
ctx.moveTo(segX, a); ctx.lineTo(segX, b)
tick(segX, a, true); tick(segX, b, true)
ctx.save()
ctx.translate(segX - 8, (a + b) / 2)
ctx.rotate(-Math.PI / 2)
ctx.fillText(String(ysA[i + 1] - ysA[i]), 0, 0)
ctx.restore()
}
ctx.stroke()
const dimX = segX - (ysA.length > 2 ? 14 : 0)
ctx.beginPath(); ctx.moveTo(dimX, y0); ctx.lineTo(dimX, y1)
tick(dimX, y0, true); tick(dimX, y1, true)
ctx.stroke()
ctx.save()
ctx.translate(dimX - 8, (y0 + y1) / 2)
ctx.rotate(-Math.PI / 2)
ctx.fillText(`${Math.round(frameBb.y1 - frameBb.y0)}`, 0, 0)
ctx.restore()
ctx.textAlign = 'left'
ctx.textBaseline = 'alphabetic'
}
}
/** 型材矩形: 深色轮廓 + 紫灰主体 + 白色高光(立体型材感) */
function drawProfileRect(
ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, lw: number,
) {
ctx.save()
ctx.lineCap = 'square'
ctx.strokeStyle = STYLE.profileDark
ctx.lineWidth = lw + 1.6
ctx.strokeRect(x, y, w, h)
ctx.strokeStyle = STYLE.profile
ctx.lineWidth = lw
ctx.strokeRect(x, y, w, h)
ctx.strokeStyle = STYLE.highlight
ctx.lineWidth = Math.max(1, lw * 0.16)
const o = lw * 0.3
ctx.strokeRect(x + o, y + o, w - o * 2, h - o * 2)
ctx.restore()
}
/** 画一根中梃(按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, scale: number, isSelected: boolean,
) {
const lw = Math.max(3, Math.min(10, 60 * scale))
const x0 = tx(bb.x0), y0 = ty(bb.y0), x1 = tx(bb.x1), y1 = ty(bb.y1)
ctx.save()
ctx.lineCap = 'butt'
const seg = (style: string, width: number, inset: number) => {
ctx.strokeStyle = style
ctx.lineWidth = width
ctx.beginPath()
if (axis === 'v') { const px = tx(pos); ctx.moveTo(px, y0 + inset); ctx.lineTo(px, y1 - inset) }
else { const py = ty(pos); ctx.moveTo(x0 + inset, py); ctx.lineTo(x1 - inset, py) }
ctx.stroke()
}
seg(STYLE.profileDark, lw + 1.6, -0.8)
seg(STYLE.profile, lw, 0)
seg(STYLE.highlight, Math.max(1, lw * 0.16), lw * 0.3)
if (isSelected) seg('#409eff', lw + 2, -1)
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) {
selRegion.value = null
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: rp.leafRefs[ri] ?? { 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
if (m) {
selRegion.value = null
syncMullionPos()
render()
return
}
const p = toModel(sx, sy)
let hit: { shapeIdx: number; ri: number } | null = null
let hitArea = Infinity
for (let si = 0; si < replays.length; si++) {
const regions = replays[si].regions
for (let ri = 0; ri < regions.length; ri++) {
const pts = regionPoints(regions[ri])
if (pts.length < 3 || !pointInPoly(p, pts)) continue
const bb = bboxOf(pts)
const area = (bb.x1 - bb.x0) * (bb.y1 - bb.y0)
if (area < hitArea) { hit = { shapeIdx: si, ri }; hitArea = area }
}
}
selRegion.value = hit
if (hit) syncRegionInputs()
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
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = [poly[i].x, poly[i].y]
const [xj, yj] = [poly[j].x, poly[j].y]
if ((yi > p.y) !== (yj > p.y) && p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi) inside = !inside
}
return inside
}
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
selRegion.value = null
rebuildReplays()
computeModelBox()
syncSizeInputs()
render()
emit('change')
}
function exportDrawing(): string | null {
return dd ? JSON.stringify(dd) : null
}
defineExpose({ exportDrawing })
// ============ 视图控制 ============
function computeModelBox() {
if (!dd) return
const pts: Pt[] = []
for (const s of dd.sm) {
for (const r of (s.polygon?.polygon ?? [])) {
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
}
for (const sub of (s.sm ?? []) as SashShape[]) {
if (sub.bd) { pts.push(sub.bd.from, sub.bd.to) }
}
}
if (!pts.length) { modelBox = { x0: 0, y0: 0, x1: 1000, y1: 1000 }; return }
modelBox = bboxOf(pts)
}
function reset() {
view = { scale: 1, cx: 0, cy: 0 }
render()
}
function onWheel(e: WheelEvent) {
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12
view.scale = Math.min(40, Math.max(0.2, view.scale * factor))
render()
}
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(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 }
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) {
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 target = Math.min(hi, Math.max(lo, moveDrag.startPos + delta))
// 组移动: 先归位到 startPos 再统一平移(幂等), 共线多段同步
const cur = spPos(sp)
for (const s of mullionGroup(moveDrag.shapeIdx, axis, cur)) {
if (axis === 'v') s.line.pt.x = moveDrag.startPos
else s.line.pt.y = moveDrag.startPos
}
moveGroup(moveDrag.shapeIdx, axis, moveDrag.startPos, target - moveDrag.startPos)
replayOne(moveDrag.shapeIdx)
render()
}
return
}
// 光标提示
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
view.cy += e.clientY - drag.y
drag = { x: e.clientX, y: e.clientY }
render()
}
function onUp() {
if (resizeDrag) {
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()
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
}
onMounted(() => {
watch(() => props.drawingData, () => {
try {
dd = props.drawingData ? parseDrawing(props.drawingData) : null
undoStack.length = 0
selected.value = null
rebuildReplays()
computeModelBox()
syncSizeInputs()
reset()
} catch {
info.value = '数据解析失败'
dd = null
render()
}
}, { immediate: true })
window.addEventListener('resize', render)
})
</script>
<style scoped>
.canvas-wrap { position: relative; width: 100%; height: 100%; min-height: 320px;
background: repeating-conic-gradient(#f7f8f9 0% 25%, #eef0f2 0% 50%) 50%/24px 24px; }
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; flex-wrap: wrap; }
.dim-input { display: inline-flex; align-items: center; gap: 4px; color: #409eff; font-weight: 600; }
</style>