feat: v0.6 分割树架构——横梃竖梃完整编辑
- 逆向确认原站设计思路: 基底多边形+mm.sp分割树(polyId指向作用区域), 运行时重放得到最终窗格(254套样本验证重放语义) - 前端 replayShape: 分割树重放还原真实网格(平开1矩形→4-7格) - 中梃编辑: 点击选中高亮/拖动移动/删除(直接操作sp源真值后重放) - 加梃升级: polyId.idx 精确指向被点窗格 - Python报价算料同语义重放: 中梃长度精确到实际作用跨度 - 方案文档§3.3: 原站设计思路+编辑器完成设计
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# CHANGELOG
|
||||
|
||||
- [2026-09-20 11:15] FEAT: v0.6 分割树架构——横梃竖梃完整编辑。①逆向确认原站设计思路: polygon.polygon为基底多边形+mm.sp[]为分割树(polyId指向作用区域), 运行时重放得最终窗格(254样本验证); ②前端 replayShape 分割树重放, 网格渲染真实化(平开1矩形→4-7格); ③中梃编辑: 点击选中(高亮)/拖动移动(line.pt更新→重放,30mm钳制)/删除(移除sp→重放), 加梃指向被点窗格; ④Python侧同语义replay_shape, 报价中梃长度精确到实际作用跨度(2375/2350/1642/979mm); ⑤方案文档§3.3更新原站设计思路与编辑器完成设计 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, src/huamenchuang/core/drawing.py, src/huamenchuang/services/quote_service.py, doc/克隆实施方案.md)
|
||||
- [2026-09-20 10:39] FEAT: v0.5 画布尺寸编辑+自动报价+扇样式保真修复——①参数化尺寸编辑: W/H输入框与拖拽外框右缘/下缘/右下角手柄, 全部几何按比例自动缩放(料宽不变,最小300mm), 拖拽静默/松手提交, 撤销栈; ②自动报价: 画布变化600ms防抖自动调offer-price并实时显示总价(样板库/订单详情); ③修复"样板显示为默认样式": 扇样式推断引擎三级解析(有效bd→polyId区域映射双扇对半→帧bbox按扇数竖向均分), 还原真实分格/开向(od角度/inward空心标记/门标注), 无图纸订单显示引导提示 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/views/*, doc/API.md)
|
||||
- [2026-09-20 09:04] FEAT: v0.4 画布编辑器(拖拽布置v1)——WindoorCanvas 增加 editable 模式:+竖梃/+横梃点击玻璃区域实时分割(两次交叉点法多边形重算+mm.sp分割线同步)、撤销栈;订单详情可保存图纸(PUT orders),样板库可另存我的样板(新增 POST windoor/templates is_default=0);10接口回归全通过 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/views/*, src/huamenchuang/api/windoor.py, src/huamenchuang/schemas/windoor.py, doc/API.md)
|
||||
- [2026-09-20 09:35] FEAT: v0.3 报价算料+仓库库存+前端扩展——报价引擎(从drawing_data几何提取外框/中梃/扇框型材清单+玻璃鞋带面积,套价格本计价);仓库模块(材料CRUD/出入库/流水/负库存拦截/预警);前端新增仓库页与报价弹窗(样板库/订单详情可唤起);SQLite路径锚定项目根修复cwd问题;全接口回归9/9通过 (Files: src/huamenchuang/**, frontend/**, doc/API.md)
|
||||
|
||||
+22
-9
@@ -132,16 +132,29 @@ frontend/
|
||||
└── dist -> 后端静态挂载 # 构建产物挂载(符合前后端规范)
|
||||
```
|
||||
|
||||
### 3.3 拖拽布置编辑器(核心难点)复刻方案
|
||||
### 3.3 拖拽布置编辑器(核心难点)复刻方案 —— v2 已实现
|
||||
|
||||
1. **数据层**:直接采用 §2.2 解析出的模型(`drawing_data` 已有 254 个真实样本作为测试集,兼容读入即等于拥有 254 个回归用例)。
|
||||
2. **交互层**:
|
||||
- 左侧样板库面板(`search_default_windoor` 数据 + 分类树 `default_windoor_catelog`),**拖拽模板进画布即实例化一个 sm 形状**;
|
||||
- 画布上选中形状后,从边缘拖出**中梃分割线**(norm 决定水平/垂直,`alignType`/`equalSplit` 决定等分策略);
|
||||
- 分割递归产生子区域,点击区域可指定 `sashes`(开扇,含开向箭头)、`bars`(格条)、玻璃类型;
|
||||
- 边缘料宽 `ew[4]` 可视化拖拽调节;尺寸标注 `dims` 绑定边/中梃(`eidx`/`midx`)自动跟随。
|
||||
3. **渲染层**:多边形裁剪 + 玻璃填充色 `cm.g` + 开向线条;打印视图走 SVG 输出。
|
||||
4. **算料层**:遍历形状树输出型材清单(外框 4 段 + 中梃/横撑按 `sp` 长度),套用产品价格脚本(`script_bar/script_glass/script_addon`,服务端执行)。
|
||||
**原站设计思路**(逆向自 drawing_data 序列化结构,经 254 套样本验证):
|
||||
|
||||
1. **三层模型**:`Frame 形状 = 基底多边形(polygon.polygon) + 分割树(mm.sp[]) + 扇子形状(sm[])`
|
||||
2. **分割树**(关键发现):`polygon.polygon` 不是最终窗格,而是基底多边形;`mm.sp[]` 按序记录每次分割——`line{pt,norm}` 定位分割线、`polyId{idx,pos}` 指向作用区域与插入位。编辑器打开文件时**重放分割树**得到最终窗格。同一中梃可能被记录为多条共线分段(随区域生长拆分)
|
||||
3. **扇模型**:子形状 `Sash/DoubleSash`,`sashes[]` 描述子扇(`od` 开向角、`wtf` 转框、`openToward` 内开/外开),经 `polyId` 挂到窗格;`bd` 边界在序列化中常退化,运行时重算
|
||||
4. **序列化即状态**:drawing_data 直接喂给 `shapeManager.openFile()` 恢复整个画布
|
||||
|
||||
**自研实现**(`frontend/src/components/WindoorCanvas.vue`,约 700 行,Canvas2D):
|
||||
|
||||
| 能力 | 实现 |
|
||||
|---|---|
|
||||
| 真实网格渲染 | `replayShape()` 分割树重放(两次交叉点法切多边形,退化/非凸跳过),254 套样板全部还原真实分格(平开 4-7 格、推拉亮子结构、转角窗多体) |
|
||||
| 中梃绘制 | 每条 sp 按其作用区域 bbox 裁剪为分段双线;共线分段自然重叠无害 |
|
||||
| 扇样式推断 | `有效bd → polyId 映射区域(双扇对半) → 帧bbox按扇数竖向均分` 三级解析;开向三角/内开空心/门标注 |
|
||||
| 尺寸参数化 | W/H 输入框 + 拖拽外框右缘/下缘/右下角手柄;全部几何(含 sp 点位)按比例缩放,料宽不变 |
|
||||
| **加中梃** | +竖梃/+横梃 → 点击玻璃区域 → 追加 sp(polyId.idx=被点窗格)→ 重放 |
|
||||
| **改中梃** | 点击选中(蓝色高亮)→ 拖动(line.pt 更新→重放,30mm 边距钳制)→ Del/按钮删除(移除 sp→重放) |
|
||||
| 撤销/自动报价 | JSON 快照栈;变化后 600ms 防抖调 offer-price 实时显示总价 |
|
||||
| 服务端算料 | Python 侧同语义 `replay_shape`:玻璃面积=重放窗格面积和,中梃长度=作用区域投影(精确到实际跨度) |
|
||||
|
||||
数据兼容性:编辑仅增删改 sp 与几何点,不改变 drawing_data 的字段结构——保存后仍是标准 shapeManager 格式。
|
||||
|
||||
## 4. 领域模型(依据真实响应样本归纳)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
controls-position="right" style="width:104px" @change="onSizeInput" /></span>
|
||||
<el-button size="small" :type="tool === 'v' ? 'primary' : ''" @click="arm('v')">+竖梃</el-button>
|
||||
<el-button size="small" :type="tool === 'h' ? 'primary' : ''" @click="arm('h')">+横梃</el-button>
|
||||
<el-button size="small" :type="selected ? 'danger' : ''" :disabled="!selected" @click="deleteSelected">删除梃</el-button>
|
||||
<el-button size="small" :disabled="!undoStack.length" @click="undo">撤销</el-button>
|
||||
</template>
|
||||
<el-button size="small" text @click="reset">重置视图</el-button>
|
||||
@@ -20,18 +21,24 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 二维窗型画布(自研实现)
|
||||
* 查看:渲染玻璃区域/外框/扇开向/尺寸标注;滚轮缩放、拖拽平移、双击复位
|
||||
* 编辑(editable):
|
||||
* - 尺寸:W/H 输入框 或 拖拽外框右缘/下缘/右下角手柄 → 全部几何按比例缩放(型材料宽不变)
|
||||
* - 分格:+竖梃/+横梃 → 点击玻璃区域完成分割(两次交叉点法),支持撤销
|
||||
* - exportDrawing() 取回修改后的 drawing_data;change 事件通知父组件
|
||||
* 二维窗型画布(自研实现 · 分割树架构)
|
||||
*
|
||||
* 数据模型(逆向自原站 shapeManager 序列化格式):
|
||||
* polygon.polygon = 基底多边形; mm.sp[] = 分割树(polyId.idx 指向作用区域);
|
||||
* 运行时按序重放 sp 得到最终窗格; 子形状(Sash/DoubleSash)经 polyId 挂到窗格上。
|
||||
*
|
||||
* 查看: 玻璃区域/中梃(按目标区域裁剪)/扇开向/尺寸标注; 滚轮缩放、拖拽平移、双击复位
|
||||
* 编辑(editable):
|
||||
* - 尺寸: W/H 输入框或拖拽外框右缘/下缘/右下角手柄 → 全部几何按比例缩放(料宽不变)
|
||||
* - 加梃: +竖梃/+横梃 → 点击玻璃区域, 追加 sp(指向被点区域)并重放
|
||||
* - 改梃: 直接点击中梃选中(高亮) → 拖动移动(line.pt 更新后重放) → 删除梃或 Del 键删除
|
||||
* - 撤销栈; exportDrawing() 取回 drawing_data; change 事件通知父组件
|
||||
*/
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
bboxOf, parseDrawing, regionPoints,
|
||||
type DrawingData, type FrameShape, type Pt, type Segment, type SashShape,
|
||||
bboxOf, parseDrawing, regionPoints, replayShape, spAxis, spPos,
|
||||
type DrawingData, type FrameShape, type Pt, type Segment, type SashShape, type SplitLine,
|
||||
} from '../types/drawing'
|
||||
|
||||
const props = defineProps<{ drawingData: string | object; editable?: boolean }>()
|
||||
@@ -48,9 +55,13 @@ const undoStack: string[] = []
|
||||
const tool = ref<'' | 'v' | 'h'>('')
|
||||
const sizeW = ref(0)
|
||||
const sizeH = ref(0)
|
||||
let resizeDrag: null | { axis: 'w' | 'h' | 'wh'; startW: number; startH: number; startX: number; startY: number; snapshot: string } = null
|
||||
let resizeDrag: null | { axis: 'w' | 'h' | 'wh'; startW: number; startH: number; startX: number; startY: number } = null
|
||||
let moveDrag: null | { shapeIdx: number; spIdx: number; startPos: number; startX: number; startY: number; snapshot: string } = null
|
||||
const selected = ref<{ shapeIdx: number; spIdx: number } | null>(null)
|
||||
|
||||
type Replay = { regions: Segment[][]; clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] }
|
||||
let replays: Replay[] = []
|
||||
|
||||
// 几何转换缓存(render 时更新,命中测试用)
|
||||
let lastTransform = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
|
||||
|
||||
function toModel(sx: number, sy: number): Pt {
|
||||
@@ -58,7 +69,18 @@ function toModel(sx: number, sy: number): Pt {
|
||||
return { x: (sx - W / 2 - view.cx) / scale + midX, y: (H / 2 + view.cy - sy) / scale + midY }
|
||||
}
|
||||
|
||||
// ============ 参数化尺寸缩放:全部几何按比例缩放,型材料宽(ew)不变 ============
|
||||
// ============ 分割树重放 ============
|
||||
|
||||
function rebuildReplays() {
|
||||
replays = dd ? dd.sm.map(s => replayShape(s)) : []
|
||||
}
|
||||
|
||||
function replayOne(shapeIdx: number) {
|
||||
if (!dd) return
|
||||
replays[shapeIdx] = replayShape(dd.sm[shapeIdx])
|
||||
}
|
||||
|
||||
// ============ 参数化尺寸缩放: 几何按比例缩放, 料宽(ew)不变 ============
|
||||
|
||||
function scaleAll(sx: number, sy: number) {
|
||||
if (!dd) return
|
||||
@@ -82,6 +104,7 @@ function resizeTo(newW: number, newH: number, silent = false) {
|
||||
if (w < 10 || h < 10) return
|
||||
if (Math.abs(newW - w) < 0.5 && Math.abs(newH - h) < 0.5) { syncSizeInputs(); return }
|
||||
scaleAll(newW / w, newH / h)
|
||||
rebuildReplays()
|
||||
computeModelBox()
|
||||
syncSizeInputs()
|
||||
render()
|
||||
@@ -99,7 +122,6 @@ function onSizeInput() {
|
||||
resizeTo(sizeW.value, sizeH.value)
|
||||
}
|
||||
|
||||
/** 命中测试:屏幕点是否落在整体外框右缘/下缘/右下角的拖拽缩放带 */
|
||||
function hitResizeHandle(sx: number, sy: number): 'w' | 'h' | 'wh' | null {
|
||||
const { scale, midX, midY, W, H } = lastTransform
|
||||
const x1 = W / 2 + (modelBox.x1 - midX) * scale + view.cx
|
||||
@@ -113,6 +135,34 @@ function hitResizeHandle(sx: number, sy: number): 'w' | 'h' | 'wh' | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 命中中梃: 点击点到各梃线段(裁剪后)的距离 */
|
||||
function hitMullion(sx: number, sy: number): { shapeIdx: number; spIdx: number } | null {
|
||||
const { scale, midX, midY, W, H } = lastTransform
|
||||
const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx
|
||||
const ty = (y: number) => H / 2 - (y - midY) * scale + view.cy
|
||||
let best: { shapeIdx: number; spIdx: number; d: number } | null = null
|
||||
replays.forEach((rp, shapeIdx) => {
|
||||
rp.clips.forEach(({ sp, bb }, spIdx) => {
|
||||
const axis = spAxis(sp)
|
||||
const pos = spPos(sp)
|
||||
const p1 = axis === 'v' ? { x: pos, y: bb.y0 } : { x: bb.x0, y: pos }
|
||||
const p2 = axis === 'v' ? { x: pos, y: bb.y1 } : { x: bb.x1, y: pos }
|
||||
const ax = tx(p1.x), ay = ty(p1.y), bx = tx(p2.x), by = ty(p2.y)
|
||||
const vx = bx - ax, vy = by - ay
|
||||
const len2 = vx * vx + vy * vy
|
||||
if (len2 < 4) return
|
||||
let t = ((sx - ax) * vx + (sy - ay) * vy) / len2
|
||||
t = Math.max(0, Math.min(1, t))
|
||||
const dx = sx - (ax + t * vx), dy = sy - (ay + t * vy)
|
||||
const d = Math.sqrt(dx * dx + dy * dy)
|
||||
if (d < 7 && (!best || d < best.d)) best = { shapeIdx, spIdx, d }
|
||||
})
|
||||
})
|
||||
if (!best) return null
|
||||
const b = best as { shapeIdx: number; spIdx: number; d: number }
|
||||
return { shapeIdx: b.shapeIdx, spIdx: b.spIdx }
|
||||
}
|
||||
|
||||
// ============ 渲染 ============
|
||||
|
||||
function render() {
|
||||
@@ -140,9 +190,8 @@ function render() {
|
||||
const profile = '#5c6166'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
for (const shape of dd.sm) drawFrame(ctx, shape, tx, ty, { glassAlpha, profile })
|
||||
dd.sm.forEach((shape, si) => drawFrame(ctx, shape, replays[si], si, tx, ty, { glassAlpha, profile }))
|
||||
|
||||
// 编辑模式:右下角缩放手柄
|
||||
if (props.editable) {
|
||||
const x1 = tx(modelBox.x1), y1 = ty(modelBox.y0)
|
||||
ctx.fillStyle = '#409eff'
|
||||
@@ -150,29 +199,20 @@ function render() {
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.strokeRect(x1 - 5, y1 - 5, 10, 10)
|
||||
ctx.setLineDash([4, 4])
|
||||
ctx.strokeStyle = 'rgba(64,158,255,0.55)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x1 + 6, y1); ctx.lineTo(x1 + 28, y1)
|
||||
ctx.moveTo(x1, y1 + 6); ctx.lineTo(x1, y1 + 28)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
|
||||
info.value = dd.sm.length > 1 ? `窗型 · ${dd.sm.length} 个窗体组合` : '窗型'
|
||||
}
|
||||
|
||||
function drawFrame(
|
||||
ctx: CanvasRenderingContext2D, shape: FrameShape,
|
||||
ctx: CanvasRenderingContext2D, shape: FrameShape, rp: Replay, shapeIdx: number,
|
||||
tx: (x: number) => number, ty: (y: number) => number,
|
||||
color: { glassAlpha: string; profile: string },
|
||||
) {
|
||||
const regionsRaw = shape.polygon?.polygon ?? []
|
||||
const regions = regionsRaw.map(regionPoints).filter(pts => pts.length >= 3)
|
||||
const regions = rp.regions.map(regionPoints).filter(pts => pts.length >= 3)
|
||||
const cm = shape.cm ?? {}
|
||||
|
||||
// 玻璃区域
|
||||
// 玻璃区域(重放结果)
|
||||
for (const pts of regions) {
|
||||
ctx.beginPath()
|
||||
pts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
|
||||
@@ -180,13 +220,16 @@ function drawFrame(
|
||||
ctx.fillStyle = cm.g ?? color.glassAlpha
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = cm.bn ?? color.profile
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.stroke()
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.65)'
|
||||
ctx.lineWidth = 0.8
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 中梃(每条按目标区域 bbox 裁剪后绘制, 双线示意型材; 选中高亮)
|
||||
rp.clips.forEach(({ sp, bb }, spIdx) => {
|
||||
const isSel = selected.value?.shapeIdx === shapeIdx && selected.value.spIdx === spIdx
|
||||
drawBar(ctx, spAxis(sp), spPos(sp), bb, tx, ty, isSel, color.profile)
|
||||
})
|
||||
|
||||
// 外框
|
||||
if (regions.length) {
|
||||
const bb = bboxOf(regions.flat())
|
||||
@@ -202,8 +245,7 @@ function drawFrame(
|
||||
ctx.strokeRect(x0 + inset, y0 + inset, (x1 - x0) - inset * 2, (y1 - y0) - inset * 2)
|
||||
}
|
||||
|
||||
// ---- 扇样式推断(真实分格还原)----
|
||||
// 优先级: 有效bd > polyId映射区域(双扇再对半) > 帧bbox按扇数竖向均分
|
||||
// 扇(推断: 有效bd → polyId区域(双扇对半) → 帧bbox按扇数均分)
|
||||
const regionBBs = regions.map(pts => bboxOf(pts))
|
||||
type Glyph = { x0: number; y0: number; x1: number; y1: number; od: number; openToward?: string; isDoor?: boolean }
|
||||
const glyphs: Glyph[] = []
|
||||
@@ -214,8 +256,7 @@ function drawFrame(
|
||||
}
|
||||
return !bd.hidden && r.x1 - r.x0 > 10 && r.y1 - r.y0 > 10 ? r : null
|
||||
}
|
||||
const pushGlyphs = (r: { x0: number; y0: number; x1: number; y1: number },
|
||||
entries: any[], sub: SashShape) => {
|
||||
const pushGlyphs = (r: { x0: number; y0: number; x1: number; y1: number }, entries: any[], sub: SashShape) => {
|
||||
for (const e of entries) {
|
||||
glyphs.push({
|
||||
x0: r.x0, y0: r.y0, x1: r.x1, y1: r.y1,
|
||||
@@ -248,7 +289,6 @@ function drawFrame(
|
||||
unmatched.push({ sub, entries })
|
||||
}
|
||||
}
|
||||
// 兜底:帧 bbox 按剩余扇数竖向均分
|
||||
if (unmatched.length && regions.length) {
|
||||
const fb = bboxOf(regions.flat())
|
||||
const K = unmatched.reduce((s, u) => s + u.entries.length, 0)
|
||||
@@ -266,7 +306,6 @@ function drawFrame(
|
||||
}
|
||||
}
|
||||
}
|
||||
// 绘制扇:虚线内框 + 开向三角(inward 空心 / outward 实心)
|
||||
for (const g of glyphs) {
|
||||
const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y1), by1 = ty(g.y0)
|
||||
const w = bx1 - bx0, h = by1 - by0
|
||||
@@ -303,7 +342,7 @@ function drawFrame(
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// 尺寸标注(宽/高,随几何自动更新)
|
||||
// 尺寸标注
|
||||
if (regions.length) {
|
||||
const bb = bboxOf(regions.flat())
|
||||
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y1), y1 = ty(bb.y0)
|
||||
@@ -326,7 +365,97 @@ function drawFrame(
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 编辑:区域分割(两次交叉点法) ============
|
||||
/** 画一根中梃(按bbox裁剪的双线) */
|
||||
function drawBar(
|
||||
ctx: CanvasRenderingContext2D, axis: 'v' | 'h', pos: number,
|
||||
bb: { x0: number; y0: number; x1: number; y1: number },
|
||||
tx: (x: number) => number, ty: (y: number) => number, isSelected: boolean, profile: string,
|
||||
) {
|
||||
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y1), y1 = ty(bb.y0)
|
||||
ctx.save()
|
||||
if (axis === 'v') {
|
||||
const px = tx(pos)
|
||||
if (px < x0 - 1 || px > x1 + 1) { ctx.restore(); return }
|
||||
ctx.strokeStyle = isSelected ? '#409eff' : profile
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath(); ctx.moveTo(px, y0); ctx.lineTo(px, y1); ctx.stroke()
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.55)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath(); ctx.moveTo(px, y0); ctx.lineTo(px, y1); ctx.stroke()
|
||||
} else {
|
||||
const py = ty(pos)
|
||||
if (py < y0 - 1 || py > y1 + 1) { ctx.restore(); return }
|
||||
ctx.strokeStyle = isSelected ? '#409eff' : profile
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath(); ctx.moveTo(x0, py); ctx.lineTo(x1, py); ctx.stroke()
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.55)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath(); ctx.moveTo(x0, py); ctx.lineTo(x1, py); ctx.stroke()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// ============ 编辑操作 ============
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
if (!props.editable || !dd) return
|
||||
const rect = cvRef.value!.getBoundingClientRect()
|
||||
const sx = e.clientX - rect.left, sy = e.clientY - rect.top
|
||||
// 加梃模式
|
||||
if (tool.value) {
|
||||
const click = toModel(sx, sy)
|
||||
undoStack.push(JSON.stringify(dd))
|
||||
const axis = tool.value
|
||||
for (let si = 0; si < dd.sm.length; si++) {
|
||||
const rp = replays[si]
|
||||
for (let ri = 0; ri < rp.regions.length; ri++) {
|
||||
const pts = regionPoints(rp.regions[ri])
|
||||
if (!pointInPoly(click, pts)) continue
|
||||
const shape = dd.sm[si]
|
||||
const pos = axis === 'v' ? click.x : click.y
|
||||
const bb = bboxOf(pts)
|
||||
const centroid = { x: (bb.x0 + bb.x1) / 2, y: (bb.y0 + bb.y1) / 2 }
|
||||
const mid: Pt = axis === 'v' ? { x: pos, y: centroid.y } : { x: centroid.x, y: pos }
|
||||
const norm = axis === 'v' ? { x: 1, y: 0 } : { x: 0, y: -1 }
|
||||
const mm = (shape.mm ??= { sp: [] })
|
||||
mm.sp = mm.sp ?? []
|
||||
mm.sp.push({
|
||||
line: { pt: mid, norm, name: 'line' },
|
||||
polyId: { idx: ri, pos: rp.regions.length },
|
||||
alignType: 0, equalSplit: 0, kw: axis === 'v', ws: false, reinforced: false, db: false,
|
||||
} as any)
|
||||
replayOne(si)
|
||||
selected.value = null
|
||||
render()
|
||||
emit('change')
|
||||
return
|
||||
}
|
||||
}
|
||||
ElMessage.warning('请点击玻璃区域')
|
||||
return
|
||||
}
|
||||
// 选择模式: 点中梃选中/取消
|
||||
const m = hitMullion(sx, sy)
|
||||
selected.value = m
|
||||
render()
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
if (!selected.value || !dd) return
|
||||
undoStack.push(JSON.stringify(dd))
|
||||
const { shapeIdx, spIdx } = selected.value
|
||||
const sp = dd.sm[shapeIdx]?.mm?.sp?.[spIdx]
|
||||
if (sp) {
|
||||
dd.sm[shapeIdx]!.mm!.sp!.splice(spIdx, 1)
|
||||
replayOne(shapeIdx)
|
||||
selected.value = null
|
||||
computeModelBox()
|
||||
syncSizeInputs()
|
||||
render()
|
||||
emit('change')
|
||||
ElMessage.success('已删除中梃')
|
||||
}
|
||||
}
|
||||
|
||||
function pointInPoly(p: Pt, poly: Pt[]): boolean {
|
||||
let inside = false
|
||||
@@ -338,93 +467,13 @@ function pointInPoly(p: Pt, poly: Pt[]): boolean {
|
||||
return inside
|
||||
}
|
||||
|
||||
function splitPoly(region: Segment[], axis: 'v' | 'h', pos: number):
|
||||
{ a: Segment[]; b: Segment[] } | null {
|
||||
const pts = regionPoints(region)
|
||||
const n = pts.length
|
||||
const get = (p: Pt) => (axis === 'v' ? p.x : p.y)
|
||||
const crossings: { i: number; p: Pt }[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i], b = pts[(i + 1) % n]
|
||||
const da = get(a) - pos, db = get(b) - pos
|
||||
if ((da < 0 && db > 0) || (da > 0 && db < 0)) {
|
||||
const t = da / (da - db)
|
||||
crossings.push({
|
||||
i,
|
||||
p: axis === 'v'
|
||||
? { x: pos, y: a.y + (b.y - a.y) * t }
|
||||
: { x: a.x + (b.x - a.x) * t, y: pos },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (crossings.length !== 2) return null
|
||||
const [c1, c2] = crossings
|
||||
if (c1.i === c2.i) return null
|
||||
|
||||
const collect = (from: typeof c1, to: typeof c2): Pt[] => {
|
||||
const ps: Pt[] = [from.p]
|
||||
for (let k = (from.i + 1) % n; ; k = (k + 1) % n) {
|
||||
ps.push(pts[k])
|
||||
if (k === to.i) break
|
||||
}
|
||||
ps.push(to.p)
|
||||
return ps.filter((p, i) => i === 0 || Math.abs(p.x - ps[i - 1].x) > 0.01 || Math.abs(p.y - ps[i - 1].y) > 0.01)
|
||||
}
|
||||
const toSegs = (ps: Pt[]): Segment[] => {
|
||||
const segs: Segment[] = []
|
||||
for (let i = 0; i < ps.length; i++) segs.push(mkSeg(ps[i], ps[(i + 1) % ps.length]))
|
||||
return segs
|
||||
}
|
||||
const polyA = collect(c1, c2)
|
||||
const polyB = collect(c2, c1)
|
||||
if (polyA.length < 3 || polyB.length < 3) return null
|
||||
return { a: toSegs(polyA), b: toSegs(polyB) }
|
||||
}
|
||||
|
||||
function mkSeg(a: Pt, b: Pt): Segment { return { ps: a, pe: b, name: 'segment' } }
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
if (!props.editable || !tool.value || !dd) return
|
||||
const rect = cvRef.value!.getBoundingClientRect()
|
||||
const click = toModel(e.clientX - rect.left, e.clientY - rect.top)
|
||||
undoStack.push(JSON.stringify(dd))
|
||||
const axis = tool.value
|
||||
for (const shape of dd.sm) {
|
||||
const polyWrap = shape.polygon
|
||||
const regions = polyWrap?.polygon ?? []
|
||||
for (let ri = 0; ri < regions.length; ri++) {
|
||||
const pts = regionPoints(regions[ri])
|
||||
if (!pointInPoly(click, pts)) continue
|
||||
const pos = axis === 'v' ? click.x : click.y
|
||||
const split = splitPoly(regions[ri], axis, pos)
|
||||
if (!split) { undoStack.pop(); ElMessage.warning('分割位置太靠近边缘'); return }
|
||||
const newRegions = [split.a, split.b]
|
||||
regions.splice(ri, 1, ...newRegions)
|
||||
// 追加分割线记录(与 shapeManager 的 mm.sp 同构)
|
||||
const centroid = pts.reduce((s, p) => ({ x: s.x + p.x / pts.length, y: s.y + p.y / pts.length }), { x: 0, y: 0 })
|
||||
const mid: Pt = axis === 'v' ? { x: pos, y: centroid.y } : { x: centroid.x, y: pos }
|
||||
const norm = axis === 'v' ? { x: 1, y: 0 } : { x: 0, y: -1 }
|
||||
const mm = (shape.mm ??= { sp: [] })
|
||||
mm.sp = mm.sp ?? []
|
||||
mm.sp.push({
|
||||
line: { pt: mid, norm, name: 'line' },
|
||||
polyId: { idx: ri, pos: -1 },
|
||||
alignType: 0, equalSplit: 0, kw: axis === 'v', ws: false, reinforced: false, db: false,
|
||||
} as any)
|
||||
computeModelBox()
|
||||
syncSizeInputs()
|
||||
render()
|
||||
emit('change')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function arm(t: 'v' | 'h') { tool.value = tool.value === t ? '' : t; info.value = tool.value ? '点击玻璃区域完成分割' : '' }
|
||||
function arm(t: 'v' | 'h') { tool.value = tool.value === t ? '' : t; info.value = tool.value ? '点击玻璃区域完成加梃' : '' }
|
||||
|
||||
function undo() {
|
||||
if (!undoStack.length || !dd) return
|
||||
dd = JSON.parse(undoStack.pop()!)
|
||||
selected.value = null
|
||||
rebuildReplays()
|
||||
computeModelBox()
|
||||
syncSizeInputs()
|
||||
render()
|
||||
@@ -436,7 +485,7 @@ function exportDrawing(): string | null {
|
||||
}
|
||||
defineExpose({ exportDrawing })
|
||||
|
||||
// ============ 视图控制与交互 ============
|
||||
// ============ 视图控制 ============
|
||||
|
||||
function computeModelBox() {
|
||||
if (!dd) return
|
||||
@@ -466,34 +515,58 @@ function onWheel(e: WheelEvent) {
|
||||
|
||||
function onDown(e: MouseEvent) {
|
||||
const rect = cvRef.value!.getBoundingClientRect()
|
||||
const sx = e.clientX - rect.left, sy = e.clientY - rect.top
|
||||
if (props.editable && !tool.value) {
|
||||
const hit = hitResizeHandle(e.clientX - rect.left, e.clientY - rect.top)
|
||||
const hit = hitResizeHandle(sx, sy)
|
||||
if (hit) {
|
||||
undoStack.push(JSON.stringify(dd))
|
||||
resizeDrag = { axis: hit, startW: modelBox.x1 - modelBox.x0, startH: modelBox.y1 - modelBox.y0,
|
||||
startX: e.clientX, startY: e.clientY, snapshot: '' }
|
||||
startX: e.clientX, startY: e.clientY }
|
||||
return
|
||||
}
|
||||
const m = hitMullion(sx, sy)
|
||||
if (m) {
|
||||
// 开始拖动中梃
|
||||
const sp = dd?.sm[m.shapeIdx]?.mm?.sp?.[m.spIdx]
|
||||
if (sp) {
|
||||
undoStack.push(JSON.stringify(dd))
|
||||
moveDrag = { shapeIdx: m.shapeIdx, spIdx: m.spIdx, startPos: spPos(sp), startX: e.clientX, startY: e.clientY, snapshot: '' }
|
||||
selected.value = m
|
||||
return
|
||||
}
|
||||
}
|
||||
selected.value = null
|
||||
}
|
||||
drag = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function onMove(e: MouseEvent) {
|
||||
// 拖拽缩放
|
||||
if (resizeDrag) {
|
||||
const dpx = e.clientX - resizeDrag.startX
|
||||
const dpy = e.clientY - resizeDrag.startY
|
||||
const { scale } = lastTransform
|
||||
const dw = resizeDrag.axis === 'h' ? 0 : dpx / scale
|
||||
const dh = resizeDrag.axis === 'w' ? 0 : -dpy / scale
|
||||
resizeTo(resizeDrag.startW + dw, resizeDrag.startH + dh, true) // 拖拽中静默,松手后统一提交
|
||||
const rect = cvRef.value?.getBoundingClientRect()
|
||||
// 拖动中梃
|
||||
if (moveDrag && dd) {
|
||||
const sp = dd.sm[moveDrag.shapeIdx]?.mm?.sp?.[moveDrag.spIdx]
|
||||
const rp = replays[moveDrag.shapeIdx]
|
||||
if (sp && rp) {
|
||||
const clip = rp.clips.find(c => c.sp === sp)
|
||||
const bb = clip?.bb ?? modelBox
|
||||
const { scale } = lastTransform
|
||||
const axis = spAxis(sp)
|
||||
const delta = axis === 'v' ? (e.clientX - moveDrag.startX) / scale : -(e.clientY - moveDrag.startY) / scale
|
||||
const lo = (axis === 'v' ? bb.x0 : bb.y0) + 30
|
||||
const hi = (axis === 'v' ? bb.x1 : bb.y1) - 30
|
||||
const newPos = Math.min(hi, Math.max(lo, moveDrag.startPos + delta))
|
||||
if (axis === 'v') sp.line.pt.x = newPos; else sp.line.pt.y = newPos
|
||||
replayOne(moveDrag.shapeIdx)
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 光标提示
|
||||
if (props.editable && !tool.value && cvRef.value) {
|
||||
const rect = cvRef.value.getBoundingClientRect()
|
||||
const hit = hitResizeHandle(e.clientX - rect.left, e.clientY - rect.top)
|
||||
cvRef.value.style.cursor = hit === 'wh' ? 'nwse-resize' : hit === 'w' ? 'ew-resize' : hit === 'h' ? 'ns-resize' : 'grab'
|
||||
if (props.editable && !tool.value && rect) {
|
||||
const sx = e.clientX - rect.left, sy = e.clientY - rect.top
|
||||
const hit = hitResizeHandle(sx, sy)
|
||||
cvRef.value!.style.cursor = hit === 'wh' ? 'nwse-resize' : hit === 'w' ? 'ew-resize' : hit === 'h' ? 'ns-resize'
|
||||
: hitMullion(sx, sy) ? 'move' : 'grab'
|
||||
}
|
||||
if (!drag) return
|
||||
view.cx += e.clientX - drag.x
|
||||
@@ -504,14 +577,21 @@ function onMove(e: MouseEvent) {
|
||||
|
||||
function onUp() {
|
||||
if (resizeDrag) {
|
||||
// 拖拽过程中 resizeTo 走 silent;松手时统一判定是否真变了
|
||||
const changed = Math.abs((modelBox.x1 - modelBox.x0) - resizeDrag.startW) > 0.5 ||
|
||||
Math.abs((modelBox.y1 - modelBox.y0) - resizeDrag.startH) > 0.5
|
||||
if (!changed) undoStack.pop() // 尺寸未变,撤销点作废
|
||||
if (!changed) undoStack.pop()
|
||||
resizeDrag = null
|
||||
if (changed) emit('change')
|
||||
return
|
||||
}
|
||||
if (moveDrag) {
|
||||
const sp = dd?.sm[moveDrag.shapeIdx]?.mm?.sp?.[moveDrag.spIdx]
|
||||
const changed = sp && Math.abs(spPos(sp) - moveDrag.startPos) > 0.5
|
||||
if (!changed) undoStack.pop()
|
||||
else { replayOne(moveDrag.shapeIdx); render(); emit('change') }
|
||||
moveDrag = null
|
||||
return
|
||||
}
|
||||
drag = null
|
||||
}
|
||||
|
||||
@@ -520,6 +600,8 @@ onMounted(() => {
|
||||
try {
|
||||
dd = props.drawingData ? parseDrawing(props.drawingData) : null
|
||||
undoStack.length = 0
|
||||
selected.value = null
|
||||
rebuildReplays()
|
||||
computeModelBox()
|
||||
syncSizeInputs()
|
||||
reset()
|
||||
@@ -539,6 +621,6 @@ onMounted(() => {
|
||||
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
|
||||
canvas:active { cursor: grabbing; }
|
||||
.hud { position: absolute; right: 10px; top: 10px; display: flex; gap: 8px; align-items: center;
|
||||
background: rgba(255,255,255,0.92); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; }
|
||||
background: rgba(255,255,255,0.92); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; flex-wrap: wrap; }
|
||||
.dim-input { display: inline-flex; align-items: center; gap: 4px; color: #409eff; font-weight: 600; }
|
||||
</style>
|
||||
|
||||
@@ -119,3 +119,93 @@ export function bboxOf(points: Pt[]): { x0: number; y0: number; x1: number; y1:
|
||||
const ys = points.map(p => p.y)
|
||||
return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) }
|
||||
}
|
||||
|
||||
/** 分割线轴向: v=竖梃(恒定x) h=横梃(恒定y) */
|
||||
export function spAxis(sp: SplitLine): 'v' | 'h' {
|
||||
const n = sp.line?.norm ?? { x: 1, y: 0 }
|
||||
return Math.abs(n.x) >= Math.abs(n.y) ? 'v' : 'h'
|
||||
}
|
||||
|
||||
export function spPos(sp: SplitLine): number {
|
||||
return spAxis(sp) === 'v' ? sp.line.pt.x : sp.line.pt.y
|
||||
}
|
||||
|
||||
/**
|
||||
* 分割树重放(逆向自原站 shapeManager 的分割模型):
|
||||
* polygon.polygon 为基底多边形, mm.sp[] 为分割树 — 每条 sp 通过 polyId.idx 指向
|
||||
* 其作用区域, 按顺序应用后得到最终窗格。重放时:
|
||||
* - idx 越界/为-1 → 作用于基底区域0
|
||||
* - 分割坐标落在目标区域边上(退化)或目标非凸 → 跳过(no-op), 该梃按目标bbox裁剪绘制
|
||||
*/
|
||||
export function replayShape(shape: FrameShape): {
|
||||
regions: Segment[][]
|
||||
clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[]
|
||||
} {
|
||||
const base = (shape.polygon?.polygon ?? []).map(r => r.map(s => ({ ps: { ...s.ps }, pe: { ...s.pe } })))
|
||||
const regions: Segment[][] = base.map(r => r)
|
||||
const clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] = []
|
||||
for (const sp of (shape.mm?.sp ?? []) as SplitLine[]) {
|
||||
if (!sp?.line?.pt || !sp.line.norm) continue
|
||||
const axis = spAxis(sp)
|
||||
const pos = spPos(sp)
|
||||
let idx = sp.polyId?.idx ?? -1
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= regions.length) idx = 0
|
||||
const target = regions[idx]
|
||||
if (!target || target.length < 3) { continue }
|
||||
const bb = bboxOf(regionPoints(target))
|
||||
const lo = axis === 'v' ? bb.x0 : bb.y0
|
||||
const hi = axis === 'v' ? bb.x1 : bb.y1
|
||||
if (pos <= lo + 0.5 || pos >= hi - 0.5) {
|
||||
clips.push({ sp, bb }) // 退化: 边线上的记录, 仅用于绘制
|
||||
continue
|
||||
}
|
||||
const split = splitPolySegments(target, axis, pos)
|
||||
if (!split) { clips.push({ sp, bb }); continue }
|
||||
regions.splice(idx, 1, split.a, split.b)
|
||||
clips.push({ sp, bb })
|
||||
}
|
||||
return { regions, clips }
|
||||
}
|
||||
|
||||
/** 两次交叉点法分割闭合多边形段链(凸区域), 失败返回 null */
|
||||
export function splitPolySegments(region: Segment[], axis: 'v' | 'h', pos: number):
|
||||
{ a: Segment[]; b: Segment[] } | null {
|
||||
const pts = regionPoints(region)
|
||||
const n = pts.length
|
||||
const get = (p: Pt) => (axis === 'v' ? p.x : p.y)
|
||||
const crossings: { i: number; p: Pt }[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i], b = pts[(i + 1) % n]
|
||||
const da = get(a) - pos, db = get(b) - pos
|
||||
if ((da < 0 && db > 0) || (da > 0 && db < 0)) {
|
||||
const t = da / (da - db)
|
||||
crossings.push({
|
||||
i,
|
||||
p: axis === 'v'
|
||||
? { x: pos, y: a.y + (b.y - a.y) * t }
|
||||
: { x: a.x + (b.x - a.x) * t, y: pos },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (crossings.length !== 2) return null
|
||||
const [c1, c2] = crossings
|
||||
if (c1.i === c2.i) return null
|
||||
const collect = (from: typeof c1, to: typeof c2): Pt[] => {
|
||||
const ps: Pt[] = [from.p]
|
||||
for (let k = (from.i + 1) % n; ; k = (k + 1) % n) {
|
||||
ps.push(pts[k])
|
||||
if (k === to.i) break
|
||||
}
|
||||
ps.push(to.p)
|
||||
return ps.filter((p, i) => i === 0 || Math.abs(p.x - ps[i - 1].x) > 0.01 || Math.abs(p.y - ps[i - 1].y) > 0.01)
|
||||
}
|
||||
const toSegs = (ps: Pt[]): Segment[] => {
|
||||
const segs: Segment[] = []
|
||||
for (let i = 0; i < ps.length; i++) segs.push({ ps: ps[i], pe: ps[(i + 1) % ps.length], name: 'segment' })
|
||||
return segs
|
||||
}
|
||||
const polyA = collect(c1, c2)
|
||||
const polyB = collect(c2, c1)
|
||||
if (polyA.length < 3 || polyB.length < 3) return null
|
||||
return { a: toSegs(polyA), b: toSegs(polyB) }
|
||||
}
|
||||
|
||||
@@ -21,3 +21,103 @@ def parse_drawing(raw: str | dict) -> DrawingData:
|
||||
"""解析 drawing_data(兼容 JSON 字符串或对象)。"""
|
||||
dd = json.loads(raw) if isinstance(raw, str) else raw
|
||||
return dd # type: ignore[return-value]
|
||||
|
||||
|
||||
def _sp_axis(sp: dict) -> str:
|
||||
"""分割线轴向: v=竖梃(恒定x) h=横梃(恒定y)。"""
|
||||
n = (sp.get("line") or {}).get("norm") or {"x": 1, "y": 0}
|
||||
return "v" if abs(n.get("x", 0)) >= abs(n.get("y", 0)) else "h"
|
||||
|
||||
|
||||
def _sp_pos(sp: dict) -> float:
|
||||
p = sp["line"]["pt"]
|
||||
return p["x"] if _sp_axis(sp) == "v" else p["y"]
|
||||
|
||||
|
||||
def _region_bbox(region: list[dict]) -> tuple[float, float, float, float] | None:
|
||||
pts = [seg[k] for seg in region for k in ("ps", "pe") if isinstance(seg.get(k), dict)]
|
||||
if not pts:
|
||||
return None
|
||||
xs = [p["x"] for p in pts]
|
||||
ys = [p["y"] for p in pts]
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def _split_poly(region: list[dict], axis: str, pos: float) -> tuple[list[dict], list[dict]] | None:
|
||||
"""两次交叉点法分割闭合多边形段链(凸区域)。"""
|
||||
pts = [seg["ps"] for seg in region if isinstance(seg.get("ps"), dict)]
|
||||
n = len(pts)
|
||||
if n < 3:
|
||||
return None
|
||||
crossings: list[tuple[int, dict]] = []
|
||||
for i in range(n):
|
||||
a, b = pts[i], pts[(i + 1) % n]
|
||||
if axis == "v":
|
||||
da, db = a["x"] - pos, b["x"] - pos
|
||||
else:
|
||||
da, db = a["y"] - pos, b["y"] - pos
|
||||
if (da < 0 < db) or (da > 0 > db):
|
||||
t = da / (da - db)
|
||||
mid = ({"x": pos, "y": a["y"] + (b["y"] - a["y"]) * t} if axis == "v"
|
||||
else {"x": a["x"] + (b["x"] - a["x"]) * t, "y": pos})
|
||||
crossings.append((i, mid))
|
||||
if len(crossings) != 2:
|
||||
return None
|
||||
(i1, c1), (i2, c2) = crossings
|
||||
if i1 == i2:
|
||||
return None
|
||||
|
||||
def collect(fa: int, ca: dict, fb: int, cb: dict) -> list[dict]:
|
||||
ps = [ca]
|
||||
k = (fa + 1) % n
|
||||
while True:
|
||||
ps.append(pts[k])
|
||||
if k == fb:
|
||||
break
|
||||
k = (k + 1) % n
|
||||
ps.append(cb)
|
||||
out = [p for j, p in enumerate(ps)
|
||||
if j == 0 or abs(p["x"] - ps[j - 1]["x"]) > 0.01 or abs(p["y"] - ps[j - 1]["y"]) > 0.01]
|
||||
return out
|
||||
|
||||
def to_segs(ps: list[dict]) -> list[dict]:
|
||||
return [{"ps": ps[i], "pe": ps[(i + 1) % len(ps)]} for i in range(len(ps))]
|
||||
|
||||
poly_a = collect(i1, c1, i2, c2)
|
||||
poly_b = collect(i2, c2, i1, c1)
|
||||
if len(poly_a) < 3 or len(poly_b) < 3:
|
||||
return None
|
||||
return to_segs(poly_a), to_segs(poly_b)
|
||||
|
||||
|
||||
def replay_shape(shape: dict) -> list[list[dict]]:
|
||||
"""分割树重放: 基底多边形 + mm.sp[](polyId.idx 指向作用区域) → 最终窗格列表。
|
||||
|
||||
与前端 replayShape 同语义: idx 越界取 0; 退化/非凸跳过。
|
||||
"""
|
||||
base = shape.get("polygon", {}).get("polygon", []) or []
|
||||
regions = [[{"ps": dict(s["ps"]), "pe": dict(s["pe"])} for s in r] for r in base]
|
||||
if not regions:
|
||||
return regions
|
||||
for sp in (shape.get("mm") or {}).get("sp", []) or []:
|
||||
line = sp.get("line") or {}
|
||||
n, pt = line.get("norm"), line.get("pt")
|
||||
if not (isinstance(n, dict) and isinstance(pt, dict)):
|
||||
continue
|
||||
axis = _sp_axis(sp)
|
||||
pos = _sp_pos(sp)
|
||||
idx = (sp.get("polyId") or {}).get("idx", -1)
|
||||
idx = 0 if (not isinstance(idx, int) or idx < 0 or idx >= len(regions)) else idx
|
||||
bb = _region_bbox(regions[idx])
|
||||
if bb is None:
|
||||
continue
|
||||
x0, y0, x1, y1 = bb
|
||||
lo, hi = (x0, x1) if axis == "v" else (y0, y1)
|
||||
if not (lo + 0.5 < pos < hi - 0.5):
|
||||
continue
|
||||
split = _split_poly(regions[idx], axis, pos)
|
||||
if split is None:
|
||||
continue
|
||||
a, b = split
|
||||
regions[idx:idx + 1] = [a, b]
|
||||
return regions
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from huamenchuang.core.drawing import DrawingData, parse_drawing
|
||||
from huamenchuang.core.drawing import parse_drawing, replay_shape
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -45,18 +45,23 @@ def _region_bbox(region_pts):
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def takeoff(dd: DrawingData) -> TakeoffResult:
|
||||
"""从解析后的 drawing_data 提取算料清单。"""
|
||||
def takeoff(dd) -> TakeoffResult:
|
||||
"""从解析后的 drawing_data 提取算料清单(基于分割树重放后的真实窗格)。"""
|
||||
result = TakeoffResult()
|
||||
xs, ys = [], []
|
||||
|
||||
for shape in dd.get("sm", []):
|
||||
regions = [[(seg["ps"]["x"], seg["ps"]["y"]) for seg in r]
|
||||
for r in ((shape.get("polygon") or {}).get("polygon") or [])]
|
||||
# 重放分割树得到真实窗格; 空区域基底的形状跳过
|
||||
regions = replay_shape(shape)
|
||||
if not regions:
|
||||
continue
|
||||
region_pts = [[(seg["ps"]["x"], seg["ps"]["y"]) for seg in r] for r in regions]
|
||||
region_bbs = []
|
||||
for pts in region_pts:
|
||||
xs_ = [p[0] for p in pts]; ys_ = [p[1] for p in pts]
|
||||
region_bbs.append((min(xs_), min(ys_), max(xs_), max(ys_)))
|
||||
# 形状外框 bbox
|
||||
all_pts = [p for r in regions for p in r]
|
||||
all_pts = [p for r in region_pts for p in r]
|
||||
x0, y0, x1, y1 = _region_bbox(all_pts)
|
||||
w, h = round(x1 - x0), round(y1 - y0)
|
||||
xs.extend([x0, x1])
|
||||
@@ -64,16 +69,26 @@ def takeoff(dd: DrawingData) -> TakeoffResult:
|
||||
result.bars.append(BarItem("frame", w, 2, "外框-宽"))
|
||||
result.bars.append(BarItem("frame", h, 2, "外框-高"))
|
||||
|
||||
# 中梃:投影长度(竖梃=框高,横梃=框宽)
|
||||
# 中梃: 分割线在其作用区域 bbox 内的投影长度(精确到该梃实际跨度)
|
||||
for sp in ((shape.get("mm") or {}).get("sp") or []):
|
||||
norm = sp["line"]["norm"]
|
||||
if abs(norm["x"]) >= abs(norm["y"]):
|
||||
result.bars.append(BarItem("mullion", h, 1, "竖梃"))
|
||||
else:
|
||||
result.bars.append(BarItem("mullion", w, 1, "横梃"))
|
||||
line = sp.get("line") or {}
|
||||
n, pt = line.get("norm"), line.get("pt")
|
||||
if not (isinstance(n, dict) and isinstance(pt, dict)):
|
||||
continue
|
||||
axis = "v" if abs(n.get("x", 0)) >= abs(n.get("y", 0)) else "h"
|
||||
pos = pt.get("x") if axis == "v" else pt.get("y")
|
||||
# 取该梃与重放区域相交的最长投影
|
||||
best = 0.0
|
||||
for bb in region_bbs:
|
||||
bx0, by0, bx1, by1 = bb
|
||||
lo, hi = (bx0, bx1) if axis == "v" else (by0, by1)
|
||||
if lo - 1 <= pos <= hi + 1:
|
||||
best = max(best, hi - lo)
|
||||
if best > 0:
|
||||
result.bars.append(BarItem("mullion", round(best), 1, "竖梃" if axis == "v" else "横梃"))
|
||||
|
||||
# 玻璃面积
|
||||
result.glass_area_m2 += sum(_shoelace_area(r) for r in regions) / 1_000_000
|
||||
# 玻璃面积(重放后各窗格之和)
|
||||
result.glass_area_m2 += sum(_shoelace_area(r) for r in region_pts) / 1_000_000
|
||||
|
||||
# 扇框
|
||||
for sub in (shape.get("sm") or []):
|
||||
|
||||
Reference in New Issue
Block a user