fix: v2.19 组合水平镜像的 3D 开向同步+放大层菜单可见性
- mirrorUnit 补铰链侧换向: hm.openDirection/sashes.openDirection left↔right 镜像(含变体) - 数学确认 mirPt+norm.x 翻转组合下重放树拓扑保持, 扇 polyId 无需换位 - rotateUnit 补 hm.openDirection 刚体旋转换向(left→up→right→down) - ctx-menu z-index 40→2100, 放大预览时右键菜单不再被盖 - 验证: 镜像前后开合 50% 对比截图(铰链侧正确换向), 全量校验 0 失败
@@ -1,5 +1,7 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
- [2026-09-23 09:00] FIX: v2.19 组合水平镜像的 3D 开向同步+放大层菜单可见性。①mirrorUnit 补铰链侧换向: hm.openDirection/sashes.openDirection left↔right(含 *_with_* 变体, up 不变)——原镜像后 3D 开合动画仍绕镜像前铰链侧(用户报"3D 不能水平镜像"的实质); 数学确认 mirPt+norm.x 翻转组合下重放树拓扑保持, 扇 polyId 无需换位; ②rotateUnit 补 hm.openDirection 刚体旋转换向(顺时针 left→up→right→down, *_with_* 两段分别映射); ③ctx-menu z-index 40→2100(放大预览层 z-2001 之上, 放大时右键菜单不再被盖); 验证: 真实浏览器镜像前后开合 50% 对比截图(镜像前左下扇绕左铰链外开→镜像后右下扇绕右铰链外开), 全量校验 0 失败 (Files: frontend/src/components/CombineCanvas.vue, shots/mirror-*.png)
|
||||||
|
|
||||||
- [2026-09-23 08:20] TEST: v2.18 全量验证适配——254 套样板自动化校验闭环。新增 verify-all-samples.mjs(真实编译模块单一事实源): 校验重放区域有效性/扇矩形有效且在窗体内/扇-区域覆盖度>=0.55(抓挂接错位)/扇间无重叠(抓叠放), 发现并修复 29 项: ①AntiTheft/KfcSash 内倒辅扇与玻璃扇同格叠放(34588/31691/31706 等)→同格叠扇去重(IoU>0.15 仅渲染首层, 真实开启扇几何上不重叠); ②幽灵形状(空段/无效点序列化残留, 31724/32154)合法跳过; 终态: 254 样本/409 形状/1247 区域/570 扇 失败 0, validate3d 3878 网格 0 无效, vue-tsc 0 错误; 可视化抽样 10 类代表样本(31337 对开门/31340 四扇/31343 对开门/31345 门/31594 纱扇/31691 内倒/31706/31729/34606/34841 八扇) 3D 结构与官方图逐一比对通过 (Files: frontend/src/types/drawing.ts, frontend/scripts/verify-all-samples.mjs, shots/batch3d-*.png)
|
- [2026-09-23 08:20] TEST: v2.18 全量验证适配——254 套样板自动化校验闭环。新增 verify-all-samples.mjs(真实编译模块单一事实源): 校验重放区域有效性/扇矩形有效且在窗体内/扇-区域覆盖度>=0.55(抓挂接错位)/扇间无重叠(抓叠放), 发现并修复 29 项: ①AntiTheft/KfcSash 内倒辅扇与玻璃扇同格叠放(34588/31691/31706 等)→同格叠扇去重(IoU>0.15 仅渲染首层, 真实开启扇几何上不重叠); ②幽灵形状(空段/无效点序列化残留, 31724/32154)合法跳过; 终态: 254 样本/409 形状/1247 区域/570 扇 失败 0, validate3d 3878 网格 0 无效, vue-tsc 0 错误; 可视化抽样 10 类代表样本(31337 对开门/31340 四扇/31343 对开门/31345 门/31594 纱扇/31691 内倒/31706/31729/34606/34841 八扇) 3D 结构与官方图逐一比对通过 (Files: frontend/src/types/drawing.ts, frontend/scripts/verify-all-samples.mjs, shots/batch3d-*.png)
|
||||||
|
|
||||||
- [2026-09-23 07:40] FIX: v2.17 3D 建模对齐样板图——门/纱扇/多扇语义修复(官方图逐样本实证)。①kind 判定收敛: 仅 Slide=推拉, DoubleSash=双扇对开门(31343)/DoubleKfcSash=内开内倒对门(31337) 按平开——原 DoubleSash 误判推拉致"门渲染成窗户"; ②纱扇(Screen/DoubleScreen 179 处)与玻璃扇同格挂接, 不再单独渲染(消除同格双层玻璃叠放); ③同格多扇沿宽度等分(forceRect 忽略 entry 重复 polyId): 31337 对开两扇对半+isDoor 合并(e.isDoor||sub.isDoor, 修 31337 门被标 false), 31729 上排 6 滑扇等分(原 6 层重叠), 31343 对开门左右各半; ④2D 图纸补纱扇网格纹理(Screen 官方画法); 验证: 31337 对开门 3D 关/开截图与官方图结构一致, 31729 上滑扇+下固定一致, validate3d 254 样板 615 扇 0 无效, ui-shots 6 页无报错 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorCanvas.vue, frontend/scripts/verify-door.mjs, shots/door-*.png, shots/verify-31729-3d.png)
|
- [2026-09-23 07:40] FIX: v2.17 3D 建模对齐样板图——门/纱扇/多扇语义修复(官方图逐样本实证)。①kind 判定收敛: 仅 Slide=推拉, DoubleSash=双扇对开门(31343)/DoubleKfcSash=内开内倒对门(31337) 按平开——原 DoubleSash 误判推拉致"门渲染成窗户"; ②纱扇(Screen/DoubleScreen 179 处)与玻璃扇同格挂接, 不再单独渲染(消除同格双层玻璃叠放); ③同格多扇沿宽度等分(forceRect 忽略 entry 重复 polyId): 31337 对开两扇对半+isDoor 合并(e.isDoor||sub.isDoor, 修 31337 门被标 false), 31729 上排 6 滑扇等分(原 6 层重叠), 31343 对开门左右各半; ④2D 图纸补纱扇网格纹理(Screen 官方画法); 验证: 31337 对开门 3D 关/开截图与官方图结构一致, 31729 上滑扇+下固定一致, validate3d 254 样板 615 扇 0 无效, ui-shots 6 页无报错 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorCanvas.vue, frontend/scripts/verify-door.mjs, shots/door-*.png, shots/verify-31729-3d.png)
|
||||||
|
|||||||
@@ -93,6 +93,21 @@ function bbOfLocal(shapes: any[]): BBox | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 旋转/镜像编辑 ----------
|
// ---------- 旋转/镜像编辑 ----------
|
||||||
|
/** 铰链侧随刚体旋转换向: 顺时针 left→up→right→down→left */
|
||||||
|
function rotDir(d: unknown, deg: number) {
|
||||||
|
const s = String(d ?? '')
|
||||||
|
if (!s) return undefined
|
||||||
|
const order = ['left', 'up', 'right', 'down']
|
||||||
|
const mapOne = (one: string) => {
|
||||||
|
const i = order.indexOf(one)
|
||||||
|
if (i < 0) return one
|
||||||
|
const step = ((deg / 90) % 4 + 4) % 4 // 顺时针步数
|
||||||
|
return order[(i + step) % 4]
|
||||||
|
}
|
||||||
|
// 形如 left_with_down: 两段分别映射
|
||||||
|
return s.split('_with_').map(mapOne).join('_with_')
|
||||||
|
}
|
||||||
|
|
||||||
/** 旋转整个单元(绕其几何中心),同步旋转分割线法向与开向角度 */
|
/** 旋转整个单元(绕其几何中心),同步旋转分割线法向与开向角度 */
|
||||||
function rotateUnit(uid: number, deg: 90 | -90 | 180) {
|
function rotateUnit(uid: number, deg: 90 | -90 | 180) {
|
||||||
const target = props.shapes.filter(s => s.__uid === uid)
|
const target = props.shapes.filter(s => s.__uid === uid)
|
||||||
@@ -117,8 +132,11 @@ function rotateUnit(uid: number, deg: 90 | -90 | 180) {
|
|||||||
for (const sub of (s.sm ?? [])) {
|
for (const sub of (s.sm ?? [])) {
|
||||||
if (sub.bd) { rotPt(sub.bd.from); rotPt(sub.bd.to) }
|
if (sub.bd) { rotPt(sub.bd.from); rotPt(sub.bd.to) }
|
||||||
if (typeof sub.od === 'number') sub.od = (sub.od + deg + 360) % 360
|
if (typeof sub.od === 'number') sub.od = (sub.od + deg + 360) % 360
|
||||||
|
const hm = (sub as any).hm
|
||||||
|
if (hm?.openDirection) hm.openDirection = rotDir(hm.openDirection, deg)
|
||||||
if (Array.isArray(sub.sashes)) for (const e of sub.sashes) {
|
if (Array.isArray(sub.sashes)) for (const e of sub.sashes) {
|
||||||
if (typeof e?.od === 'number') e.od = (e.od + deg + 360) % 360
|
if (typeof e?.od === 'number') e.od = (e.od + deg + 360) % 360
|
||||||
|
if ((e as any)?.hm?.openDirection) (e as any).hm.openDirection = rotDir((e as any).hm.openDirection, deg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,13 +144,20 @@ function rotateUnit(uid: number, deg: 90 | -90 | 180) {
|
|||||||
emit('change')
|
emit('change')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 水平镜像整个单元(开向同步镜像) */
|
/** 水平镜像整个单元(几何+分割线法向+开向角度+铰链侧同步镜像) */
|
||||||
function mirrorUnit(uid: number) {
|
function mirrorUnit(uid: number) {
|
||||||
const target = props.shapes.filter(s => s.__uid === uid)
|
const target = props.shapes.filter(s => s.__uid === uid)
|
||||||
const bb = bbOfLocal(target)
|
const bb = bbOfLocal(target)
|
||||||
if (!bb) return
|
if (!bb) return
|
||||||
const cx2 = (bb.x0 + bb.x1) / 2
|
const cx2 = (bb.x0 + bb.x1) / 2
|
||||||
const mirPt = (p: any) => { p.x = 2 * cx2 - p.x }
|
const mirPt = (p: any) => { p.x = 2 * cx2 - p.x }
|
||||||
|
// 铰链侧换向: left↔right(含 *_with_* 变体), up 不变
|
||||||
|
const flipDir = (d: unknown) => {
|
||||||
|
const s = String(d ?? '')
|
||||||
|
if (s.startsWith('left')) return 'right' + s.slice(4)
|
||||||
|
if (s.startsWith('right')) return 'left' + s.slice(5)
|
||||||
|
return s || undefined
|
||||||
|
}
|
||||||
for (const s of target) {
|
for (const s of target) {
|
||||||
for (const r of (s.polygon?.polygon ?? [])) for (const seg of r) { mirPt(seg.ps); mirPt(seg.pe) }
|
for (const r of (s.polygon?.polygon ?? [])) for (const seg of r) { mirPt(seg.ps); mirPt(seg.pe) }
|
||||||
for (const sp of (s.mm?.sp ?? [])) {
|
for (const sp of (s.mm?.sp ?? [])) {
|
||||||
@@ -142,8 +167,12 @@ function mirrorUnit(uid: number) {
|
|||||||
for (const sub of (s.sm ?? [])) {
|
for (const sub of (s.sm ?? [])) {
|
||||||
if (sub.bd) { mirPt(sub.bd.from); mirPt(sub.bd.to) }
|
if (sub.bd) { mirPt(sub.bd.from); mirPt(sub.bd.to) }
|
||||||
if (typeof sub.od === 'number') sub.od = (180 - sub.od + 360) % 360
|
if (typeof sub.od === 'number') sub.od = (180 - sub.od + 360) % 360
|
||||||
|
const hm = (sub as any).hm
|
||||||
|
if (hm?.openDirection) hm.openDirection = flipDir(hm.openDirection)
|
||||||
if (Array.isArray(sub.sashes)) for (const e of sub.sashes) {
|
if (Array.isArray(sub.sashes)) for (const e of sub.sashes) {
|
||||||
if (typeof e?.od === 'number') e.od = (180 - e.od + 360) % 360
|
if (typeof e?.od === 'number') e.od = (180 - e.od + 360) % 360
|
||||||
|
if ((e as any)?.hm?.openDirection) (e as any).hm.openDirection = flipDir((e as any).hm.openDirection)
|
||||||
|
if (e?.openDirection) e.openDirection = flipDir(e.openDirection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,7 +581,7 @@ canvas { width: 100%; height: 100%; display: block; cursor: grab; }
|
|||||||
canvas:active { cursor: grabbing; }
|
canvas:active { cursor: grabbing; }
|
||||||
.hud { position: absolute; left: 10px; top: 10px; background: rgba(255,255,255,0.9);
|
.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; }
|
border-radius: 6px; padding: 3px 10px; font-size: 12px; color: #606266; }
|
||||||
.ctx-menu { position: absolute; z-index: 40; background: #fff; border-radius: 8px;
|
.ctx-menu { position: absolute; z-index: 2100; background: #fff; border-radius: 8px;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.16); padding: 4px 0; min-width: 150px; }
|
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-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;
|
.ctx-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; font-size: 13px;
|
||||||
|
|||||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 97 KiB |