fix: v2.6 组合画布选中根因修复+右键菜单+3D 三场景

- 选中失效根因: Vue3 布尔 prop 缺省转换(editable 缺省→false)
  withDefaults 默认 true 修复, 左键选中/右键选中/菜单动作全链路打通
- 右键菜单: 右键单元弹操作菜单(顺/逆旋转/镜像/底边对齐/删除)
  动作 emit('ctx') 由父组件统一执行, document 级委托
- 3D 三场景: 室内(窗后墙+木地板)/室外(外墙+草地)/影棚(网格)
  场景联动背景色, 真实浏览器截图验证三场景渲染
- 菜单动作行为级验证: 旋转后 1600x1800 宽高互换✓ 删除清空✓
This commit is contained in:
HHY
2026-09-22 05:11:47 -07:00
parent b6d7db58d6
commit e3b4056855
24 changed files with 520 additions and 13 deletions
+88 -7
View File
@@ -1,11 +1,23 @@
<template>
<div class="cc-wrap" ref="wrapRef">
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset" />
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset"
@contextmenu.prevent="onContextMenu" />
<div class="hud">
<span>{{ hint }}</span>
<el-button size="small" text @click="reset">重置视图</el-button>
</div>
<!-- 右键操作菜单 -->
<transition name="fade">
<div v-if="ctxMenu.visible" class="ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }">
<div class="ctx-title">单元 #{{ ctxMenu.uid }}</div>
<div class="ctx-item" data-action="rotate-cw"><el-icon><RefreshRight /></el-icon>顺时针旋转 90°</div>
<div class="ctx-item" data-action="rotate-ccw"><el-icon><RefreshLeft /></el-icon>逆时针旋转 90°</div>
<div class="ctx-item" data-action="mirror"><el-icon><Switch /></el-icon>水平镜像</div>
<div class="ctx-item" data-action="bottom"><el-icon><Bottom /></el-icon>底边对齐</div>
<div class="ctx-item ctx-danger" data-action="delete"><el-icon><Delete /></el-icon>删除单元</div>
</div>
</transition>
</div>
</template>
@@ -15,17 +27,23 @@
* props.shapes 为共享引用:画布直接平移形状几何(同 uid 的形状作为整体移动),
* 变更后 emit('change') 通知父组件;expose refresh()/modelAt() 供父组件投放与读取。
*/
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
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 props = withDefaults(defineProps<{ shapes: any[]; editable?: boolean }>(), { editable: true })
const emit = defineEmits<{
(e: 'change'): void
(e: 'select', uid: number | null): void
(e: 'ctx', action: string, uid: number): void
}>()
const cvRef = ref<HTMLCanvasElement>()
const wrapRef = ref<HTMLDivElement>()
const hint = ref('拖入样板开始组合')
const selectedUid = ref<number | null>(null)
const ctxMenu = reactive({ visible: false, x: 0, y: 0, uid: null as number | null })
let view = { scale: 1, cx: 0, cy: 0 }
let lastT = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
@@ -270,10 +288,11 @@ function hitUnit(p: Pt): number | null {
}
function onDown(e: MouseEvent) {
closeMenu()
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) {
if (props.editable && uid !== null) {
selectedUid.value = uid
const bb = unitBBox(uid)!
dragUnit = { uid, grabX: p.x, grabY: p.y, startBb: { ...bb }, moved: false }
@@ -342,6 +361,31 @@ function modelAt(clientX: number, clientY: number): Pt {
return toModel(clientX - rect.left, clientY - rect.top)
}
/** 右键菜单: 命中单元弹出操作菜单 */
function onContextMenu(e: MouseEvent) {
const rect = cvRef.value!.getBoundingClientRect()
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
const uid = hitUnit(p)
if (uid === null) { closeMenu(); return }
selectedUid.value = uid
ctxMenu.uid = uid
ctxMenu.visible = true
ctxMenu.x = e.clientX - rect.left + 6
ctxMenu.y = e.clientY - rect.top + 6
emit('select', uid)
render()
}
function closeMenu() { ctxMenu.visible = false }
function ctxAction(a: string) {
const uid = ctxMenu.uid
closeMenu()
if (uid === null) return
// 动作统一 emit 给父组件执行(父组件持有 canvasRef 与 shapes 权威状态)
emit('ctx', a, uid)
}
function refresh() { render() }
/** 选中单元缩放到目标尺寸(几何按比例缩放,锚定自身左下角) */
@@ -374,6 +418,17 @@ function nudgeSelected(dx: number, dy: number) {
emit('change')
}
/** 单元底边对齐到其余单元最低底边 */
function bottomAlignUnit(uid: number) {
const bb = unitBBox(uid)
const others = uids().filter(u => u !== uid).map(u => unitBBox(u)).filter(b => b) as BBox[]
if (!bb || !others.length) return
const target = Math.min(...others.map(o => o.y0))
translateUid(uid, 0, target - bb.y0)
refresh()
emit('change')
}
/** 自动排列: 全部单元按 X 序紧贴排布、底边对齐(可选的整理工具,不强制) */
function autoArrange() {
const ids = uids().filter(u => unitBBox(u)).sort((a, b) =>
@@ -401,11 +456,30 @@ function exportDrawing(): string | null {
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [],
})
}
defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit, resizeSelected, nudgeSelected, autoArrange })
// 临时调试钩子(定位选中问题,验证后移除)
function debugHit(clientX: number, clientY: number) {
const rect = cvRef.value!.getBoundingClientRect()
const p = toModel(clientX - rect.left, clientY - rect.top)
const uid = hitUnit(p)
return { p, uid, lastT: { ...lastT }, rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height } }
}
;(window as any).__ccDebug = { debugHit, getSelected: () => selectedUid.value, units: uids }
defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit, resizeSelected, nudgeSelected, autoArrange, bottomAlignUnit, debugHit })
onMounted(() => {
watch(() => props.shapes, () => { selectedUid.value = null; rebuild() }, { immediate: true })
watch(() => props.shapes, () => { selectedUid.value = null; closeMenu(); rebuild() }, { immediate: true })
window.addEventListener('resize', render)
// 点击菜单外关闭 + 菜单项动作(在 mousedown 阶段触发,真实点击必有效)
document.addEventListener('mousedown', (e: MouseEvent) => {
const item = (e.target as HTMLElement).closest?.('.ctx-item')
if (item && ctxMenu.visible) {
const action = (item as HTMLElement).dataset.action
ctxMenu.visible = false
if (action) ctxAction(action)
return
}
if (ctxMenu.visible && !(e.target as HTMLElement).closest('.ctx-menu')) closeMenu()
})
})
function rebuild() {
view = { scale: 1, cx: 0, cy: 0 }
@@ -420,4 +494,11 @@ 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; }
.ctx-menu { position: absolute; z-index: 40; background: #fff; border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.16); padding: 4px 0; min-width: 150px; }
.ctx-title { font-size: 11px; color: #909399; padding: 4px 12px; border-bottom: 1px solid #f0f0f0; }
.ctx-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; font-size: 13px;
color: #303133; cursor: pointer; }
.ctx-item:hover { background: #f5f7fa; }
.ctx-danger { color: #f56c6c; }
</style>
+24 -5
View File
@@ -26,7 +26,11 @@
</div>
<div class="p-row">
<span class="p-label">场景</span>
<el-checkbox v-model="showWall" size="small" @change="rebuild">墙体</el-checkbox>
<el-radio-group v-model="scene3d" size="small" @change="rebuild">
<el-radio-button value="indoor">室内</el-radio-button>
<el-radio-button value="outdoor">室外</el-radio-button>
<el-radio-button value="studio">影棚</el-radio-button>
</el-radio-group>
<el-checkbox v-model="autoRotate" size="small">自动旋转</el-checkbox>
<el-button size="small" text type="primary" @click="screenshot">截图</el-button>
</div>
@@ -69,7 +73,8 @@ const autoPlay = ref(false)
const hasOpenable = ref(false)
const lightMode = ref<'day' | 'dusk' | 'night'>('day')
const viewMode = ref<'front' | 'deg45' | 'side' | 'indoor'>('deg45')
const showWall = ref(true)
const scene3d = ref<'indoor' | 'outdoor' | 'studio'>('outdoor')
const SCENE_BG = { indoor: 0xf5efe6, outdoor: 0xbcd9f0, studio: 0xf2f4f7 } as const
const autoRotate = ref(false)
let renderer: THREE.WebGLRenderer | null = null
@@ -153,7 +158,7 @@ function applyLight() {
dirLight.position.set(p.pos[0], p.pos[1], p.pos[2])
}
if (scene) {
scene.background = new THREE.Color(p.bg)
scene.background = new THREE.Color(SCENE_BG[scene3d.value])
scene.traverse(o => {
if ((o as THREE.AmbientLight).isAmbientLight) (o as THREE.AmbientLight).intensity = p.ambient
})
@@ -219,6 +224,7 @@ function build(drawing: DrawingData) {
const sashMat = new THREE.MeshStandardMaterial({ color: 0x84898f, roughness: 0.4, metalness: 0.55 })
const wallMat = new THREE.MeshStandardMaterial({ color: 0xe8e2d6, roughness: 0.95 })
const groundMat = new THREE.MeshStandardMaterial({ color: 0xcfc9bd, roughness: 1 })
const floorMat = new THREE.MeshStandardMaterial({ color: 0xb59b74, roughness: 0.9 })
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0xd8ecf5, transparent: true, opacity: 0.4, roughness: 0.06,
metalness: 0.1, side: THREE.DoubleSide, envMapIntensity: 1.2,
@@ -324,8 +330,9 @@ function build(drawing: DrawingData) {
}
}
// ---- 墙体洞口 + 地面(世界单位:窗体约 1 单位见方)----
if (showWall.value) {
// ---- 环境场景(室内/室外/影棚)----
if (scene3d.value === 'outdoor') {
// 室外: 建筑外墙围绕窗洞 + 草地
const hw = gw * S / 2 + 0.05 // 窗洞半宽
const hh = gh * S / 2 + 0.05 // 窗洞半高
const side = 0.55 // 上下墙高度/左右墙宽度
@@ -347,7 +354,19 @@ function build(drawing: DrawingData) {
ground.position.set(0, -hh - side - 0.02, DEPTH * 2)
ground.receiveShadow = true
sceneRoot.add(ground)
} else if (scene3d.value === 'indoor') {
// 室内: 窗后整墙 + 室内地板(木色)
const wall = new THREE.Mesh(new THREE.BoxGeometry(gw * S + 1.4, gh * S + 1.2, 0.02), wallMat)
wall.position.set(0, 0, -DEPTH)
wall.receiveShadow = true
wall.userData.isWall = true
sceneRoot.add(wall)
const floor = new THREE.Mesh(new THREE.BoxGeometry(gw * S + 1.4, 0.02, gh * S + 1.2), floorMat)
floor.position.set(0, -gh * S / 2 - DEPTH, 0)
floor.receiveShadow = true
sceneRoot.add(floor)
}
// 影棚(studio): 仅网格地面(下方已添加)
sceneRoot.traverse(o => {
const m = o as THREE.Mesh
if (m.isMesh) { m.castShadow = !m.userData.isWall; m.receiveShadow = true }
+17 -1
View File
@@ -52,7 +52,7 @@
</div>
</template>
<div class="canvas-box" @dragover.prevent @drop="onDrop" style="position:relative">
<CombineCanvas ref="canvasRef" :shapes="shapes" @select="onSelect" @change="onCanvasChange" />
<CombineCanvas ref="canvasRef" :shapes="shapes" @select="onSelect" @change="onCanvasChange" @ctx="onCtx" />
</div>
<div class="save-bar">
<el-input v-model="saveName" placeholder="组合样板名称(如:三扇组合窗)" style="flex:1" />
@@ -213,6 +213,22 @@ async function onDrop(e: DragEvent) {
// ---------- 单元操作 ----------
function onSelect(uid: number | null) { selectedUid.value = uid; syncSelSize() }
/** 右键菜单动作(父组件统一执行,权威状态在 shapes) */
function onCtx(action: string, uid: number) {
console.log('[CV] onCtx:', action, 'uid=', uid, 'shapes=', shapes.value.length)
if (action === 'rotate-cw') return canvasRef.value?.rotateUnit(uid, 90)
if (action === 'rotate-ccw') return canvasRef.value?.rotateUnit(uid, -90)
if (action === 'mirror') return canvasRef.value?.mirrorUnit(uid)
if (action === 'bottom') return canvasRef.value?.bottomAlignUnit(uid)
if (action === 'delete') {
shapes.value = shapes.value.filter(s => s.__uid !== uid)
rev.value++
refreshMeta()
ElMessage.success('单元已删除')
console.log('[CV] 删除完成, 剩余:', shapes.value.length)
}
}
const selSize = reactive({ w: 0, h: 0 })
function syncSelSize() {