- 根因: 四画布 ty 按 y 向上渲染, 而模型坐标 y 向下(v2.11 实证) → 样板内部布局颠倒 - 统一去翻转: ty 直接映射 + toModel 反演同步 + 顶底序归位 - WindoorCanvas: 尺寸手柄(右下角)/中梃竖向拖动 dy 符号/扇符号与标注位置同步修正 - 验证: 组合页 2D 与 3D 方向一致, 设计图纸 704 布局=官方平面图, ui-shots 6 页无报错
553 lines
21 KiB
Vue
553 lines
21 KiB
Vue
<template>
|
||
<div class="cc-wrap" ref="wrapRef">
|
||
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
|
||
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset"
|
||
@contextmenu.prevent="onContextMenu" />
|
||
<div class="hud">
|
||
<span>{{ hint }}</span>
|
||
<el-button size="small" text @click="reset">重置视图</el-button>
|
||
</div>
|
||
<!-- 右键操作菜单 -->
|
||
<transition name="fade">
|
||
<div v-if="ctxMenu.visible" class="ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }">
|
||
<div class="ctx-title">单元 #{{ ctxMenu.uid }}</div>
|
||
<div class="ctx-item" data-action="drag-move"><el-icon><Rank /></el-icon>拖拽移动此单元</div>
|
||
<div class="ctx-item" data-action="rotate-cw"><el-icon><RefreshRight /></el-icon>顺时针旋转 90°</div>
|
||
<div class="ctx-item" data-action="rotate-ccw"><el-icon><RefreshLeft /></el-icon>逆时针旋转 90°</div>
|
||
<div class="ctx-item" data-action="mirror"><el-icon><Switch /></el-icon>水平镜像</div>
|
||
<div class="ctx-item" data-action="bottom"><el-icon><Bottom /></el-icon>底边对齐</div>
|
||
<div class="ctx-item ctx-danger" data-action="delete"><el-icon><Delete /></el-icon>删除单元</div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
/**
|
||
* 样板组合画布(自研,交互方式对齐原站画图编辑器:库面板拖拽 → 画布摆放 → 自由拖移 + 边缘吸附)
|
||
* props.shapes 为共享引用:画布直接平移形状几何(同 uid 的形状作为整体移动),
|
||
* 变更后 emit('change') 通知父组件;expose refresh()/modelAt() 供父组件投放与读取。
|
||
*/
|
||
import { onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { bboxOf, parseDrawing, regionPoints, replayShape, type Pt } from '../types/drawing'
|
||
|
||
interface BBox { x0: number; y0: number; x1: number; y1: number }
|
||
|
||
const props = withDefaults(defineProps<{ shapes: any[]; editable?: boolean }>(), { editable: true })
|
||
const emit = defineEmits<{
|
||
(e: 'change'): void
|
||
(e: 'select', uid: number | null): void
|
||
(e: 'ctx', action: string, uid: number): void
|
||
}>()
|
||
const cvRef = ref<HTMLCanvasElement>()
|
||
const wrapRef = ref<HTMLDivElement>()
|
||
const hint = ref('拖入样板开始组合')
|
||
const selectedUid = ref<number | null>(null)
|
||
const ctxMenu = reactive({ visible: false, x: 0, y: 0, uid: null as number | null })
|
||
|
||
let view = { scale: 1, cx: 0, cy: 0 }
|
||
let lastT = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
|
||
let pan: { x: number; y: number } | null = null
|
||
let dragUnit: null | { uid: number; grabX: number; grabY: number; startBb: BBox; moved: boolean } = null
|
||
let guides: { v: number[]; h: number[] } = { v: [], h: [] }
|
||
|
||
function toModel(sx: number, sy: number): Pt {
|
||
const { scale, midX, midY, W, H } = lastT
|
||
return { x: (sx - W / 2 - view.cx) / scale + midX, y: (sy - H / 2 - view.cy) / scale + midY }
|
||
}
|
||
|
||
// ---------- 几何 ----------
|
||
function unitBBox(uid: number): BBox | null {
|
||
const pts = props.shapes.filter(s => s.__uid === uid).flatMap(shapePts)
|
||
return pts.length ? bboxOf(pts) : null
|
||
}
|
||
function shapePts(shape: any): Pt[] {
|
||
const out: Pt[] = []
|
||
for (const r of (shape.polygon?.polygon ?? []))
|
||
for (const seg of r) {
|
||
if (seg.ps && Number.isFinite(seg.ps.x) && Number.isFinite(seg.ps.y)) out.push(seg.ps)
|
||
if (seg.pe && Number.isFinite(seg.pe.x) && Number.isFinite(seg.pe.y)) out.push(seg.pe)
|
||
}
|
||
for (const sp of (shape.mm?.sp ?? [])) {
|
||
if (sp.line?.pt && Number.isFinite(sp.line.pt.x) && Number.isFinite(sp.line.pt.y)) out.push(sp.line.pt)
|
||
}
|
||
return out
|
||
}
|
||
function translateUid(uid: number, dx: number, dy: number) {
|
||
const mv = (p: any) => { p.x += dx; p.y += dy }
|
||
for (const shape of props.shapes) {
|
||
if (shape.__uid !== uid) continue
|
||
for (const r of (shape.polygon?.polygon ?? [])) for (const seg of r) { mv(seg.ps); mv(seg.pe) }
|
||
for (const sp of (shape.mm?.sp ?? [])) mv(sp.line.pt)
|
||
for (const sub of (shape.sm ?? [])) if (sub.bd) { mv(sub.bd.from); mv(sub.bd.to) }
|
||
}
|
||
}
|
||
function uids(): number[] {
|
||
return [...new Set(props.shapes.map(s => s.__uid))]
|
||
}
|
||
|
||
function bbOfLocal(shapes: any[]): BBox | null {
|
||
const pts = shapes.flatMap(shapePts)
|
||
return pts.length ? bboxOf(pts) : null
|
||
}
|
||
|
||
// ---------- 旋转/镜像编辑 ----------
|
||
/** 旋转整个单元(绕其几何中心),同步旋转分割线法向与开向角度 */
|
||
function rotateUnit(uid: number, deg: 90 | -90 | 180) {
|
||
const target = props.shapes.filter(s => s.__uid === uid)
|
||
const bb = bbOfLocal(target)
|
||
if (!bb) return
|
||
const cx2 = (bb.x0 + bb.x1) / 2, cy2 = (bb.y0 + bb.y1) / 2
|
||
const rad = (deg * Math.PI) / 180
|
||
const cos = Math.cos(rad), sin = Math.sin(rad)
|
||
const rotPt = (p: any) => {
|
||
const dx = p.x - cx2, dy = p.y - cy2
|
||
p.x = cx2 + dx * cos - dy * sin
|
||
p.y = cy2 + dx * sin + dy * cos
|
||
}
|
||
const rotVec = (v: any) => {
|
||
const nx = v.x ?? 1, ny = v.y ?? 0
|
||
v.x = nx * cos - ny * sin
|
||
v.y = nx * sin + ny * cos
|
||
}
|
||
for (const s of target) {
|
||
for (const r of (s.polygon?.polygon ?? [])) for (const seg of r) { rotPt(seg.ps); rotPt(seg.pe) }
|
||
for (const sp of (s.mm?.sp ?? [])) { rotPt(sp.line.pt); if (sp.line.norm) rotVec(sp.line.norm) }
|
||
for (const sub of (s.sm ?? [])) {
|
||
if (sub.bd) { rotPt(sub.bd.from); rotPt(sub.bd.to) }
|
||
if (typeof sub.od === 'number') sub.od = (sub.od + deg + 360) % 360
|
||
if (Array.isArray(sub.sashes)) for (const e of sub.sashes) {
|
||
if (typeof e?.od === 'number') e.od = (e.od + deg + 360) % 360
|
||
}
|
||
}
|
||
}
|
||
refresh()
|
||
emit('change')
|
||
}
|
||
|
||
/** 水平镜像整个单元(开向同步镜像) */
|
||
function mirrorUnit(uid: number) {
|
||
const target = props.shapes.filter(s => s.__uid === uid)
|
||
const bb = bbOfLocal(target)
|
||
if (!bb) return
|
||
const cx2 = (bb.x0 + bb.x1) / 2
|
||
const mirPt = (p: any) => { p.x = 2 * cx2 - p.x }
|
||
for (const s of target) {
|
||
for (const r of (s.polygon?.polygon ?? [])) for (const seg of r) { mirPt(seg.ps); mirPt(seg.pe) }
|
||
for (const sp of (s.mm?.sp ?? [])) {
|
||
mirPt(sp.line.pt)
|
||
if (sp.line.norm) sp.line.norm.x = -(sp.line.norm.x ?? 1)
|
||
}
|
||
for (const sub of (s.sm ?? [])) {
|
||
if (sub.bd) { mirPt(sub.bd.from); mirPt(sub.bd.to) }
|
||
if (typeof sub.od === 'number') sub.od = (180 - sub.od + 360) % 360
|
||
if (Array.isArray(sub.sashes)) for (const e of sub.sashes) {
|
||
if (typeof e?.od === 'number') e.od = (180 - e.od + 360) % 360
|
||
}
|
||
}
|
||
}
|
||
refresh()
|
||
emit('change')
|
||
}
|
||
|
||
// 吸附: 单元边缘贴近其他单元边缘时对齐(返回修正后的位置与辅助线)
|
||
/** 六边缘对齐吸附(标准画布编辑器模式): 拖动单元的左/中/右、上/中/下与其余单元对应边比对, 取最近者吸附 */
|
||
function snap(bb: BBox, excludeUid: number): { dx: number; dy: number; guides: { v: number[]; h: number[] } } {
|
||
const others = uids().filter(u => u !== excludeUid)
|
||
.map(u => unitBBox(u)).filter(b => b) as BBox[]
|
||
const gv: number[] = [], gh: number[] = []
|
||
let dx = 0, dy = 0
|
||
const myX = [bb.x0, (bb.x0 + bb.x1) / 2, bb.x1]
|
||
const myY = [bb.y0, (bb.y0 + bb.y1) / 2, bb.y1]
|
||
let bestX: { d: number; from: number; to: number } | null = null
|
||
let bestY: { d: number; from: number; to: number } | null = null
|
||
for (const o of others) {
|
||
const oX = [o.x0, (o.x0 + o.x1) / 2, o.x1]
|
||
const oY = [o.y0, (o.y0 + o.y1) / 2, o.y1]
|
||
for (const a of myX) for (const b of oX) {
|
||
const d = Math.abs(a - b)
|
||
if (d < 12 && (!bestX || d < bestX.d)) bestX = { d, from: a, to: b }
|
||
}
|
||
for (const a of myY) for (const b of oY) {
|
||
const d = Math.abs(a - b)
|
||
if (d < 12 && (!bestY || d < bestY.d)) bestY = { d, from: a, to: b }
|
||
}
|
||
}
|
||
if (bestX) { dx = bestX.to - bestX.from; gv.push(bestX.to) }
|
||
if (bestY) { dy = bestY.to - bestY.from; gh.push(bestY.to) }
|
||
return { dx, dy, guides: { v: gv, h: gh } }
|
||
}
|
||
|
||
// ---------- 渲染 ----------
|
||
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.fillStyle = '#eef1f4'
|
||
ctx.fillRect(0, 0, cv.width, cv.height)
|
||
const W = wrap.clientWidth, H = wrap.clientHeight
|
||
|
||
const pts = props.shapes.flatMap(shapePts)
|
||
if (!pts.length) { hint.value = '拖入样板开始组合'; return }
|
||
const gb = bboxOf(pts)
|
||
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
|
||
const pad = 50
|
||
const fit = Math.min((W - pad * 2) / (gw || 1), (H - pad * 2) / (gh || 1))
|
||
const scale = fit * view.scale
|
||
const midX = (gb.x0 + gb.x1) / 2, midY = (gb.y0 + gb.y1) / 2
|
||
lastT = { scale, midX, midY, W, H }
|
||
const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx
|
||
// 模型坐标 y 向下(屏幕系): 直接映射, 不做上下翻转
|
||
const ty = (y: number) => H / 2 + (y - midY) * scale + view.cy
|
||
|
||
ctx.lineJoin = 'round'
|
||
for (const shape of props.shapes) {
|
||
const sel = selectedUid.value !== null && shape.__uid === selectedUid.value
|
||
for (const region of replayShape(shape).regions) {
|
||
const rpts = regionPoints(region)
|
||
if (rpts.length < 3) continue
|
||
ctx.beginPath()
|
||
rpts.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(211,221,218,0.6)'
|
||
ctx.fill()
|
||
ctx.strokeStyle = sel ? '#409eff' : '#5c6166'
|
||
ctx.lineWidth = 1.6
|
||
ctx.stroke()
|
||
}
|
||
// 外框
|
||
const rs = replayShape(shape).regions.map(regionPoints).filter(p => p.length >= 3)
|
||
if (rs.length) {
|
||
const bb = bboxOf(rs.flat())
|
||
ctx.strokeStyle = sel ? '#409eff' : '#5c6166'
|
||
ctx.lineWidth = 3
|
||
ctx.strokeRect(tx(bb.x0), ty(bb.y0), (bb.x1 - bb.x0) * scale, (bb.y1 - bb.y0) * scale)
|
||
}
|
||
// 中梃(按重放区域裁剪近似:画在其基底 bbox 内)
|
||
const rb = rs.length ? bboxOf(rs.flat()) : null
|
||
for (const sp of (shape.mm?.sp ?? [])) {
|
||
if (!sp.line?.pt || !rb) continue
|
||
const n = sp.line.norm ?? { x: 1, y: 0 }
|
||
const pos = Math.abs(n.x) >= Math.abs(n.y) ? sp.line.pt.x : sp.line.pt.y
|
||
ctx.strokeStyle = sel ? '#66aaff' : '#7c828a'
|
||
ctx.lineWidth = 2
|
||
ctx.beginPath()
|
||
if (Math.abs(n.x) >= Math.abs(n.y)) { ctx.moveTo(tx(pos), ty(rb.y0)); ctx.lineTo(tx(pos), ty(rb.y1)) }
|
||
else { ctx.moveTo(tx(rb.x0), ty(pos)); ctx.lineTo(tx(rb.x1), ty(pos)) }
|
||
ctx.stroke()
|
||
}
|
||
}
|
||
|
||
// 吸附辅助线
|
||
ctx.strokeStyle = '#f56c6c'
|
||
ctx.setLineDash([5, 4])
|
||
ctx.lineWidth = 1
|
||
for (const v of guides.v) { ctx.beginPath(); ctx.moveTo(tx(v), 0); ctx.lineTo(tx(v), H); ctx.stroke() }
|
||
for (const h of guides.h) { ctx.beginPath(); ctx.moveTo(0, ty(h)); ctx.lineTo(W, ty(h)); ctx.stroke() }
|
||
ctx.setLineDash([])
|
||
|
||
// 选中单元: 蓝色虚线框 + 角点手柄 + 尺寸标注
|
||
if (selectedUid.value !== null) {
|
||
const bb = unitBBox(selectedUid.value)
|
||
if (bb) {
|
||
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y0), y1 = ty(bb.y1)
|
||
ctx.strokeStyle = '#409eff'
|
||
ctx.setLineDash([6, 4])
|
||
ctx.lineWidth = 1.5
|
||
ctx.strokeRect(x0 - 4, y0 - 4, (x1 - x0) + 8, (y1 - y0) + 8)
|
||
ctx.setLineDash([])
|
||
ctx.fillStyle = '#409eff'
|
||
for (const [hx, hy] of [[x0, y0], [x1, y0], [x0, y1], [x1, y1]]) {
|
||
ctx.fillRect(hx - 4, hy - 4, 8, 8)
|
||
}
|
||
ctx.font = 'bold 12px sans-serif'
|
||
ctx.textAlign = 'center'
|
||
ctx.fillText(`${Math.round(bb.x1 - bb.x0)} × ${Math.round(bb.y1 - bb.y0)} mm`, (x0 + x1) / 2, y0 - 10)
|
||
ctx.textAlign = 'left'
|
||
}
|
||
}
|
||
|
||
hint.value = `${uids().length} 个单元 · 总尺寸 ${Math.round(gw)} × ${Math.round(gh)} mm`
|
||
}
|
||
|
||
// ---------- 交互 ----------
|
||
function hitUnit(p: Pt): number | null {
|
||
let best: { uid: number; area: number } | null = null
|
||
for (const uid of uids()) {
|
||
const bb = unitBBox(uid)
|
||
if (!bb) continue
|
||
if (p.x >= bb.x0 && p.x <= bb.x1 && p.y >= bb.y0 && p.y <= bb.y1) {
|
||
const area = (bb.x1 - bb.x0) * (bb.y1 - bb.y0)
|
||
if (!best || area < best.area) best = { uid, area }
|
||
}
|
||
}
|
||
return best?.uid ?? null
|
||
}
|
||
|
||
function onDown(e: MouseEvent) {
|
||
closeMenu()
|
||
const rect = cvRef.value!.getBoundingClientRect()
|
||
// 拖拽移动模式: 点击落下
|
||
if (moveModeUid.value !== null) {
|
||
moveModeUid.value = null
|
||
moveLast = null
|
||
hint.value = '已落下'
|
||
render()
|
||
emit('change')
|
||
return
|
||
}
|
||
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
|
||
const uid = hitUnit(p)
|
||
if (props.editable && uid !== null) {
|
||
selectedUid.value = uid
|
||
const bb = unitBBox(uid)!
|
||
dragUnit = { uid, grabX: p.x, grabY: p.y, startBb: { ...bb }, moved: false }
|
||
emit('select', uid)
|
||
render()
|
||
return
|
||
}
|
||
selectedUid.value = null
|
||
emit('select', null)
|
||
pan = { x: e.clientX, y: e.clientY }
|
||
render()
|
||
}
|
||
|
||
function onMove(e: MouseEvent) {
|
||
const rect = cvRef.value!.getBoundingClientRect()
|
||
// 拖拽移动模式: 鼠标移动即拖动单元
|
||
if (moveModeUid.value !== null) {
|
||
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
|
||
if (moveLast) {
|
||
translateUid(moveModeUid.value, p.x - moveLast.x, p.y - moveLast.y)
|
||
moveLast = p
|
||
render()
|
||
}
|
||
return
|
||
}
|
||
if (dragUnit) {
|
||
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
|
||
const bb = unitBBox(dragUnit.uid)
|
||
if (!bb) return
|
||
let dx = p.x - dragUnit.grabX, dy = p.y - dragUnit.grabY
|
||
// 以拖动前 bbox 计算吸附
|
||
const cand = { ...bb }
|
||
cand.x0 += dx; cand.x1 += dx; cand.y0 += dy; cand.y1 += dy
|
||
const s = snap(cand, dragUnit.uid)
|
||
dx += s.dx; dy += s.dy
|
||
guides = s.guides
|
||
if (dx || dy) translateUid(dragUnit.uid, dx, dy)
|
||
dragUnit.grabX = p.x; dragUnit.grabY = p.y
|
||
dragUnit.moved = true
|
||
render()
|
||
return
|
||
}
|
||
if (pan) {
|
||
view.cx += e.clientX - pan.x
|
||
view.cy += e.clientY - pan.y
|
||
pan = { x: e.clientX, y: e.clientY }
|
||
render()
|
||
}
|
||
}
|
||
|
||
function onUp() {
|
||
if (dragUnit) {
|
||
const moved = dragUnit.moved
|
||
guides = { v: [], h: [] }
|
||
dragUnit = null
|
||
render()
|
||
if (moved) emit('change')
|
||
return
|
||
}
|
||
pan = null
|
||
}
|
||
|
||
function onWheel(e: WheelEvent) {
|
||
view.scale = Math.min(20, Math.max(0.15, view.scale * (e.deltaY < 0 ? 1.12 : 1 / 1.12)))
|
||
render()
|
||
}
|
||
|
||
function reset() {
|
||
view = { scale: 1, cx: 0, cy: 0 }
|
||
render()
|
||
}
|
||
|
||
/** 屏幕坐标 → 模型坐标(供父组件处理拖放落点) */
|
||
function modelAt(clientX: number, clientY: number): Pt {
|
||
const rect = cvRef.value!.getBoundingClientRect()
|
||
return toModel(clientX - rect.left, clientY - rect.top)
|
||
}
|
||
|
||
/** 右键菜单: 命中单元弹出操作菜单 */
|
||
function onContextMenu(e: MouseEvent) {
|
||
const rect = cvRef.value!.getBoundingClientRect()
|
||
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
|
||
const uid = hitUnit(p)
|
||
if (uid === null) { closeMenu(); return }
|
||
selectedUid.value = uid
|
||
ctxMenu.uid = uid
|
||
ctxMenu.visible = true
|
||
ctxMenu.x = e.clientX - rect.left + 6
|
||
ctxMenu.y = e.clientY - rect.top + 6
|
||
emit('select', uid)
|
||
render()
|
||
}
|
||
|
||
function closeMenu() { ctxMenu.visible = false }
|
||
|
||
function ctxAction(a: string) {
|
||
const uid = ctxMenu.uid
|
||
closeMenu()
|
||
if (uid === null) return
|
||
// 拖拽移动模式: 后续鼠标移动拖动该单元, 点击落下; Esc 取消还原
|
||
if (a === 'drag-move') {
|
||
moveModeUid.value = uid
|
||
moveOriginBb = unitBBox(uid)
|
||
moveLast = null
|
||
hint.value = '拖拽移动模式:移动鼠标拖动单元,点击落下;Esc 取消'
|
||
return
|
||
}
|
||
// 动作统一 emit 给父组件执行(父组件持有 canvasRef 与 shapes 权威状态)
|
||
emit('ctx', a, uid)
|
||
}
|
||
|
||
const moveModeUid = ref<number | null>(null)
|
||
let moveLast: Pt | null = null
|
||
let moveOriginBb: BBox | null = null
|
||
|
||
function refresh() { render() }
|
||
|
||
/** 选中单元缩放到目标尺寸(几何按比例缩放,锚定自身左下角) */
|
||
function resizeSelected(newW: number, newH: number) {
|
||
if (selectedUid.value === null) return
|
||
const target = props.shapes.filter(s => s.__uid === selectedUid.value)
|
||
const bb = bbOfLocal(target)
|
||
if (!bb) return
|
||
const w = bb.x1 - bb.x0, h = bb.y1 - bb.y0
|
||
if (w < 10 || h < 10) return
|
||
const sx = Math.max(0.05, newW / w), sy = Math.max(0.05, newH / h)
|
||
const scX = (p: any) => { p.x = bb.x0 + (p.x - bb.x0) * sx }
|
||
const scY = (p: any) => { p.y = bb.y0 + (p.y - bb.y0) * sy }
|
||
for (const s of target) {
|
||
for (const r of (s.polygon?.polygon ?? [])) {
|
||
for (const seg of r) { scX(seg.ps); scY(seg.ps); scX(seg.pe); scY(seg.pe) }
|
||
}
|
||
for (const sp of (s.mm?.sp ?? [])) { scX(sp.line.pt); scY(sp.line.pt) }
|
||
for (const sub of (s.sm ?? [])) if (sub.bd) { scX(sub.bd.from); scY(sub.bd.from); scX(sub.bd.to); scY(sub.bd.to) }
|
||
}
|
||
refresh()
|
||
emit('change')
|
||
}
|
||
|
||
/** 方向键微调(mm) */
|
||
function nudgeSelected(dx: number, dy: number) {
|
||
if (selectedUid.value === null) return
|
||
translateUid(selectedUid.value, dx, dy)
|
||
refresh()
|
||
emit('change')
|
||
}
|
||
|
||
/** 单元底边对齐到其余单元最低底边 */
|
||
function bottomAlignUnit(uid: number) {
|
||
const bb = unitBBox(uid)
|
||
const others = uids().filter(u => u !== uid).map(u => unitBBox(u)).filter(b => b) as BBox[]
|
||
if (!bb || !others.length) return
|
||
const target = Math.min(...others.map(o => o.y0))
|
||
translateUid(uid, 0, target - bb.y0)
|
||
refresh()
|
||
emit('change')
|
||
}
|
||
|
||
/** 自动排列: 全部单元按 X 序紧贴排布、底边对齐(可选的整理工具,不强制) */
|
||
function autoArrange() {
|
||
const ids = uids().filter(u => unitBBox(u)).sort((a, b) =>
|
||
(unitBBox(a)!.x0) - (unitBBox(b)!.x0))
|
||
if (!ids.length) return
|
||
let bottom = Infinity
|
||
for (const id of ids) {
|
||
const bb = unitBBox(id)!
|
||
bottom = Math.min(bottom, bb.y0)
|
||
}
|
||
let x = ids.length ? unitBBox(ids[0])!.x0 : 0
|
||
for (const id of ids) {
|
||
const bb = unitBBox(id)!
|
||
translateUid(id, x - bb.x0, bottom - bb.y0)
|
||
x += bb.x1 - bb.x0
|
||
}
|
||
refresh()
|
||
emit('change')
|
||
}
|
||
|
||
function exportDrawing(): string | null {
|
||
if (!props.shapes.length) return null
|
||
return JSON.stringify({
|
||
s: { cs: 60, cjs: 100 }, cp: [], sm: props.shapes,
|
||
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [],
|
||
})
|
||
}
|
||
// 临时调试钩子(定位选中问题,验证后移除)
|
||
function debugHit(clientX: number, clientY: number) {
|
||
const rect = cvRef.value!.getBoundingClientRect()
|
||
const p = toModel(clientX - rect.left, clientY - rect.top)
|
||
const uid = hitUnit(p)
|
||
return { p, uid, lastT: { ...lastT }, rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height } }
|
||
}
|
||
;(window as any).__ccDebug = { debugHit, getSelected: () => selectedUid.value, units: uids }
|
||
defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit, resizeSelected, nudgeSelected, autoArrange, bottomAlignUnit, debugHit })
|
||
|
||
onMounted(() => {
|
||
watch(() => props.shapes, () => { selectedUid.value = null; closeMenu(); rebuild() }, { immediate: true })
|
||
window.addEventListener('resize', render)
|
||
// Esc 取消拖拽移动(还原到模式进入时的位置)
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape' && moveModeUid.value !== null) {
|
||
const bb = unitBBox(moveModeUid.value)
|
||
if (bb && moveOriginBb) {
|
||
translateUid(moveModeUid.value,
|
||
(moveOriginBb.x0 + moveOriginBb.x1) / 2 - (bb.x0 + bb.x1) / 2,
|
||
(moveOriginBb.y0 + moveOriginBb.y1) / 2 - (bb.y0 + bb.y1) / 2)
|
||
}
|
||
moveModeUid.value = null
|
||
render()
|
||
}
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||
// 点击菜单外关闭 + 菜单项动作(在 mousedown 阶段触发,真实点击必有效)
|
||
document.addEventListener('mousedown', (e: MouseEvent) => {
|
||
const item = (e.target as HTMLElement).closest?.('.ctx-item')
|
||
if (item && ctxMenu.visible) {
|
||
const action = (item as HTMLElement).dataset.action
|
||
ctxMenu.visible = false
|
||
if (action) ctxAction(action)
|
||
return
|
||
}
|
||
if (ctxMenu.visible && !(e.target as HTMLElement).closest('.ctx-menu')) closeMenu()
|
||
})
|
||
})
|
||
function rebuild() {
|
||
view = { scale: 1, cx: 0, cy: 0 }
|
||
render()
|
||
}
|
||
onBeforeUnmount(() => window.removeEventListener('resize', render))
|
||
</script>
|
||
|
||
<style scoped>
|
||
.cc-wrap { position: relative; width: 100%; height: 100%; min-height: 320px; }
|
||
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
|
||
canvas:active { cursor: grabbing; }
|
||
.hud { position: absolute; left: 10px; top: 10px; background: rgba(255,255,255,0.9);
|
||
border-radius: 6px; padding: 3px 10px; font-size: 12px; color: #606266; }
|
||
.ctx-menu { position: absolute; z-index: 40; background: #fff; border-radius: 8px;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.16); padding: 4px 0; min-width: 150px; }
|
||
.ctx-title { font-size: 11px; color: #909399; padding: 4px 12px; border-bottom: 1px solid #f0f0f0; }
|
||
.ctx-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; font-size: 13px;
|
||
color: #303133; cursor: pointer; }
|
||
.ctx-item:hover { background: #f5f7fa; }
|
||
.ctx-danger { color: #f56c6c; }
|
||
</style>
|