Files
menchuang/frontend/src/components/CombineCanvas.vue
T
HHY 2cee9febe5 fix: v2.4 组合画布回归自由拖拽
- 移除拖动松手后的强制自动排列(恢复自由摆放能力)
- 吸附升级为标准六边缘对齐: 拖动单元左/中/右×其余单元左/中/右
  (上/中/下同理), 取最近者吸附并绘制辅助线
- 允许自由重叠摆放(用户自主决定布局)
- 自动排列保留为可选整理按钮
- 参考 Konva/Fabric 吸附标准模式实现
2026-09-21 10:05:44 -07:00

424 lines
15 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="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>