- 分段尺寸标注: 按重放区域分割点生成蓝色分段+总尺寸双排标注(端部刻度/左侧旋转文字) - 玻璃配置第二行: fl.spet.sp(如 5+12+5)画于编号下方 - 中梃与外框交点白色连接件方块 - 验证: 704 画布与官方封面图逐元素一致, ui-shots 6 页无报错
893 lines
33 KiB
Vue
893 lines
33 KiB
Vue
<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>
|