feat: v1.4 组合画布旋转编辑+方向核验+双向门符号
- 组合画布旋转编辑: 选中单元 ⟲/⟳ 90°旋转(绕几何中心) 分割线法向与开向角度同步旋转, 水平镜像(开向镜像) - 数据方向核验: 以31729/31740真实图纸比对确认2D渲染Y向正确 - 2D渲染补双向开启门虚线交叉符号(wtf) - Node数学验证: 旋转后宽高互换/法向转轴/可逆性全部通过
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
- [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)
|
||||||
- [2026-09-20 23:40] FEAT: v1.2 样板组合工场+组合3D渲染+菜单分组。①样板组合(/combine): 样板库作为子组件点击加入, 多单元紧贴排布(平移归一), 等高对齐(参考首单元缩放), 删除/左右调序, 组合结果为标准多形状 drawing_data(与渲染/报价/AI炫图/3D全链路兼容), 可另存为我的样板; ②组合预览支持 2D/3D 双模式(Three.js 直接渲染组合 sm[]); ③侧边栏菜单分组: 销售业务/设计制图/生产供应/系统; 算法经 Node 几何验证(6形状无缝拼接/高度0误差) (Files: frontend/src/views/CombineView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts)
|
- [2026-09-20 23:40] FEAT: v1.2 样板组合工场+组合3D渲染+菜单分组。①样板组合(/combine): 样板库作为子组件点击加入, 多单元紧贴排布(平移归一), 等高对齐(参考首单元缩放), 删除/左右调序, 组合结果为标准多形状 drawing_data(与渲染/报价/AI炫图/3D全链路兼容), 可另存为我的样板; ②组合预览支持 2D/3D 双模式(Three.js 直接渲染组合 sm[]); ③侧边栏菜单分组: 销售业务/设计制图/生产供应/系统; 算法经 Node 几何验证(6形状无缝拼接/高度0误差) (Files: frontend/src/views/CombineView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts)
|
||||||
- [2026-09-20 23:45] FIX: 修复多页面"参数错误: page_size"——样板库接口 page_size 上限 le=200 但订单新建/AI炫图请求 254 条触发校验失败, 上限放宽至 le=1000; 同步修复 el-select 清空后 status 空串导致的参数错误(订单/合同/量尺/售后筛选归一化) (Files: src/huamenchuang/api/windoor.py, frontend/src/views/OrdersView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/MeasureView.vue, frontend/src/views/AfterSaleView.vue)
|
- [2026-09-20 23:45] FIX: 修复多页面"参数错误: page_size"——样板库接口 page_size 上限 le=200 但订单新建/AI炫图请求 254 条触发校验失败, 上限放宽至 le=1000; 同步修复 el-select 清空后 status 空串导致的参数错误(订单/合同/量尺/售后筛选归一化) (Files: src/huamenchuang/api/windoor.py, frontend/src/views/OrdersView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/MeasureView.vue, frontend/src/views/AfterSaleView.vue)
|
||||||
|
|||||||
@@ -66,6 +66,70 @@ function uids(): number[] {
|
|||||||
return [...new Set(props.shapes.map(s => s.__uid))]
|
return [...new Set(props.shapes.map(s => s.__uid))]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bbOfLocal(shapes: any[]): BBox | null {
|
||||||
|
const pts = shapes.flatMap(shapePts)
|
||||||
|
return pts.length ? bboxOf(pts) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 旋转/镜像编辑 ----------
|
||||||
|
/** 旋转整个单元(绕其几何中心),同步旋转分割线法向与开向角度 */
|
||||||
|
function rotateUnit(uid: number, deg: 90 | -90 | 180) {
|
||||||
|
const target = props.shapes.filter(s => s.__uid === uid)
|
||||||
|
const bb = bbOfLocal(target)
|
||||||
|
if (!bb) return
|
||||||
|
const cx2 = (bb.x0 + bb.x1) / 2, cy2 = (bb.y0 + bb.y1) / 2
|
||||||
|
const rad = (deg * Math.PI) / 180
|
||||||
|
const cos = Math.cos(rad), sin = Math.sin(rad)
|
||||||
|
const rotPt = (p: any) => {
|
||||||
|
const dx = p.x - cx2, dy = p.y - cy2
|
||||||
|
p.x = cx2 + dx * cos - dy * sin
|
||||||
|
p.y = cy2 + dx * sin + dy * cos
|
||||||
|
}
|
||||||
|
const rotVec = (v: any) => {
|
||||||
|
const nx = v.x ?? 1, ny = v.y ?? 0
|
||||||
|
v.x = nx * cos - ny * sin
|
||||||
|
v.y = nx * sin + ny * cos
|
||||||
|
}
|
||||||
|
for (const s of target) {
|
||||||
|
for (const r of (s.polygon?.polygon ?? [])) for (const seg of r) { rotPt(seg.ps); rotPt(seg.pe) }
|
||||||
|
for (const sp of (s.mm?.sp ?? [])) { rotPt(sp.line.pt); if (sp.line.norm) rotVec(sp.line.norm) }
|
||||||
|
for (const sub of (s.sm ?? [])) {
|
||||||
|
if (sub.bd) { rotPt(sub.bd.from); rotPt(sub.bd.to) }
|
||||||
|
if (typeof sub.od === 'number') sub.od = (sub.od + deg + 360) % 360
|
||||||
|
if (Array.isArray(sub.sashes)) for (const e of sub.sashes) {
|
||||||
|
if (typeof e?.od === 'number') e.od = (e.od + deg + 360) % 360
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
emit('change')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 水平镜像整个单元(开向同步镜像) */
|
||||||
|
function mirrorUnit(uid: number) {
|
||||||
|
const target = props.shapes.filter(s => s.__uid === uid)
|
||||||
|
const bb = bbOfLocal(target)
|
||||||
|
if (!bb) return
|
||||||
|
const cx2 = (bb.x0 + bb.x1) / 2
|
||||||
|
const mirPt = (p: any) => { p.x = 2 * cx2 - p.x }
|
||||||
|
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 sp of (s.mm?.sp ?? [])) {
|
||||||
|
mirPt(sp.line.pt)
|
||||||
|
if (sp.line.norm) sp.line.norm.x = -(sp.line.norm.x ?? 1)
|
||||||
|
}
|
||||||
|
for (const sub of (s.sm ?? [])) {
|
||||||
|
if (sub.bd) { mirPt(sub.bd.from); mirPt(sub.bd.to) }
|
||||||
|
if (typeof sub.od === 'number') sub.od = (180 - sub.od + 360) % 360
|
||||||
|
if (Array.isArray(sub.sashes)) for (const e of sub.sashes) {
|
||||||
|
if (typeof e?.od === 'number') e.od = (180 - e.od + 360) % 360
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
emit('change')
|
||||||
|
}
|
||||||
|
|
||||||
// 吸附: 单元边缘贴近其他单元边缘时对齐(返回修正后的位置与辅助线)
|
// 吸附: 单元边缘贴近其他单元边缘时对齐(返回修正后的位置与辅助线)
|
||||||
function snap(bb: BBox, excludeUid: number): { dx: number; dy: number; guides: { v: number[]; h: number[] } } {
|
function snap(bb: BBox, excludeUid: number): { dx: number; dy: number; guides: { v: number[]; h: number[] } } {
|
||||||
const TOL = 12
|
const TOL = 12
|
||||||
@@ -251,7 +315,7 @@ function exportDrawing(): string | null {
|
|||||||
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [],
|
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
defineExpose({ refresh, modelAt, exportDrawing })
|
defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
watch(() => props.shapes, () => { selectedUid.value = null; rebuild() }, { immediate: true })
|
watch(() => props.shapes, () => { selectedUid.value = null; rebuild() }, { immediate: true })
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ function drawFrame(
|
|||||||
|
|
||||||
// 扇(推断: 有效bd → polyId区域(双扇对半) → 帧bbox按扇数均分)
|
// 扇(推断: 有效bd → polyId区域(双扇对半) → 帧bbox按扇数均分)
|
||||||
const regionBBs = regions.map(pts => bboxOf(pts))
|
const regionBBs = regions.map(pts => bboxOf(pts))
|
||||||
type Glyph = { x0: number; y0: number; x1: number; y1: number; od: number; openToward?: string; isDoor?: boolean }
|
type Glyph = { x0: number; y0: number; x1: number; y1: number; od: number; openToward?: string; isDoor?: boolean; wtf?: boolean }
|
||||||
const glyphs: Glyph[] = []
|
const glyphs: Glyph[] = []
|
||||||
const bdRect = (bd: NonNullable<SashShape['bd']>) => {
|
const bdRect = (bd: NonNullable<SashShape['bd']>) => {
|
||||||
const r = {
|
const r = {
|
||||||
@@ -263,6 +263,7 @@ function drawFrame(
|
|||||||
od: (e.od ?? sub.od ?? 90) as number,
|
od: (e.od ?? sub.od ?? 90) as number,
|
||||||
openToward: (e.openToward ?? sub.openToward) as string | undefined,
|
openToward: (e.openToward ?? sub.openToward) as string | undefined,
|
||||||
isDoor: Boolean(e.isDoor ?? sub.isDoor),
|
isDoor: Boolean(e.isDoor ?? sub.isDoor),
|
||||||
|
wtf: Boolean(e.wtf ?? sub.wtf ?? sub.withTurningFrame),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,22 +317,32 @@ function drawFrame(
|
|||||||
ctx.lineWidth = 1.3
|
ctx.lineWidth = 1.3
|
||||||
ctx.strokeRect(bx0 + 2, by0 + 2, w - 4, h - 4)
|
ctx.strokeRect(bx0 + 2, by0 + 2, w - 4, h - 4)
|
||||||
ctx.setLineDash([])
|
ctx.setLineDash([])
|
||||||
const ang = ((g.od || 90) * Math.PI) / 180
|
if (g.wtf) {
|
||||||
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
|
// 双向开启门:对角虚线交叉符号(对齐原站图纸样式)
|
||||||
const L = Math.min(w, h) * 0.32
|
ctx.strokeStyle = 'rgba(70,76,82,0.7)'
|
||||||
const ax = cx + Math.cos(ang) * L, ay = cy - Math.sin(ang) * L
|
ctx.lineWidth = 1
|
||||||
const wx = Math.cos(ang + Math.PI / 2) * L * 0.38
|
ctx.beginPath()
|
||||||
const wy = -Math.sin(ang + Math.PI / 2) * L * 0.38
|
ctx.moveTo(bx0 + 4, by0 + 4); ctx.lineTo(bx1 - 4, by1 - 4)
|
||||||
ctx.beginPath()
|
ctx.moveTo(bx1 - 4, by0 + 4); ctx.lineTo(bx0 + 4, by1 - 4)
|
||||||
ctx.moveTo(ax, ay)
|
ctx.stroke()
|
||||||
ctx.lineTo(cx - wx, cy - wy)
|
} else {
|
||||||
ctx.lineTo(cx + wx, cy + wy)
|
const ang = ((g.od || 90) * Math.PI) / 180
|
||||||
ctx.closePath()
|
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
|
||||||
ctx.fillStyle = g.openToward === 'inward' ? 'rgba(255,255,255,0.55)' : 'rgba(70,76,82,0.25)'
|
const L = Math.min(w, h) * 0.32
|
||||||
ctx.fill()
|
const ax = cx + Math.cos(ang) * L, ay = cy - Math.sin(ang) * L
|
||||||
ctx.strokeStyle = 'rgba(70,76,82,0.95)'
|
const wx = Math.cos(ang + Math.PI / 2) * L * 0.38
|
||||||
ctx.lineWidth = 1.4
|
const wy = -Math.sin(ang + Math.PI / 2) * L * 0.38
|
||||||
ctx.stroke()
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(ax, ay)
|
||||||
|
ctx.lineTo(cx - wx, cy - wy)
|
||||||
|
ctx.lineTo(cx + wx, cy + wy)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fillStyle = g.openToward === 'inward' ? 'rgba(255,255,255,0.55)' : 'rgba(70,76,82,0.25)'
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = 'rgba(70,76,82,0.95)'
|
||||||
|
ctx.lineWidth = 1.4
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
if (g.isDoor) {
|
if (g.isDoor) {
|
||||||
ctx.fillStyle = 'rgba(70,76,82,0.85)'
|
ctx.fillStyle = 'rgba(70,76,82,0.85)'
|
||||||
ctx.font = '10px sans-serif'
|
ctx.font = '10px sans-serif'
|
||||||
|
|||||||
@@ -25,6 +25,16 @@
|
|||||||
<div class="head-row">
|
<div class="head-row">
|
||||||
<span>组合画布({{ units.length }} 个单元 · 拖动摆放 · 边缘自动吸附)</span>
|
<span>组合画布({{ units.length }} 个单元 · 拖动摆放 · 边缘自动吸附)</span>
|
||||||
<div style="display:flex; gap:8px; align-items:center">
|
<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>
|
||||||
|
<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>
|
||||||
<el-button size="small" :disabled="!shapes.length" @click="clearAll">清空</el-button>
|
<el-button size="small" :disabled="!shapes.length" @click="clearAll">清空</el-button>
|
||||||
|
|||||||
Reference in New Issue
Block a user