- replayShape 段过滤补 Y 轴有限值校验(此前仅校验 X) - inferSashGlyphs bdRect 增加有限值守卫 - WindoorThreed 逐网格有限值防护 + maxDim 守卫, 杜绝 NaN 网格 - CombineCanvas shapePts 过滤非有限点, 修复包围盒污染导致的点选失效 - 组合画布引入 rev 版本号: 原地变异几何后强制 3D 预览重建 (修复尺寸编辑后样品图不变化)
315 lines
14 KiB
Vue
315 lines
14 KiB
Vue
<template>
|
||
<el-row :gutter="16" class="combine-page">
|
||
<!-- 左:样板库(可拖拽) -->
|
||
<el-col :xs="24" :md="5">
|
||
<el-card header="样板库(拖入画布或点击加入)">
|
||
<el-input v-model="keyword" placeholder="搜索样板" clearable size="small" @keyup.enter="loadLib" @clear="loadLib">
|
||
<template #prefix><el-icon><Search /></el-icon></template>
|
||
</el-input>
|
||
<el-scrollbar height="56vh" style="margin-top:10px">
|
||
<div class="lib-grid">
|
||
<div v-for="t in lib" :key="t.id" class="lib-item" draggable="true"
|
||
@dragstart="onDragStart($event, t)" @click="addTemplate(t)">
|
||
<el-image :src="t.pic_url" fit="contain" lazy class="lib-pic" />
|
||
<span class="lib-name">{{ t.name || `#${t.id}` }}</span>
|
||
</div>
|
||
</div>
|
||
</el-scrollbar>
|
||
</el-card>
|
||
</el-col>
|
||
|
||
<!-- 中:组合画布 -->
|
||
<el-col :xs="24" :md="13">
|
||
<el-card>
|
||
<template #header>
|
||
<div class="head-row">
|
||
<span>组合画布({{ units.length }} 个单元 · 拖动摆放 · 边缘自动吸附)</span>
|
||
<div style="display:flex; gap:8px; align-items:center">
|
||
<el-tooltip content="选中单元逆时针旋转90°" placement="top">
|
||
<el-button size="small" :disabled="!selectedUid" @click="canvasRef?.rotateUnit(selectedUid!, -90)">⟲ 90°</el-button>
|
||
</el-tooltip>
|
||
<el-tooltip content="选中单元顺时针旋转90°" placement="top">
|
||
<el-button size="small" :disabled="!selectedUid" @click="canvasRef?.rotateUnit(selectedUid!, 90)">⟳ 90°</el-button>
|
||
</el-tooltip>
|
||
<el-tooltip content="选中单元水平镜像" placement="top">
|
||
<el-button size="small" :disabled="!selectedUid" @click="canvasRef?.mirrorUnit(selectedUid!)">⇋ 镜像</el-button>
|
||
</el-tooltip>
|
||
<template v-if="selectedUid">
|
||
<span style="font-size:12px; color:#909399">尺寸</span>
|
||
<el-input-number v-model="selSize.w" :min="300" :step="10" size="small" controls-position="right"
|
||
style="width:96px" @change="onSelResize" />
|
||
<el-input-number v-model="selSize.h" :min="300" :step="10" size="small" controls-position="right"
|
||
style="width:96px" @change="onSelResize" />
|
||
</template>
|
||
<el-divider direction="vertical" />
|
||
<el-button size="small" :disabled="!selectedUid" @click="alignBottom">底边对齐</el-button>
|
||
<el-button size="small" :disabled="!selectedUid" type="danger" plain @click="deleteSelected">删除单元</el-button>
|
||
<el-button size="small" :disabled="!shapes.length" @click="clearAll">清空</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div class="canvas-box" @drop="onDrop" style="position:relative">
|
||
<CombineCanvas ref="canvasRef" :shapes="shapes" @select="onSelect" @change="onCanvasChange" />
|
||
</div>
|
||
<div class="save-bar">
|
||
<el-input v-model="saveName" placeholder="组合样板名称(如:三扇组合窗)" style="flex:1" />
|
||
<el-button type="primary" :disabled="!shapes.length || !saveName.trim()" :loading="saving"
|
||
@click="saveAsTemplate">另存为我的样板</el-button>
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
|
||
<!-- 右:单元清单 + 3D 预览 -->
|
||
<el-col :xs="24" :md="6">
|
||
<el-card header="组合单元">
|
||
<el-empty v-if="!units.length" description="拖入或点击样板开始" :image-size="60" />
|
||
<transition-group name="list" tag="div">
|
||
<div v-for="u in units" :key="u.uid" class="unit" :class="{ pick: u.uid === selectedUid }"
|
||
@click="selectUnit(u.uid)">
|
||
<el-image :src="u.pic" fit="contain" class="unit-pic" />
|
||
<div class="unit-info">
|
||
<div class="unit-name">{{ u.name }}</div>
|
||
<div class="unit-size">{{ u.w }} × {{ u.h }} mm</div>
|
||
</div>
|
||
<el-button size="small" text type="danger" @click.stop="deleteUnit(u.uid)"><el-icon><Delete /></el-icon></el-button>
|
||
</div>
|
||
</transition-group>
|
||
</el-card>
|
||
<el-card header="3D 预览" style="margin-top:16px">
|
||
<div class="threed-box">
|
||
<WindoorThreed v-if="shapes.length" :drawing-data="combinedJson" />
|
||
<el-empty v-else description="—" :image-size="50" />
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
/**
|
||
* 样板组合工场(画布形式,交互对齐原站画图编辑器:库面板拖拽 → 画布摆放 → 自由拖移 + 边缘吸附)
|
||
* 数据模型:shapes = 多形状 drawing_data 的 sm[](每形状打 __uid 单元标记);
|
||
* 组合结果与渲染/报价/AI 炫图/3D 全链路兼容,可另存为"我的样板"。
|
||
*/
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { api } from '../api'
|
||
import { parseDrawing } from '../types/drawing'
|
||
import CombineCanvas from '../components/CombineCanvas.vue'
|
||
import WindoorThreed from '../components/WindoorThreed.vue'
|
||
|
||
interface UnitMeta { uid: number; name: string; pic: string; templateId: number }
|
||
|
||
const lib = ref<any[]>([])
|
||
const keyword = ref('')
|
||
const shapes = ref<any[]>([])
|
||
const units = ref<UnitMeta[]>([])
|
||
const selectedUid = ref<number | null>(null)
|
||
const saveName = ref('')
|
||
const saving = ref(false)
|
||
const canvasRef = ref()
|
||
let uidSeq = 1
|
||
const rev = ref(0)
|
||
|
||
const combinedJson = computed(() => {
|
||
void rev.value // 画布原地变异几何后通过版本号强制重算(驱动 3D 预览重建)
|
||
if (!shapes.value.length) return ''
|
||
return JSON.stringify({ s: { cs: 60, cjs: 100 }, cp: [], sm: shapes.value,
|
||
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [], rev: rev.value })
|
||
})
|
||
|
||
// ---------- 样板库 ----------
|
||
async function loadLib() {
|
||
lib.value = await api<any>('get', `/api/windoor/templates?page=1&page_size=254&is_default=1&name=${keyword.value}`)
|
||
}
|
||
|
||
// ---------- 加入样板 ----------
|
||
function shapesOf(detail: any): any[] {
|
||
const dd = parseDrawing(detail.drawing_data)
|
||
return dd.sm.map(s => JSON.parse(JSON.stringify(s)))
|
||
}
|
||
function bbOf(shapes: any[]) {
|
||
const pts = shapes.flatMap(s => (s.polygon?.polygon ?? []).flat()
|
||
.flatMap((seg: any) => [seg.ps, seg.pe]).filter((p: any) => p && Number.isFinite(p.x)))
|
||
if (!pts.length) return null
|
||
const xs = pts.map(p => p.x), ys = pts.map(p => p.y)
|
||
return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) }
|
||
}
|
||
function translate(shapes: any[], dx: number, dy: number) {
|
||
const mv = (p: any) => { p.x += dx; p.y += dy }
|
||
for (const s of shapes) {
|
||
for (const r of (s.polygon?.polygon ?? [])) for (const seg of r) { mv(seg.ps); mv(seg.pe) }
|
||
for (const sp of (s.mm?.sp ?? [])) mv(sp.line.pt)
|
||
for (const sub of (s.sm ?? [])) if (sub.bd) { mv(sub.bd.from); mv(sub.bd.to) }
|
||
}
|
||
}
|
||
|
||
function registerUnit(detail: any, uid: number, name: string) {
|
||
units.value = [...units.value, { uid, name, pic: detail.pic_url || '', templateId: detail.id }]
|
||
}
|
||
|
||
function addShapesAt(detail: any, cx: number | null, cy: number | null, name: string) {
|
||
const uid = uidSeq++
|
||
const newShapes = shapesOf(detail)
|
||
if (!newShapes.length) { ElMessage.warning('该样板没有可用的几何数据'); return false }
|
||
const bb = bbOf(newShapes)!
|
||
let dx = 0, dy = 0
|
||
if (cx !== null && cy !== null) {
|
||
dx = cx - (bb.x0 + bb.x1) / 2
|
||
dy = cy - (bb.y0 + bb.y1) / 2
|
||
} else {
|
||
const cur = bbOf(shapes.value)
|
||
dx = cur ? cur.x1 - bb.x0 : 0
|
||
dy = cur ? cur.y0 - bb.y0 : 0
|
||
}
|
||
translate(newShapes, dx, dy)
|
||
for (const s of newShapes) { s.__uid = uid; s.__name = name }
|
||
shapes.value = [...shapes.value, ...newShapes]
|
||
registerUnit(detail, uid, name)
|
||
canvasRef.value?.refresh()
|
||
refreshMeta()
|
||
return true
|
||
}
|
||
|
||
async function addTemplate(t: any) {
|
||
const detail = await api<any>('get', `/api/windoor/templates/${t.id}`)
|
||
if (addShapesAt(detail, null, null, t.name || `样板#${t.id}`))
|
||
ElMessage.success(`已加入:${t.name || `样板#${t.id}`}`)
|
||
}
|
||
|
||
function onDragStart(e: DragEvent, t: any) {
|
||
e.dataTransfer?.setData('template_id', String(t.id))
|
||
e.dataTransfer!.effectAllowed = 'copy'
|
||
}
|
||
|
||
async function onDrop(e: DragEvent) {
|
||
const id = e.dataTransfer?.getData('template_id')
|
||
if (!id) return
|
||
const detail = await api<any>('get', `/api/windoor/templates/${id}`)
|
||
const p = canvasRef.value?.modelAt(e.clientX, e.clientY)
|
||
if (addShapesAt(detail, p?.x ?? null, p?.y ?? null, detail.name || `样板#${detail.id}`))
|
||
ElMessage.success('已放入画布')
|
||
}
|
||
|
||
// ---------- 单元操作 ----------
|
||
function onSelect(uid: number | null) { selectedUid.value = uid; syncSelSize() }
|
||
|
||
const selSize = reactive({ w: 0, h: 0 })
|
||
|
||
function syncSelSize() {
|
||
if (selectedUid.value === null) { selSize.w = selSize.h = 0; return }
|
||
const bb = bbOf(shapes.value.filter(s => s.__uid === selectedUid.value))
|
||
if (bb) { selSize.w = Math.round(bb.x1 - bb.x0); selSize.h = Math.round(bb.y1 - bb.y0) }
|
||
}
|
||
|
||
function onCanvasChange() { refreshMeta(); syncSelSize(); rev.value++ }
|
||
|
||
function onSelResize() {
|
||
if (selSize.w >= 300 && selSize.h >= 300) canvasRef.value?.resizeSelected(selSize.w, selSize.h)
|
||
}
|
||
function selectUnit(uid: number) { selectedUid.value = uid; canvasRef.value?.refresh() }
|
||
|
||
function refreshMeta() {
|
||
// 更新右侧清单的单元尺寸
|
||
units.value = units.value.map(u => {
|
||
const bb = bbOf(shapes.value.filter(s => s.__uid === u.uid))
|
||
return bb ? { ...u, w: Math.round(bb.x1 - bb.x0), h: Math.round(bb.y1 - bb.y0) } : u
|
||
})
|
||
}
|
||
|
||
function deleteUnit(uid: number) {
|
||
shapes.value = shapes.value.filter(s => s.__uid !== uid)
|
||
units.value = units.value.filter(u => u.uid !== uid)
|
||
if (selectedUid.value === uid) selectedUid.value = null
|
||
canvasRef.value?.refresh()
|
||
}
|
||
|
||
function deleteSelected() {
|
||
if (selectedUid.value !== null) deleteUnit(selectedUid.value)
|
||
}
|
||
|
||
function clearAll() {
|
||
shapes.value = []
|
||
units.value = []
|
||
selectedUid.value = null
|
||
saveName.value = ''
|
||
canvasRef.value?.refresh()
|
||
}
|
||
|
||
function unitBb(uid: number) { return bbOf(shapes.value.filter(s => s.__uid === uid)) }
|
||
|
||
function alignBottom() {
|
||
if (selectedUid.value === null) return
|
||
const others = units.value.filter(u => u.uid !== selectedUid.value)
|
||
.map(u => unitBb(u.uid)).filter(b => b)
|
||
if (!others.length) return
|
||
const target = Math.min(...others.map(b => b!.y0))
|
||
const bb = unitBb(selectedUid.value)!
|
||
translate(shapes.value.filter(s => s.__uid === selectedUid.value), 0, target - bb.y0)
|
||
canvasRef.value?.refresh()
|
||
ElMessage.success('已底边对齐')
|
||
}
|
||
|
||
// ---------- 保存 ----------
|
||
async function saveAsTemplate() {
|
||
const json = canvasRef.value?.exportDrawing()
|
||
if (!json) return
|
||
// 剥离内部标记字段
|
||
const dd = JSON.parse(json)
|
||
dd.sm = dd.sm.map((s: any) => {
|
||
const { __uid, __name, __templateId, ...rest } = s
|
||
void __uid; void __name; void __templateId
|
||
return rest
|
||
})
|
||
saving.value = true
|
||
try {
|
||
const d = await api<any>('post', '/api/windoor/templates', {
|
||
name: saveName.value.trim(),
|
||
drawing_data: JSON.stringify(dd),
|
||
catelog_id: null,
|
||
note: `组合样板(${units.value.map(u => u.name).join(' + ')})`,
|
||
})
|
||
ElMessage.success(`已保存为我的样板 #${d.id},可在「样板库 → 我的样板」使用`)
|
||
} finally { saving.value = false }
|
||
}
|
||
|
||
function onKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Delete' && selectedUid.value !== null) { deleteSelected(); return }
|
||
// 方向键微调选中单元(1mm,Shift=10mm)
|
||
if (selectedUid.value !== null && e.key.startsWith('Arrow')) {
|
||
const step = e.shiftKey ? 10 : 1
|
||
const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] }[e.key] as [number, number] | undefined
|
||
if (d) { e.preventDefault(); canvasRef.value?.nudgeSelected(d[0], d[1]) }
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadLib()
|
||
window.addEventListener('keydown', onKeydown)
|
||
})
|
||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||
</script>
|
||
|
||
<style scoped>
|
||
.combine-page { min-height: 100%; }
|
||
.head-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||
.lib-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||
.lib-item { border: 1px solid #e4e7ed; border-radius: 8px; padding: 6px; cursor: grab; text-align: center; transition: all .15s; }
|
||
.lib-item:hover { border-color: var(--el-color-primary); box-shadow: var(--shadow-card); }
|
||
.lib-item:active { cursor: grabbing; }
|
||
.lib-pic { width: 100%; height: 64px; pointer-events: none; }
|
||
.lib-name { font-size: 11px; color: #909399; display: block; overflow: hidden; text-overflow: ellipsis; }
|
||
.canvas-box { height: 62vh; border: 1px solid #e4e7ed; border-radius: 8px; overflow: hidden; }
|
||
.save-bar { display: flex; gap: 10px; margin-top: 12px; }
|
||
.unit { display: flex; align-items: center; gap: 8px; border: 1px solid #e4e7ed; border-radius: 8px;
|
||
padding: 8px; margin-bottom: 8px; background: #fff; cursor: pointer; }
|
||
.unit.pick { border-color: var(--el-color-primary); }
|
||
.unit-pic { width: 64px; height: 44px; flex-shrink: 0; }
|
||
.unit-info { flex: 1; min-width: 0; }
|
||
.unit-name { font-size: 13px; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.unit-size { font-size: 11px; color: #909399; }
|
||
.threed-box { height: 300px; }
|
||
.list-enter-active, .list-leave-active { transition: all .2s; }
|
||
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(12px); }
|
||
</style>
|