feat: v1.3 样板组合升级为画布形式

- 新组件 CombineCanvas: 交互对齐原站画图编辑器
  库面板可拖拽(dragstart/drop) → 画布落点摆放
  自由拖移(同uid单元整体移动) + 边缘吸附对齐(12px容差+红色辅助线)
  点选高亮/底边对齐/Del键删除/平移缩放/双击复位
- CombineView 重构: 拖放+点击双入口, 保留2D编辑与3D预览切换
- 另存我的样板时剥离内部标记字段, 保持标准 drawing_data 格式
- 原'顺序拼接'模式由画布自由摆放取代
This commit is contained in:
HHY
2026-09-21 01:35:59 -07:00
parent 2ebd7e0b5c
commit f3dc6a760d
3 changed files with 432 additions and 154 deletions
+273
View File
@@ -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>