Files
menchuang/frontend/src/components/WindoorCanvas.vue
T

1230 lines
48 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">格宽 <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">格高 <el-input-number v-model="regH" :min="100" :step="10" size="small"
controls-position="right" style="width:92px" @change="onRegionSize" /></span>
<el-button size="small" type="warning" plain @click="openDec">格条</el-button>
<el-button size="small" type="success" plain @click="openAssign">扇/玻璃</el-button>
</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>
<!-- 装饰格条设计对话框 -->
<el-dialog v-model="decVisible" title="装饰格条设计" width="520px" append-to-body>
<div class="dec-presets">
<div v-for="p in DEC_PRESETS" :key="p.kind" class="dec-preset" :class="{ active: decForm.kind === p.kind }"
@click="applyPreset(p)">
<div class="dec-thumb" :style="thumbStyle(p)">
<div class="dec-thumb-bar" :style="{ background: p.color }" />
</div>
<span>{{ p.label }}</span>
</div>
</div>
<el-form label-width="80px" style="margin-top:10px">
<el-form-item label="横条数"><el-input-number v-model="decForm.rows" :min="0" :max="8" size="small" /></el-form-item>
<el-form-item label="竖条数"><el-input-number v-model="decForm.cols" :min="0" :max="8" size="small" /></el-form-item>
<el-form-item label="条宽 mm"><el-input-number v-model="decForm.bw" :min="8" :max="60" size="small" /></el-form-item>
<el-form-item label="颜色">
<el-radio-group v-model="decForm.color" size="small">
<el-radio-button v-for="c in DEC_COLORS" :key="c.v" :value="c.v">{{ c.label }}</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button v-if="hasDec" type="warning" plain @click="removeDec">清除该格格条</el-button>
<el-button @click="decVisible = false">取消</el-button>
<el-button type="primary" @click="applyDec">应用</el-button>
</template>
</el-dialog>
<!-- 扇/玻璃逐格指定对话框 -->
<el-dialog v-model="assignVisible" title="扇 / 玻璃逐格指定" width="460px" append-to-body>
<el-form label-width="90px" size="small">
<el-form-item label="开启类型">
<el-radio-group v-model="assignForm.sash.kind">
<el-radio-button value="fixed">固定玻璃</el-radio-button>
<el-radio-button value="casement">平开扇</el-radio-button>
<el-radio-button value="awning">上悬</el-radio-button>
<el-radio-button value="sliding">推拉</el-radio-button>
<el-radio-button value="kfc">内开内倒</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="assignForm.sash.kind !== 'fixed'">
<el-form-item label="开启方向">
<el-radio-group v-model="assignForm.sash.openToward">
<el-radio-button value="inward">内开</el-radio-button>
<el-radio-button value="outward">外开</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="铰链侧">
<el-radio-group v-model="assignForm.sash.openDirection" :disabled="assignForm.sash.kind === 'awning'">
<el-radio-button value="left">左侧</el-radio-button>
<el-radio-button value="right">右侧</el-radio-button>
</el-radio-group>
</el-form-item>
</template>
<el-form-item label="副梃">
<el-switch v-model="assignForm.subBar" />
<span class="assign-tip">扇中梃(按长度计价)</span>
</el-form-item>
<el-form-item label="玻璃配置">
<el-select v-model="assignForm.glass" style="width:200px" clearable>
<el-option value="单玻 5mm" /><el-option value="中空 5+12A+5" />
<el-option value="中空 5+18A+5" /><el-option value="三玻两腔 5+9A+5+9A+5" />
<el-option value="夹胶 5+0.76PVB+5" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button v-if="hasAssign" type="warning" plain @click="removeAssign">清除该格指定</el-button>
<el-button @click="assignVisible = false">取消</el-button>
<el-button type="primary" @click="applyAssign">应用</el-button>
</template>
</el-dialog>
</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, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
bboxOf, DEC_PRESETS, inferSashGlyphs, parseDrawing, regionPoints, replayShape, spAxis, spPos,
type DecBar, type DrawingData, type FrameShape, type Pt, type SashAssign, 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)
let hoverRegion: { 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
}
/** 找格在该方向的可动边界梃: 优先 pos1(右/下)侧, 其次 pos0(左/上)侧; 返回 {pos, sign} 或 null */
function findRegionEdge(sps: SplitLine[], axis: 'v' | 'h', bb: { x0: number; y0: number; x1: number; y1: number }) {
const pos1 = axis === 'v' ? bb.x1 : bb.y1
const e1 = sps.find(s => spAxis(s) === axis && Math.abs(spPos(s) - pos1) < 1.5)
if (e1) return { pos: spPos(e1), sign: 1 }
const pos0 = axis === 'v' ? bb.x0 : bb.y0
const e0 = sps.find(s => spAxis(s) === axis && Math.abs(spPos(s) - pos0) < 1.5)
if (e0) return { pos: spPos(e0), sign: -1 }
return null
}
/** 格尺寸编辑: 输入目标宽/高 → 平移边界中梃; 格两边都贴外框时联动等比缩放整窗 */
function onRegionSize() {
if (!selRegion.value || !dd) return
undoStack.push(JSON.stringify(dd))
const { shapeIdx, ri } = selRegion.value
const sps = dd.sm[shapeIdx]?.mm?.sp ?? []
const targetW = Math.max(100, Math.round(regW.value))
const targetH = Math.max(100, Math.round(regH.value))
let changed = false
// 宽
let bb = regionBb(shapeIdx, ri)
if (!bb) { undoStack.pop(); return }
let cur = bb.x1 - bb.x0
if (Math.abs(targetW - cur) >= 1) {
const edge = findRegionEdge(sps, 'v', bb)
if (edge) {
moveGroup(shapeIdx, 'v', edge.pos, (targetW - cur) * edge.sign)
changed = true
} else if (Math.abs(bb.x0 - modelBox.x0) < 1.5 && Math.abs(bb.x1 - modelBox.x1) < 1.5) {
// 格宽=整窗宽(两边界均为外框): 等比缩放整窗
resizeTo(Math.round((modelBox.x1 - modelBox.x0) * targetW / cur), Math.round(modelBox.y1 - modelBox.y0))
changed = true
}
}
// 高(宽调整可能已缩放整窗, 重新取格 bbox)
bb = regionBb(shapeIdx, ri)
if (!bb) { undoStack.pop(); return }
cur = bb.y1 - bb.y0
if (Math.abs(targetH - cur) >= 1) {
const edge = findRegionEdge(sps, 'h', bb)
if (edge) {
moveGroup(shapeIdx, 'h', edge.pos, (targetH - cur) * edge.sign)
changed = true
} else if (Math.abs(bb.y0 - modelBox.y0) < 1.5 && Math.abs(bb.y1 - modelBox.y1) < 1.5) {
resizeTo(Math.round(modelBox.x1 - modelBox.x0), Math.round((modelBox.y1 - modelBox.y0) * targetH / cur))
changed = true
}
}
if (!changed) { undoStack.pop(); syncRegionInputs(); 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()
}
// 1.5) 装饰格条(自研 decs 扩展, 挂在玻璃格上; ri 对齐重放区域索引)
for (const dec of (shape.decs ?? []) as DecBar[]) {
const segs = rp.regions[dec.ri]
if (!segs) continue
const pts = regionPoints(segs)
if (pts.length < 3) continue
const bb = bboxOf(pts)
const px0 = tx(bb.x0), py0 = ty(bb.y0), px1 = tx(bb.x1), py1 = ty(bb.y1)
const gw = px1 - px0, gh = py1 - py0
if (gw < 12 || gh < 12) continue
const barPx = Math.max(1.5, dec.bw * scale)
ctx.save()
ctx.beginPath(); ctx.rect(px0, py0, gw, gh); ctx.clip()
ctx.fillStyle = dec.color
ctx.strokeStyle = 'rgba(0,0,0,0.4)'
ctx.lineWidth = 1
for (let i = 1; i <= dec.rows; i++) {
const y = py0 + (gh * i) / (dec.rows + 1)
ctx.fillRect(px0, y - barPx / 2, gw, barPx)
ctx.strokeRect(px0, y - barPx / 2, gw, barPx)
}
for (let i = 1; i <= dec.cols; i++) {
const x = px0 + (gw * i) / (dec.cols + 1)
ctx.fillRect(x - barPx / 2, py0, barPx, gh)
ctx.strokeRect(x - barPx / 2, py0, barPx, gh)
}
ctx.restore()
}
// 1.6) 逐格指定(自研 assigns): 副梃竖线 + 开扇符号 + 玻璃配置角标
for (const a of (shape.assigns ?? []) as SashAssign[]) {
const segs = rp.regions[a.ri]
if (!segs) continue
const apts = regionPoints(segs)
if (apts.length < 3) continue
const abb = bboxOf(apts)
const ax0 = tx(abb.x0), ay0 = ty(abb.y0), ax1 = tx(abb.x1), ay1 = ty(abb.y1)
const aw = ax1 - ax0, ah = ay1 - ay0
if (aw < 14 || ah < 14) continue
const acx = (ax0 + ax1) / 2, acy = (ay0 + ay1) / 2
if (a.subBar) {
ctx.strokeStyle = STYLE.profileDark
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(acx, ay0 + 2); ctx.lineTo(acx, ay1 - 2); ctx.stroke()
}
if (a.sash) {
ctx.save()
ctx.setLineDash([7, 5])
ctx.strokeStyle = 'rgba(40,60,90,0.75)'
ctx.lineWidth = 1.6
ctx.beginPath()
if (a.sash.kind === 'sliding') {
ctx.moveTo(ax0 + 4, acy); ctx.lineTo(ax1 - 4, acy)
ctx.moveTo(ax1 - 10, acy - 4); ctx.lineTo(ax1 - 4, acy); ctx.lineTo(ax1 - 10, acy + 4)
} else if (a.sash.kind === 'awning') {
ctx.moveTo(ax0 + 4, ay1 - 4); ctx.lineTo(ax1 - 4, ay1 - 4); ctx.lineTo(acx, ay0 + 6)
} else {
const hingeLeft = a.sash.openDirection !== 'right'
if (hingeLeft) { ctx.moveTo(ax0 + 5, ay0 + 5); ctx.lineTo(ax0 + 5, ay1 - 5); ctx.lineTo(ax1 - 6, acy) }
else { ctx.moveTo(ax1 - 5, ay0 + 5); ctx.lineTo(ax1 - 5, ay1 - 5); ctx.lineTo(ax0 + 6, acy) }
}
ctx.closePath()
ctx.stroke()
ctx.restore()
}
if (a.glass) {
ctx.fillStyle = 'rgba(43,108,176,0.85)'
ctx.font = `${Math.max(9, Math.min(12, aw / 8))}px sans-serif`
ctx.fillText(a.glass, ax0 + 4, ay1 - 5)
}
}
// 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'
// 纱扇(Screen): 官方图纸的网格纹理(不生成开合扇)
for (const sub of (shape.sm ?? []) as SashShape[]) {
if (String(sub.type ?? '') !== 'Screen' || sub.enabled === false) continue
const segs = rp.resolveRef?.(sub.polyId?.idx ?? -1, sub.polyId?.pos)
if (!segs) continue
const bb = bboxOf(regionPoints(segs))
const x0 = tx(bb.x0), y0 = ty(bb.y0), x1 = tx(bb.x1), y1 = ty(bb.y1)
ctx.save()
ctx.beginPath()
ctx.rect(x0, y0, x1 - x0, y1 - y0)
ctx.clip()
ctx.strokeStyle = 'rgba(60,66,72,0.32)'
ctx.lineWidth = 0.7
const step = Math.max(5, (x1 - x0) / 22)
for (let gx = x0; gx < x1; gx += step) { ctx.beginPath(); ctx.moveTo(gx, y0); ctx.lineTo(gx, y1); ctx.stroke() }
for (let gy = y0; gy < y1; gy += step) { ctx.beginPath(); ctx.moveTo(x0, gy); ctx.lineTo(x1, gy); ctx.stroke() }
ctx.restore()
}
// 悬停格淡高亮(可编辑提示)
if (props.editable && hoverRegion && hoverRegion.shapeIdx === shapeIdx
&& !(selRegion.value && selRegion.value.shapeIdx === shapeIdx && selRegion.value.ri === hoverRegion.ri)) {
const r = rp.regions[hoverRegion.ri]
if (r) {
ctx.beginPath()
regionPoints(r).forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
ctx.closePath()
ctx.fillStyle = 'rgba(64,158,255,0.14)'
ctx.fill()
}
}
// 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
}
// ---- 装饰格条设计(P0-格条设计器) ----
const DEC_COLORS = [
{ label: '黑', v: '#2b2b2b' }, { label: '白', v: '#ffffff' }, { label: '灰', v: '#9aa0a6' },
{ label: '金', v: '#c9a86a' }, { label: '棕', v: '#6b4a2b' }, { label: '蓝', v: '#3a6ea5' },
]
const decVisible = ref(false)
const decForm = reactive<DecBar>({ ri: 0, kind: 'modern', rows: 1, cols: 1, bw: 25, color: '#2b2b2b' })
const hasDec = ref(false)
function openDec() {
if (!selRegion.value || !dd) return
const { shapeIdx, ri } = selRegion.value
const shape = dd.sm[shapeIdx]
const existing = (shape?.decs ?? []).find(d => d.ri === ri)
hasDec.value = !!existing
Object.assign(decForm, existing ?? { ri, kind: 'modern', rows: 1, cols: 1, bw: 25, color: '#2b2b2b' }, { ri })
decVisible.value = true
}
function applyPreset(p: (typeof DEC_PRESETS)[number]) {
Object.assign(decForm, { kind: p.kind, rows: p.rows, cols: p.cols, bw: p.bw, color: p.color })
}
function thumbStyle(p: (typeof DEC_PRESETS)[number]) {
// 纯 CSS 缩略图: 用线性渐变模拟格条
const rowPct = p.rows ? 100 / (p.rows + 1) : 0
const colPct = p.cols ? 100 / (p.cols + 1) : 0
const horizontal = Array.from({ length: p.rows }, (_, i) => `${p.color} ${rowPct * (i + 1) - 2}% ${rowPct * (i + 1) + 2}%`).join(',')
const vertical = Array.from({ length: p.cols }, (_, i) => `${p.color} ${colPct * (i + 1) - 2}% ${colPct * (i + 1) + 2}%`).join(',')
const base = 'linear-gradient(135deg,#dceef0,#bcd9dc)'
const grad = [base, ...(horizontal ? [`linear-gradient(0deg,transparent,${horizontal},transparent)`] : []),
...(vertical ? [`linear-gradient(90deg,transparent,${vertical},transparent)`] : [])]
return { background: grad.join(',') }
}
function applyDec() {
if (!selRegion.value || !dd) return
const { shapeIdx } = selRegion.value
const shape = dd.sm[shapeIdx]
if (!shape) return
undoStack.push(JSON.stringify(dd))
shape.decs = shape.decs ?? []
const found = shape.decs.find(d => d.ri === decForm.ri)
if (found) Object.assign(found, JSON.parse(JSON.stringify(decForm)))
else shape.decs.push(JSON.parse(JSON.stringify(decForm)))
decVisible.value = false
render()
emit('change')
ElMessage.success('格条已应用')
}
function removeDec() {
if (!selRegion.value || !dd) return
const { shapeIdx, ri } = selRegion.value
const shape = dd.sm[shapeIdx]
if (!shape?.decs) return
undoStack.push(JSON.stringify(dd))
shape.decs = shape.decs.filter(d => d.ri !== ri)
hasDec.value = false
decVisible.value = false
render()
emit('change')
ElMessage.success('格条已清除')
}
// ---- 扇/玻璃逐格指定(P1-逐格编辑) ----
const assignVisible = ref(false)
const hasAssign = ref(false)
const assignForm = reactive<SashAssign>({
ri: 0, sash: { kind: 'fixed', openToward: 'inward', openDirection: 'left' }, subBar: false, glass: '',
})
function openAssign() {
if (!selRegion.value || !dd) return
const { shapeIdx, ri } = selRegion.value
const existing = (dd.sm[shapeIdx]?.assigns ?? []).find(a => a.ri === ri)
hasAssign.value = !!existing
const blank: SashAssign = { ri, sash: { kind: 'fixed', openToward: 'inward', openDirection: 'left' }, subBar: false, glass: '' }
Object.assign(assignForm, JSON.parse(JSON.stringify(existing ?? blank)), { ri })
assignVisible.value = true
}
function applyAssign() {
if (!selRegion.value || !dd) return
const { shapeIdx } = selRegion.value
const shape = dd.sm[shapeIdx]
if (!shape) return
undoStack.push(JSON.stringify(dd))
shape.assigns = shape.assigns ?? []
const found = shape.assigns.find(a => a.ri === assignForm.ri)
const copy = JSON.parse(JSON.stringify(assignForm)) as SashAssign
if (found) Object.assign(found, copy)
else shape.assigns.push(copy)
assignVisible.value = false
render()
emit('change')
ElMessage.success('已指定该格')
}
function removeAssign() {
if (!selRegion.value || !dd) return
const { shapeIdx, ri } = selRegion.value
const shape = dd.sm[shapeIdx]
if (!shape?.assigns) return
undoStack.push(JSON.stringify(dd))
shape.assigns = shape.assigns.filter(a => a.ri !== ri)
hasAssign.value = false
assignVisible.value = false
render()
emit('change')
ElMessage.success('已清除该格指定')
}
defineExpose({ exportDrawing })
// ============ 视图控制 ============
function computeModelBox() {
if (!dd) return
const pts: Pt[] = []
for (const s of dd.sm) {
// 外框只由基底多边形决定; 扇 bd 序列化常退化为单点, 计入会把模型框拉大
for (const r of (s.polygon?.polygon ?? [])) {
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
}
}
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)
const m = hit ? null : hitMullion(sx, sy)
let hover: { shapeIdx: number; ri: number } | null = null
if (!hit && !m) {
const p = toModel(sx, sy)
let best = 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 b = bboxOf(pts)
const area = (b.x1 - b.x0) * (b.y1 - b.y0)
if (area < best) { best = area; hover = { shapeIdx: si, ri } }
}
}
}
cvRef.value!.style.cursor = hit === 'wh' ? 'nwse-resize' : hit === 'w' ? 'ew-resize' : hit === 'h' ? 'ns-resize'
: m ? 'move' : hover ? 'pointer' : 'grab'
const changed = hover?.shapeIdx !== hoverRegion?.shapeIdx || hover?.ri !== hoverRegion?.ri
if (changed) { hoverRegion = hover; render() }
}
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; }
.dec-presets { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.dec-preset { display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: pointer;
padding: 6px; border: 1.5px solid transparent; border-radius: 8px; font-size: 12px; color: #606266; }
.dec-preset:hover { background: #f5f7fa; }
.dec-preset.active { border-color: #409eff; background: #ecf5ff; }
.dec-thumb { width: 100%; height: 44px; border: 1px solid #dcdfe6; border-radius: 4px; overflow: hidden; }
.dec-thumb-bar { width: 100%; height: 100%; }
.assign-tip { color: #909399; font-size: 12px; margin-left: 8px; }
</style>