feat: v1.3 样板组合升级为画布形式
- 新组件 CombineCanvas: 交互对齐原站画图编辑器 库面板可拖拽(dragstart/drop) → 画布落点摆放 自由拖移(同uid单元整体移动) + 边缘吸附对齐(12px容差+红色辅助线) 点选高亮/底边对齐/Del键删除/平移缩放/双击复位 - CombineView 重构: 拖放+点击双入口, 保留2D编辑与3D预览切换 - 另存我的样板时剥离内部标记字段, 保持标准 drawing_data 格式 - 原'顺序拼接'模式由画布自由摆放取代
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="cc-wrap" ref="wrapRef">
|
||||
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
|
||||
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset" @dragover.prevent @drop="onDrop" />
|
||||
<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)) out.push(seg.ps)
|
||||
if (seg.pe && Number.isFinite(seg.pe.x)) out.push(seg.pe)
|
||||
}
|
||||
for (const sp of (shape.mm?.sp ?? [])) if (sp.line?.pt) 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 snap(bb: BBox, excludeUid: number): { dx: number; dy: number; guides: { v: number[]; h: number[] } } {
|
||||
const TOL = 12
|
||||
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
|
||||
for (const o of others) {
|
||||
// 左缘贴对方右缘(无缝拼接)
|
||||
if (Math.abs(bb.x0 - o.x1) < TOL) { dx = o.x1 - bb.x0; gv.push(o.x1) }
|
||||
if (Math.abs(bb.x1 - o.x0) < TOL) { dx = o.x0 - bb.x1; gv.push(o.x0) }
|
||||
if (Math.abs(bb.y0 - o.y0) < TOL) { dy = o.y0 - bb.y0; gh.push(o.y0) }
|
||||
if (Math.abs(bb.y1 - o.y1) < TOL) { dy = o.y1 - bb.y1; gh.push(o.y1) }
|
||||
}
|
||||
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([])
|
||||
|
||||
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) {
|
||||
if (dragUnit.moved) emit('change')
|
||||
guides = { v: [], h: [] }
|
||||
dragUnit = null
|
||||
render()
|
||||
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 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 })
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user