- 移除拖动松手后的强制自动排列(恢复自由摆放能力) - 吸附升级为标准六边缘对齐: 拖动单元左/中/右×其余单元左/中/右 (上/中/下同理), 取最近者吸附并绘制辅助线 - 允许自由重叠摆放(用户自主决定布局) - 自动排列保留为可选整理按钮 - 参考 Konva/Fabric 吸附标准模式实现
424 lines
15 KiB
Vue
424 lines
15 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" />
|
||
<div class="hud">
|
||
<span>{{ hint }}</span>
|
||
<el-button size="small" text @click="reset">重置视图</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
/**
|
||
* 样板组合画布(自研,交互方式对齐原站画图编辑器:库面板拖拽 → 画布摆放 → 自由拖移 + 边缘吸附)
|
||
* props.shapes 为共享引用:画布直接平移形状几何(同 uid 的形状作为整体移动),
|
||
* 变更后 emit('change') 通知父组件;expose refresh()/modelAt() 供父组件投放与读取。
|
||
*/
|
||
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { bboxOf, parseDrawing, regionPoints, replayShape, type Pt } from '../types/drawing'
|
||
|
||
interface BBox { x0: number; y0: number; x1: number; y1: number }
|
||
|
||
const props = defineProps<{ shapes: any[]; editable?: boolean }>()
|
||
const emit = defineEmits<{ (e: 'change'): void; (e: 'select', uid: number | null): void }>()
|
||
const cvRef = ref<HTMLCanvasElement>()
|
||
const wrapRef = ref<HTMLDivElement>()
|
||
const hint = ref('拖入样板开始组合')
|
||
const selectedUid = ref<number | null>(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: (H / 2 + view.cy - sy) / 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
|
||
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.y1), (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.y1), y1 = ty(bb.y0)
|
||
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) {
|
||
const rect = cvRef.value!.getBoundingClientRect()
|
||
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
|
||
const uid = hitUnit(p)
|
||
if (props.editable !== false && 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) {
|
||
if (dragUnit) {
|
||
const rect = cvRef.value!.getBoundingClientRect()
|
||
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 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')
|
||
}
|
||
|
||
/** 自动排列: 全部单元按 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: [],
|
||
})
|
||
}
|
||
defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit, resizeSelected, nudgeSelected, autoArrange })
|
||
|
||
onMounted(() => {
|
||
watch(() => props.shapes, () => { selectedUid.value = null; rebuild() }, { immediate: true })
|
||
window.addEventListener('resize', render)
|
||
})
|
||
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; }
|
||
</style>
|