feat: v1.6 组合画布编辑能力增强
- 选中单元可视化: 蓝色虚线框+四角手柄+实时尺寸标注 - 精确尺寸编辑: 工具栏 W/H 输入框(几何按比例缩放, 锚定左下角) - 方向键微调: 1mm/Shift+方向键 10mm - 右侧单元清单点击选中与画布联动, selSize 双向同步
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
- [2026-09-21 10:55] FEAT: v1.6 组合画布编辑能力增强——①选中单元可视化: 蓝色虚线框+四角手柄+实时尺寸标注(W×H mm); ②精确尺寸编辑: 工具栏 W/H 输入框(选中单元几何按比例缩放, 锚定左下角, 中梃点位同步); ③方向键微调: 1mm/Shift 10mm; ④右侧单元清单点击选中联动画布; selSize 双向同步(画布拖拽/输入框改动均实时刷新) (Files: frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue)
|
||||||
- [2026-09-21 10:30] FEAT: v1.5 3D 门窗开合动画——①扇推断逻辑抽取为共享模块 inferSashGlyphs(types/drawing.ts), 2D 符号与 3D 动画共用; ②WindoorThreed 重写: 每个扇构建独立网格组(玻璃+扇框), 平开扇绕铰链旋转(od 定铰链侧, 门75°/平开32°/双向门40°), 推拉扇沿轨道平移(双扇前后轨交替), 区域归属扇的不再重复渲染静态玻璃; ③开合控制条: 开合度滑杆+自动演示(正弦摆动)+全开/全关, 平滑插值动画; 组合画布 3D 预览自动获得开合能力 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorThreed.vue, frontend/src/components/WindoorCanvas.vue)
|
- [2026-09-21 10:30] FEAT: v1.5 3D 门窗开合动画——①扇推断逻辑抽取为共享模块 inferSashGlyphs(types/drawing.ts), 2D 符号与 3D 动画共用; ②WindoorThreed 重写: 每个扇构建独立网格组(玻璃+扇框), 平开扇绕铰链旋转(od 定铰链侧, 门75°/平开32°/双向门40°), 推拉扇沿轨道平移(双扇前后轨交替), 区域归属扇的不再重复渲染静态玻璃; ③开合控制条: 开合度滑杆+自动演示(正弦摆动)+全开/全关, 平滑插值动画; 组合画布 3D 预览自动获得开合能力 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorThreed.vue, frontend/src/components/WindoorCanvas.vue)
|
||||||
- [2026-09-21 00:40] FEAT: v1.4 旋转编辑+方向核验+双向门符号。①组合画布旋转编辑: 选中单元⟲/⟳90°旋转(绕几何中心, 分割线法向/开向角度同步旋转)与水平镜像, 工具栏按钮+Del删除; Node数学验证(宽高互换/法向转轴/可逆性✓); ②数据方向核验: 以31729(扇上固定下)与31740(裙板下)真实图纸比对, 确认2D渲染Y翻转方向正确(模型+y=屏幕上); ③2D渲染补双向开启门虚线交叉符号(wtf/withTurningFrame) (Files: frontend/src/components/CombineCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/views/CombineView.vue)
|
- [2026-09-21 00:40] FEAT: v1.4 旋转编辑+方向核验+双向门符号。①组合画布旋转编辑: 选中单元⟲/⟳90°旋转(绕几何中心, 分割线法向/开向角度同步旋转)与水平镜像, 工具栏按钮+Del删除; Node数学验证(宽高互换/法向转轴/可逆性✓); ②数据方向核验: 以31729(扇上固定下)与31740(裙板下)真实图纸比对, 确认2D渲染Y翻转方向正确(模型+y=屏幕上); ③2D渲染补双向开启门虚线交叉符号(wtf/withTurningFrame) (Files: frontend/src/components/CombineCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/views/CombineView.vue)
|
||||||
- [2026-09-21 00:10] FEAT: v1.3 样板组合升级为画布形式——交互对齐原站画图编辑器(库面板可拖拽+shapeManager按位添加)。新组件 CombineCanvas: 拖拽样板入库投放(落点即中心)/自由拖移(同uid单元整体移动)/边缘吸附对齐(红色辅助线,12px容差)/点选高亮/底边对齐/Del删除/平移缩放; CombineView 重构为画布交互(拖放+点击双入口), 保留2D编辑与3D预览切换、另存我的样板(导出时剥离内部标记字段); 原"顺序拼接"模式由画布自由摆放取代 (Files: frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue)
|
- [2026-09-21 00:10] FEAT: v1.3 样板组合升级为画布形式——交互对齐原站画图编辑器(库面板可拖拽+shapeManager按位添加)。新组件 CombineCanvas: 拖拽样板入库投放(落点即中心)/自由拖移(同uid单元整体移动)/边缘吸附对齐(红色辅助线,12px容差)/点选高亮/底边对齐/Del删除/平移缩放; CombineView 重构为画布交互(拖放+点击双入口), 保留2D编辑与3D预览切换、另存我的样板(导出时剥离内部标记字段); 原"顺序拼接"模式由画布自由摆放取代 (Files: frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue)
|
||||||
|
|||||||
@@ -218,6 +218,27 @@ function render() {
|
|||||||
for (const h of guides.h) { ctx.beginPath(); ctx.moveTo(0, ty(h)); ctx.lineTo(W, ty(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([])
|
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`
|
hint.value = `${uids().length} 个单元 · 总尺寸 ${Math.round(gw)} × ${Math.round(gh)} mm`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +329,37 @@ function modelAt(clientX: number, clientY: number): Pt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refresh() { render() }
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
function exportDrawing(): string | null {
|
function exportDrawing(): string | null {
|
||||||
if (!props.shapes.length) return null
|
if (!props.shapes.length) return null
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
@@ -315,7 +367,7 @@ function exportDrawing(): string | null {
|
|||||||
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [],
|
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit })
|
defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit, resizeSelected, nudgeSelected })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
watch(() => props.shapes, () => { selectedUid.value = null; rebuild() }, { immediate: true })
|
watch(() => props.shapes, () => { selectedUid.value = null; rebuild() }, { immediate: true })
|
||||||
|
|||||||
@@ -34,6 +34,13 @@
|
|||||||
<el-tooltip content="选中单元水平镜像" placement="top">
|
<el-tooltip content="选中单元水平镜像" placement="top">
|
||||||
<el-button size="small" :disabled="!selectedUid" @click="canvasRef?.mirrorUnit(selectedUid!)">⇋ 镜像</el-button>
|
<el-button size="small" :disabled="!selectedUid" @click="canvasRef?.mirrorUnit(selectedUid!)">⇋ 镜像</el-button>
|
||||||
</el-tooltip>
|
</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-divider direction="vertical" />
|
||||||
<el-button size="small" :disabled="!selectedUid" @click="alignBottom">底边对齐</el-button>
|
<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="!selectedUid" type="danger" plain @click="deleteSelected">删除单元</el-button>
|
||||||
@@ -42,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="canvas-box" @drop="onDrop" style="position:relative">
|
<div class="canvas-box" @drop="onDrop" style="position:relative">
|
||||||
<CombineCanvas ref="canvasRef" :shapes="shapes" @select="onSelect" @change="refreshMeta" />
|
<CombineCanvas ref="canvasRef" :shapes="shapes" @select="onSelect" @change="onCanvasChange" />
|
||||||
</div>
|
</div>
|
||||||
<div class="save-bar">
|
<div class="save-bar">
|
||||||
<el-input v-model="saveName" placeholder="组合样板名称(如:三扇组合窗)" style="flex:1" />
|
<el-input v-model="saveName" placeholder="组合样板名称(如:三扇组合窗)" style="flex:1" />
|
||||||
@@ -84,7 +91,7 @@
|
|||||||
* 数据模型:shapes = 多形状 drawing_data 的 sm[](每形状打 __uid 单元标记);
|
* 数据模型:shapes = 多形状 drawing_data 的 sm[](每形状打 __uid 单元标记);
|
||||||
* 组合结果与渲染/报价/AI 炫图/3D 全链路兼容,可另存为"我的样板"。
|
* 组合结果与渲染/报价/AI 炫图/3D 全链路兼容,可另存为"我的样板"。
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { parseDrawing } from '../types/drawing'
|
import { parseDrawing } from '../types/drawing'
|
||||||
@@ -181,7 +188,21 @@ async function onDrop(e: DragEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 单元操作 ----------
|
// ---------- 单元操作 ----------
|
||||||
function onSelect(uid: number | null) { selectedUid.value = uid }
|
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() }
|
||||||
|
|
||||||
|
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 selectUnit(uid: number) { selectedUid.value = uid; canvasRef.value?.refresh() }
|
||||||
|
|
||||||
function refreshMeta() {
|
function refreshMeta() {
|
||||||
@@ -249,7 +270,13 @@ async function saveAsTemplate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Delete' && selectedUid.value !== null) deleteSelected()
|
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 () => {
|
onMounted(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user