diff --git a/CHANGELOG b/CHANGELOG index a52dde4..dcd8b0f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-25 15:40] FEAT: v2.26 P0-7 玻璃装饰格条设计器。①types/drawing.ts 加 DecBar(decs 挂 FrameShape, ri=重放区域索引)+DEC_PRESETS 8 风格(对齐源站 decorationBar: 现代轻奢/意式简约/奶油风/中式/欧式古典/法式/地中海/田园密格); ②WindoorCanvas: 选格后 HUD"格条"按钮+设计对话框(预设缩略图/行列/条宽/6 色)+2D 渲染(区域 clip 内横竖条+描边, 应用/清除入撤销栈); ③WindoorThreed: 静态格+扇面板玻璃前表面格条体(barX/barY 实心腔); ④后端 takeoff 计 dec_bar 算料(横=区域宽×rows/竖=区域高×cols, 默认 12 元/米); ⑤端到端验证: 无头浏览器订单详情→编辑→选格→欧式古典 3×3 应用→截图渲染正确(shots/verify-decbar-2d.png); 254 套全量校验 0 失败(decs 可选字段无回归); vue-tsc 0 错+构建通过 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorCanvas.vue, frontend/src/components/WindoorThreed.vue, src/huamenchuang/services/quote_service.py, frontend/scripts/verify-decbar.mjs, doc/API.md) + - [2026-09-25 14:50] FEAT: v2.25 P0-6 Excel 导入导出全套。①excel_service 扩展: read_rows 自动探测表头行(兼容首行标题合并格, 修复导入全跳过)/pick 中文列名取值/simple_table_workbook/statistics_workbook 双Sheet; ②客户: excel-template 模板/excel-in 批量导入(缺姓名行级报错)/excel-out 导出; ③合同 excel-out(含已收); ④库存 excel-out/excel-in(同名更新数量累加); ⑤reports/excel-statistics(订单汇总+收款统计); ⑥依赖补 python-multipart(UploadFile 必需, 缺失曾致后端启动失败); ⑦前端 apiUpload+四个页面导入导出按钮; 实测: 模板回传导入成功1条/库存导出→再导入更新1条; vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/excel_service.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/reports.py, frontend/src/api/index.ts, frontend/src/views/CustomersView.vue, frontend/src/views/WarehouseView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/ReportView.vue, doc/API.md) - [2026-09-25 13:50] FEAT: v2.24 P0-5 收款财务闭环。①ContractReceipt 模型(SK+日期+流水/方式五类/作废状态)+finance.py(账户 CRUD/登记收款/作废/流水分页/年度统计); ②GET /receipts/statistics(收款总额/合同应收/未收/12 月分布, 对齐 statistics_tb); ③合同列表注入 received/unreceived(当页一次 GROUP BY 聚合); ④新页面 /financial 财务管理(统计卡+月度柱图+流水+登记+账户管理)+路由菜单; 合同页加已收/未收列+收款按钮对话框; 修复 v-for 索引 m+1 类型; vue-tsc 0 错+构建通过; API 实测登记 3000→统计/列表联动正确 (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/finance.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/router.py, frontend/src/views/FinancialView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts, doc/API.md) diff --git a/doc/API.md b/doc/API.md index 2583d6f..2f3d97b 100644 --- a/doc/API.md +++ b/doc/API.md @@ -445,3 +445,13 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000 | **库存批量导入** | POST | `/api/warehouse/excel-in` | multipart `file` | 对齐 materials_config_excel:同名材料更新(数量累加),否则新增 | | 库存导出 | GET | `/api/warehouse/excel-out` | — | 对齐 warehouse_excel_out | | 统计报表导出 | GET | `/api/reports/excel-statistics` | — | 对齐 excel-statistics:订单汇总+收款统计双 Sheet | + +### 12.21 装饰格条(v2.20 新增,P0-格条设计器) + +对齐目标站 `/draw-decoration` + `decorationBarLibrary`(8 种装修风格)。**自研数据扩展**:`drawing_data.sm[i].decs[] = [{ri(重放区域索引), kind(风格), rows, cols, bw(条宽mm), color}]`,序列化向后兼容(254 套样板校验 0 回归)。 + +- **2D**:画布编辑态点选玻璃格 → HUD"格条"按钮 → 对话框(8 风格预设+行列/条宽/颜色)→ 应用/清除 +- **3D**:玻璃前表面按行列挤出格条体(静态格与扇面板均支持) +- **报价**:`offer-price`/BOM 自动计入格条算料(横条=区域宽×rows、竖条=区域高×cols,默认 12 元/米,`prices.dec_bar` 可覆盖,kind=`dec_bar`) + +风格预设:现代轻奢(1×1黑25)/意式简约(1×1灰18)/奶油风(2×2白22)/中式(2×2棕28)/欧式古典(3×3金20)/法式(2×2金16)/地中海(2×3蓝24)/田园密格(4×4白14)。 diff --git a/frontend/scripts/.drawing-bundle.mjs b/frontend/scripts/.drawing-bundle.mjs new file mode 100644 index 0000000..78fbd28 --- /dev/null +++ b/frontend/scripts/.drawing-bundle.mjs @@ -0,0 +1,311 @@ +// src/types/drawing.ts +var DEC_PRESETS = [ + { kind: "modern", label: "\u73B0\u4EE3\u8F7B\u5962", rows: 1, cols: 1, bw: 25, color: "#2b2b2b" }, + { kind: "italian", label: "\u610F\u5F0F\u7B80\u7EA6", rows: 1, cols: 1, bw: 18, color: "#9aa0a6" }, + { kind: "cream", label: "\u5976\u6CB9\u98CE", rows: 2, cols: 2, bw: 22, color: "#f3ece2" }, + { kind: "chinese", label: "\u4E2D\u5F0F", rows: 2, cols: 2, bw: 28, color: "#6b4a2b" }, + { kind: "european", label: "\u6B27\u5F0F\u53E4\u5178", rows: 3, cols: 3, bw: 20, color: "#c9a86a" }, + { kind: "french", label: "\u6CD5\u5F0F", rows: 2, cols: 2, bw: 16, color: "#d8c9a3" }, + { kind: "medit", label: "\u5730\u4E2D\u6D77", rows: 2, cols: 3, bw: 24, color: "#3a6ea5" }, + { kind: "pastoral", label: "\u7530\u56ED\u5BC6\u683C", rows: 4, cols: 4, bw: 14, color: "#ffffff" } +]; +function parseDrawing(raw) { + const dd = typeof raw === "string" ? JSON.parse(raw) : raw; + return dd; +} +function regionPoints(region) { + return region.map((seg) => ({ x: seg.ps.x, y: seg.ps.y })); +} +function bboxOf(points) { + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) }; +} +function spAxis(sp) { + const n = sp.line?.norm ?? { x: 1, y: 0 }; + return Math.abs(n.x) >= Math.abs(n.y) ? "v" : "h"; +} +function spPos(sp) { + return spAxis(sp) === "v" ? sp.line.pt.x : sp.line.pt.y; +} +function pointInRegion(p, region) { + let inside = false; + const pts = regionPoints(region); + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const xi = pts[i].x, yi = pts[i].y, xj = pts[j].x, yj = pts[j].y; + if (yi > p.y !== yj > p.y && p.x < (xj - xi) * (p.y - yi) / (yj - yi) + xi) inside = !inside; + } + return inside; +} +function dfsLeaves(node, out) { + if (node.kids.length === 0) { + out.push(node); + return; + } + node.kids.forEach((k) => dfsLeaves(k, out)); +} +function replayShape(shape) { + const base = (shape.polygon?.polygon ?? []).map((r) => r.filter((s) => s && typeof s === "object" && s.ps && s.pe && Number.isFinite(s.ps.x) && Number.isFinite(s.ps.y) && Number.isFinite(s.pe.x) && Number.isFinite(s.pe.y)).map((s) => ({ ps: { ...s.ps }, pe: { ...s.pe } }))).filter((r) => r.length >= 3); + const root = { segs: base[0] ?? [], kids: [] }; + const extraRoots = base.slice(1).map((segs) => ({ segs, kids: [] })); + const idMap = /* @__PURE__ */ new Map(); + const clips = []; + const allLeaves = () => { + const out = []; + dfsLeaves(root, out); + extraRoots.forEach((r) => dfsLeaves(r, out)); + return out; + }; + let spIdx = 0; + for (const sp of shape.mm?.sp ?? []) { + const myId = spIdx; + spIdx++; + if (!sp?.line?.pt || !sp.line.norm) continue; + const axis = spAxis(sp); + const pos = spPos(sp); + let target = null; + const nid = sp.polyId?.idx; + const cpos = sp.polyId?.pos; + if (Number.isInteger(nid) && nid >= 0) { + const node = nid === 0 ? root : idMap.get(nid); + if (node) { + if (node.kids.length === 0) target = node; + else if (Number.isInteger(cpos) && cpos >= 0 && cpos < node.kids.length) { + target = node.kids[cpos]; + } + } + } + if (!target) target = allLeaves().find((nd) => pointInRegion(sp.line.pt, nd.segs)) ?? null; + if (!target || target.segs.length < 3) continue; + const bb = bboxOf(regionPoints(target.segs)); + 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 }); + idMap.set(myId, target); + continue; + } + const split = splitPolySegments(target.segs, axis, pos); + if (!split) { + clips.push({ sp, bb }); + idMap.set(myId, target); + continue; + } + const nrm = sp.line.norm; + const sideOf = (segs) => { + for (const s of segs) { + for (const p of [s.ps, s.pe]) { + const d = (p.x - sp.line.pt.x) * nrm.x + (p.y - sp.line.pt.y) * nrm.y; + if (Math.abs(d) > 1e-6) return d > 0; + } + } + return true; + }; + const c0 = sideOf(split.a) ? split.a : split.b; + target.kids = [{ segs: c0, kids: [] }, { segs: sideOf(split.a) ? split.b : split.a, kids: [] }]; + target.segs = []; + idMap.set(myId, target); + clips.push({ sp, bb }); + } + const leaves = allLeaves(); + const regions = leaves.map((l) => l.segs).filter((s) => s.length >= 3); + const nodeId = /* @__PURE__ */ new Map([[root, 0]]); + const leafRefs = []; + const walk = (node, id) => { + node.kids.forEach((k, i) => { + if (k.kids.length === 0) leafRefs.push({ idx: id, pos: i }); + else { + const kidId = nodeId.get(k); + if (kidId != null) walk(k, kidId); + } + }); + }; + if (root.kids.length === 0 && root.segs.length >= 3) leafRefs.push({ idx: 0, pos: 0 }); + else walk(root, 0); + extraRoots.forEach((r, i) => { + const eid = base.length + spIdx + i; + idMap.set(eid, r); + if (r.kids.length === 0) leafRefs.push({ idx: eid, pos: 0 }); + else walk(r, eid); + }); + const resolveRef = (idx, pos) => { + const node = idx === 0 ? root : idMap.get(idx); + if (!node) return null; + if (node.kids.length === 0) return node.segs; + if (Number.isInteger(pos) && pos >= 0 && pos < node.kids.length) { + const k = node.kids[pos]; + if (k.kids.length === 0) return k.segs; + const l = []; + dfsLeaves(k, l); + return l[0]?.segs ?? null; + } + return null; + }; + return { regions, clips, leafRefs, resolveRef }; +} +function splitPolySegments(region, axis, pos) { + const pts = regionPoints(region); + const n = pts.length; + const get = (p) => axis === "v" ? p.x : p.y; + const crossings = []; + 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, to) => { + const ps = [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) => { + const segs = []; + 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) }; +} +function hmOf(sub) { + const hm = sub.hm; + return hm ?? {}; +} +function inferSashGlyphs(shape, replayed) { + let rr = null; + let regions; + if (replayed && !Array.isArray(replayed)) { + rr = replayed; + regions = rr.regions; + } else if (replayed) { + regions = replayed; + } else { + rr = replayShape(shape); + regions = rr.regions; + } + const regionBBs = regions.map((r) => bboxOf(regionPoints(r))); + const glyphs = []; + const bdRect = (bd) => { + if (!bd.from || !bd.to) return null; + const r = { + x0: Math.min(bd.from.x, bd.to.x), + y0: Math.min(bd.from.y, bd.to.y), + x1: Math.max(bd.from.x, bd.to.x), + y1: Math.max(bd.from.y, bd.to.y) + }; + const ok = [r.x0, r.y0, r.x1, r.y1].every((v) => Number.isFinite(v)); + return ok && !bd.hidden && r.x1 - r.x0 > 10 && r.y1 - r.y0 > 10 ? r : null; + }; + const kindOf = (sub) => String(sub.type) === "Slide" ? "sliding" : "casement"; + const rectOfRef = (ref) => { + if (!ref || !Number.isInteger(ref.idx) || ref.idx < 0) return null; + if (rr) { + const segs = rr.resolveRef(ref.idx, ref.pos); + if (segs && segs.length >= 3) return bboxOf(regionPoints(segs)); + } + const i = ref.idx; + return i < regionBBs.length ? regionBBs[i] : null; + }; + const iouR = (a, b) => { + const w = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0); + const h = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0); + if (w <= 0 || h <= 0) return 0; + const inter = w * h; + return inter / ((a.x1 - a.x0) * (a.y1 - a.y0) + (b.x1 - b.x0) * (b.y1 - b.y0) - inter); + }; + const pushGlyphs = (r, entries, sub, forceRect = false) => { + const kind = kindOf(sub); + const hm = hmOf(sub); + for (const e of entries) { + const rect = !forceRect ? rectOfRef(e.polyId) ?? r : r; + if (glyphs.some((g) => iouR(rect, g) > 0.15)) continue; + glyphs.push({ + x0: rect.x0, + y0: rect.y0, + x1: rect.x1, + y1: rect.y1, + od: e.od ?? sub.od ?? 90, + openToward: e.openToward ?? sub.openToward ?? hm.openToward, + openDirection: e.openDirection ?? hm.openDirection, + isDoor: Boolean(e.isDoor || sub.isDoor), + wtf: Boolean(e.wtf ?? sub.wtf ?? sub.withTurningFrame), + kind + }); + } + }; + const unmatched = []; + for (const sub of shape.sm ?? []) { + const type = String(sub.type ?? ""); + if (type === "Screen" || type === "DoubleScreen") continue; + const raw = sub.sashes?.length ? sub.sashes : [sub]; + const entries = raw.filter((e) => e && e.enabled !== false); + if (!entries.length) continue; + const n = Math.max(entries.length, type.startsWith("Double") ? 2 : 1); + let rect = sub.bd ? bdRect(sub.bd) : null; + if (!rect) rect = rectOfRef(sub.polyId); + if (rect) { + for (let k = 0; k < n; k++) { + const part = n > 1 ? { x0: rect.x0 + (rect.x1 - rect.x0) * k / n, y0: rect.y0, x1: rect.x0 + (rect.x1 - rect.x0) * (k + 1) / n, y1: rect.y1 } : rect; + pushGlyphs(part, [entries[Math.min(k, entries.length - 1)]], sub, n > 1); + } + continue; + } + unmatched.push({ sub, entries }); + } + if (unmatched.length && regions.length) { + const flatPts = []; + for (const r of regions) { + for (const seg of r) { + if (Number.isFinite(seg.ps?.x) && Number.isFinite(seg.ps?.y)) flatPts.push(seg.ps); + if (Number.isFinite(seg.pe?.x) && Number.isFinite(seg.pe?.y)) flatPts.push(seg.pe); + } + } + const fb = bboxOf(flatPts); + const K = unmatched.reduce((s, u) => s + u.entries.length, 0); + const colW = (fb.x1 - fb.x0) / K; + let i = 0; + for (const u of unmatched) { + for (const e of u.entries) { + glyphs.push({ + x0: fb.x0 + i * colW, + x1: fb.x0 + (i + 1) * colW, + y0: fb.y0, + y1: fb.y1, + od: e.od ?? u.sub.od ?? 90, + openToward: e.openToward ?? u.sub.openToward ?? hmOf(u.sub).openToward, + openDirection: e.openDirection ?? hmOf(u.sub).openDirection, + isDoor: Boolean(e.isDoor || u.sub.isDoor), + kind: kindOf(u.sub) + }); + i++; + } + } + } + return { regions, sashes: glyphs }; +} +export { + DEC_PRESETS, + bboxOf, + inferSashGlyphs, + parseDrawing, + pointInRegion, + regionPoints, + replayShape, + spAxis, + spPos, + splitPolySegments +}; diff --git a/frontend/scripts/verify-decbar.mjs b/frontend/scripts/verify-decbar.mjs new file mode 100644 index 0000000..44c5050 --- /dev/null +++ b/frontend/scripts/verify-decbar.mjs @@ -0,0 +1,59 @@ +// 格条设计器端到端验证: 订单详情 → 编辑 → 点选玻璃格 → 格条对话框 → 预设应用 → 截图 +// 用法: node verify-decbar.mjs +import puppeteer from 'puppeteer-core' +const browser = await puppeteer.launch({ + executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', + headless: 'new', + args: ['--window-size=1440,900', '--enable-unsafe-swiftshader'], + defaultViewport: { width: 1440, height: 900 }, +}) +const page = await browser.newPage() +page.on('pageerror', e => console.log(`[JS异常] ${String(e).slice(0, 200)}`)) +const token = process.argv[2] +await page.goto(`http://localhost:8000/orders?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' }) +await new Promise(r => setTimeout(r, 1500)) + +// 打开第一行订单图纸 +const rows = await page.$$('.el-table__body-wrapper .el-table__row') +if (!rows.length) { console.log('✗ 无订单行'); process.exit(1) } +const btns = await rows[0].$$('button') +for (const b of btns) { + const t = await b.evaluate(el => el.textContent.trim()) + if (t === '图纸') { await b.click(); break } +} +await new Promise(r => setTimeout(r, 1800)) + +// 进入编辑模式 +const editBtn = await page.evaluateHandle(() => [...document.querySelectorAll('button')].find(b => b.textContent.includes('编辑分格'))) +await editBtn.asElement()?.click() +await new Promise(r => setTimeout(r, 800)) + +// 点击画布若干位置直到选中玻璃格(格条按钮出现) +const box = await (await page.$('.canvas-box')).boundingBox() +const cx = box.x + box.width / 2, cy = box.y + box.height / 2 +const offsets = [[-0.18, -0.15], [0.18, -0.15], [-0.18, 0.15], [0.18, 0.15], [0, 0.2], [0, -0.2], [-0.25, 0], [0.25, 0]] +let decBtn = null +for (const [dx, dy] of offsets) { + await page.mouse.click(cx + box.width * dx, cy + box.height * dy) + await new Promise(r => setTimeout(r, 500)) + decBtn = await page.evaluateHandle(() => [...document.querySelectorAll('button')].find(b => b.textContent.trim() === '格条')) + if (await decBtn.asElement()) break + // 换位置前先取消当前选中(点 HUD 外空白不行, 直接再点别处即可) +} +if (!(await decBtn.asElement())) { console.log('✗ 格条按钮未出现(未选中玻璃格)'); await browser.close(); process.exit(1) } +await decBtn.asElement().click() +await new Promise(r => setTimeout(r, 800)) + +// 选第 5 个预设(欧式古典 3x3) + 应用 +const presets = await page.$$('.dec-preset') +if (presets.length < 8) { console.log(`✗ 预设数量异常: ${presets.length}`); process.exit(1) } +await presets[4].click() +await new Promise(r => setTimeout(r, 300)) +await page.evaluateHandle(() => [...document.querySelectorAll('.el-dialog button')].find(b => b.textContent.includes('应用'))).then(h => h.asElement()?.click()) +await new Promise(r => setTimeout(r, 800)) + +await page.screenshot({ path: 'D:/Code/huamenchuang/shots/verify-decbar-2d.png', clip: box }) +console.log('✓ 2D 格条渲染截图完成') + +// 3D 同窗型验证: 打开渲染/3D 页选同样板? 直接验证 2D 即可, 3D 走 validate3d +await browser.close() diff --git a/frontend/src/components/WindoorCanvas.vue b/frontend/src/components/WindoorCanvas.vue index 5f60758..0301154 100644 --- a/frontend/src/components/WindoorCanvas.vue +++ b/frontend/src/components/WindoorCanvas.vue @@ -14,6 +14,7 @@ controls-position="right" style="width:92px" @change="onRegionSize" /> 格高 + 格条 @@ -44,11 +73,11 @@ * - 改梃: 直接点击中梃选中(高亮) → 拖动移动(line.pt 更新后重放) → 删除梃或 Del 键删除 * - 撤销栈; exportDrawing() 取回 drawing_data; change 事件通知父组件 */ -import { onMounted, ref, watch } from 'vue' +import { onMounted, reactive, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { - bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape, spAxis, spPos, - type DrawingData, type FrameShape, type Pt, type Segment, type SashShape, type SplitLine, + bboxOf, DEC_PRESETS, inferSashGlyphs, parseDrawing, regionPoints, replayShape, spAxis, spPos, + type DecBar, type DrawingData, type FrameShape, type Pt, type Segment, type SashShape, type SplitLine, } from '../types/drawing' const props = defineProps<{ drawingData: string | object; editable?: boolean }>() @@ -378,6 +407,35 @@ function drawFrame( ctx.stroke() } + // 1.5) 装饰格条(自研 decs 扩展, 挂在玻璃格上; ri 对齐重放区域索引) + for (const dec of (shape.decs ?? []) as DecBar[]) { + const segs = rp.regions[dec.ri] + if (!segs) continue + const pts = regionPoints(segs) + if (pts.length < 3) continue + const bb = bboxOf(pts) + const px0 = tx(bb.x0), py0 = ty(bb.y0), px1 = tx(bb.x1), py1 = ty(bb.y1) + const gw = px1 - px0, gh = py1 - py0 + if (gw < 12 || gh < 12) continue + const barPx = Math.max(1.5, dec.bw * scale) + ctx.save() + ctx.beginPath(); ctx.rect(px0, py0, gw, gh); ctx.clip() + ctx.fillStyle = dec.color + ctx.strokeStyle = 'rgba(0,0,0,0.4)' + ctx.lineWidth = 1 + for (let i = 1; i <= dec.rows; i++) { + const y = py0 + (gh * i) / (dec.rows + 1) + ctx.fillRect(px0, y - barPx / 2, gw, barPx) + ctx.strokeRect(px0, y - barPx / 2, gw, barPx) + } + for (let i = 1; i <= dec.cols; i++) { + const x = px0 + (gw * i) / (dec.cols + 1) + ctx.fillRect(x - barPx / 2, py0, barPx, gh) + ctx.strokeRect(x - barPx / 2, py0, barPx, gh) + } + ctx.restore() + } + // 2) 中梃(按目标区域裁剪, 型材三段线; 选中高亮) + 与外框交点白块(官方连接件符号) for (let spIdx = 0; spIdx < rp.clips.length; spIdx++) { const { sp, bb } = rp.clips[spIdx] @@ -796,6 +854,71 @@ function undo() { function exportDrawing(): string | null { return dd ? JSON.stringify(dd) : null } + +// ---- 装饰格条设计(P0-格条设计器) ---- +const DEC_COLORS = [ + { label: '黑', v: '#2b2b2b' }, { label: '白', v: '#ffffff' }, { label: '灰', v: '#9aa0a6' }, + { label: '金', v: '#c9a86a' }, { label: '棕', v: '#6b4a2b' }, { label: '蓝', v: '#3a6ea5' }, +] +const decVisible = ref(false) +const decForm = reactive({ ri: 0, kind: 'modern', rows: 1, cols: 1, bw: 25, color: '#2b2b2b' }) +const hasDec = ref(false) + +function openDec() { + if (!selRegion.value || !dd) return + const { shapeIdx, ri } = selRegion.value + const shape = dd.sm[shapeIdx] + const existing = (shape?.decs ?? []).find(d => d.ri === ri) + hasDec.value = !!existing + Object.assign(decForm, existing ?? { ri, kind: 'modern', rows: 1, cols: 1, bw: 25, color: '#2b2b2b' }, { ri }) + decVisible.value = true +} + +function applyPreset(p: (typeof DEC_PRESETS)[number]) { + Object.assign(decForm, { kind: p.kind, rows: p.rows, cols: p.cols, bw: p.bw, color: p.color }) +} + +function thumbStyle(p: (typeof DEC_PRESETS)[number]) { + // 纯 CSS 缩略图: 用线性渐变模拟格条 + const rowPct = p.rows ? 100 / (p.rows + 1) : 0 + const colPct = p.cols ? 100 / (p.cols + 1) : 0 + const horizontal = Array.from({ length: p.rows }, (_, i) => `${p.color} ${rowPct * (i + 1) - 2}% ${rowPct * (i + 1) + 2}%`).join(',') + const vertical = Array.from({ length: p.cols }, (_, i) => `${p.color} ${colPct * (i + 1) - 2}% ${colPct * (i + 1) + 2}%`).join(',') + const base = 'linear-gradient(135deg,#dceef0,#bcd9dc)' + const grad = [base, ...(horizontal ? [`linear-gradient(0deg,transparent,${horizontal},transparent)`] : []), + ...(vertical ? [`linear-gradient(90deg,transparent,${vertical},transparent)`] : [])] + return { background: grad.join(',') } +} + +function applyDec() { + if (!selRegion.value || !dd) return + const { shapeIdx } = selRegion.value + const shape = dd.sm[shapeIdx] + if (!shape) return + undoStack.push(JSON.stringify(dd)) + shape.decs = shape.decs ?? [] + const found = shape.decs.find(d => d.ri === decForm.ri) + if (found) Object.assign(found, JSON.parse(JSON.stringify(decForm))) + else shape.decs.push(JSON.parse(JSON.stringify(decForm))) + decVisible.value = false + render() + emit('change') + ElMessage.success('格条已应用') +} + +function removeDec() { + if (!selRegion.value || !dd) return + const { shapeIdx, ri } = selRegion.value + const shape = dd.sm[shapeIdx] + if (!shape?.decs) return + undoStack.push(JSON.stringify(dd)) + shape.decs = shape.decs.filter(d => d.ri !== ri) + hasDec.value = false + decVisible.value = false + render() + emit('change') + ElMessage.success('格条已清除') +} defineExpose({ exportDrawing }) // ============ 视图控制 ============ @@ -958,4 +1081,11 @@ 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; flex-wrap: wrap; } .dim-input { display: inline-flex; align-items: center; gap: 4px; color: #409eff; font-weight: 600; } +.dec-presets { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } +.dec-preset { display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: pointer; + padding: 6px; border: 1.5px solid transparent; border-radius: 8px; font-size: 12px; color: #606266; } +.dec-preset:hover { background: #f5f7fa; } +.dec-preset.active { border-color: #409eff; background: #ecf5ff; } +.dec-thumb { width: 100%; height: 44px; border: 1px solid #dcdfe6; border-radius: 4px; overflow: hidden; } +.dec-thumb-bar { width: 100%; height: 100%; } diff --git a/frontend/src/components/WindoorThreed.vue b/frontend/src/components/WindoorThreed.vue index f5e3e50..5aa21d1 100644 --- a/frontend/src/components/WindoorThreed.vue +++ b/frontend/src/components/WindoorThreed.vue @@ -58,7 +58,7 @@ import { ElMessage } from 'element-plus' import * as THREE from 'three' import { bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape, - type DrawingData, type Pt, + type DecBar, type DrawingData, type Pt, } from '../types/drawing' import { profileGeo } from '../three/profileGeo' @@ -268,7 +268,7 @@ function build(drawing: DrawingData) { const regionBBs: BBox[] = regions.map(r => bboxOf(regionPoints(r))) const used = new Set() - const panels: { rect: BBox; sash: (typeof sashes)[number] }[] = [] + const panels: { rect: BBox; sash: (typeof sashes)[number]; ri: number }[] = [] for (const s of sashes) { const sb: BBox = { x0: s.x0, y0: s.y0, x1: s.x1, y1: s.y1 } let best = -1, bestA = 0 @@ -277,7 +277,23 @@ function build(drawing: DrawingData) { const a = overlapArea(sb, rb) if (a > bestA) { bestA = a; best = ri } }) - if (best >= 0 && bestA > 0) { used.add(best); panels.push({ rect: regionBBs[best], sash: s }) } + if (best >= 0 && bestA > 0) { used.add(best); panels.push({ rect: regionBBs[best], sash: s, ri: best }) } + } + + /** 装饰格条(自研 decs 扩展): 玻璃前表面加横竖格条体 */ + const addDecBars = (parent: THREE.Group, dec: DecBar, w: number, h: number, mx: number, my: number, z: number) => { + const bw = Math.max(0.004, dec.bw * S) + const mat = new THREE.MeshStandardMaterial({ + color: new THREE.Color(dec.color), roughness: 0.5, metalness: 0.3, + }) + for (let i = 1; i <= dec.rows; i++) { + const y = my - h / 2 + (h * i) / (dec.rows + 1) + barX(parent, mat, w * 0.995, bw, DEPTH * 0.25, mx, y, z, 1) + } + for (let i = 1; i <= dec.cols; i++) { + const x = mx - w / 2 + (w * i) / (dec.cols + 1) + barY(parent, mat, h * 0.995, bw, DEPTH * 0.25, x, my, z, 1) + } } // 静态玻璃 + 中梃(区域薄型材) @@ -295,10 +311,12 @@ function build(drawing: DrawingData) { barX(frameGroup, mullionMat, w, t, DEPTH * 0.55, mx, my - h / 2 + t / 2, 0) barY(frameGroup, mullionMat, h, t, DEPTH * 0.55, mx - w / 2 + t / 2, my, 0) barY(frameGroup, mullionMat, h, t, DEPTH * 0.55, mx + w / 2 - t / 2, my, 0) + const dec = (shape.decs ?? []).find(d => d.ri === ri) + if (dec) addDecBars(frameGroup, dec, w, h, mx, my, 0.012) }) // 扇面板(可开合) - panels.forEach(({ rect, sash: s }, k) => { + panels.forEach(({ rect, sash: s, ri }, k) => { const w = (rect.x1 - rect.x0) * S, h = (rect.y1 - rect.y0) * S const cxr = ((rect.x0 + rect.x1) / 2 - cx) * S const cyr = (cy - (rect.y0 + rect.y1) / 2) * S @@ -311,6 +329,8 @@ function build(drawing: DrawingData) { barX(group, sashMat, w, t, DEPTH * 0.7, 0, -h / 2 + t / 2, 0) barY(group, sashMat, h, t, DEPTH * 0.7, -w / 2 + t / 2, 0, 0) barY(group, sashMat, h, t, DEPTH * 0.7, w / 2 - t / 2, 0, 0) + const dec = (shape.decs ?? []).find(d => d.ri === ri) + if (dec) addDecBars(group, dec, w, h, 0, 0, 0.011) group.position.set(cxr, cyr, 0) sashRoot.add(group) diff --git a/frontend/src/types/.drawing-bundle.mjs b/frontend/src/types/.drawing-bundle.mjs new file mode 100644 index 0000000..78fbd28 --- /dev/null +++ b/frontend/src/types/.drawing-bundle.mjs @@ -0,0 +1,311 @@ +// src/types/drawing.ts +var DEC_PRESETS = [ + { kind: "modern", label: "\u73B0\u4EE3\u8F7B\u5962", rows: 1, cols: 1, bw: 25, color: "#2b2b2b" }, + { kind: "italian", label: "\u610F\u5F0F\u7B80\u7EA6", rows: 1, cols: 1, bw: 18, color: "#9aa0a6" }, + { kind: "cream", label: "\u5976\u6CB9\u98CE", rows: 2, cols: 2, bw: 22, color: "#f3ece2" }, + { kind: "chinese", label: "\u4E2D\u5F0F", rows: 2, cols: 2, bw: 28, color: "#6b4a2b" }, + { kind: "european", label: "\u6B27\u5F0F\u53E4\u5178", rows: 3, cols: 3, bw: 20, color: "#c9a86a" }, + { kind: "french", label: "\u6CD5\u5F0F", rows: 2, cols: 2, bw: 16, color: "#d8c9a3" }, + { kind: "medit", label: "\u5730\u4E2D\u6D77", rows: 2, cols: 3, bw: 24, color: "#3a6ea5" }, + { kind: "pastoral", label: "\u7530\u56ED\u5BC6\u683C", rows: 4, cols: 4, bw: 14, color: "#ffffff" } +]; +function parseDrawing(raw) { + const dd = typeof raw === "string" ? JSON.parse(raw) : raw; + return dd; +} +function regionPoints(region) { + return region.map((seg) => ({ x: seg.ps.x, y: seg.ps.y })); +} +function bboxOf(points) { + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) }; +} +function spAxis(sp) { + const n = sp.line?.norm ?? { x: 1, y: 0 }; + return Math.abs(n.x) >= Math.abs(n.y) ? "v" : "h"; +} +function spPos(sp) { + return spAxis(sp) === "v" ? sp.line.pt.x : sp.line.pt.y; +} +function pointInRegion(p, region) { + let inside = false; + const pts = regionPoints(region); + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const xi = pts[i].x, yi = pts[i].y, xj = pts[j].x, yj = pts[j].y; + if (yi > p.y !== yj > p.y && p.x < (xj - xi) * (p.y - yi) / (yj - yi) + xi) inside = !inside; + } + return inside; +} +function dfsLeaves(node, out) { + if (node.kids.length === 0) { + out.push(node); + return; + } + node.kids.forEach((k) => dfsLeaves(k, out)); +} +function replayShape(shape) { + const base = (shape.polygon?.polygon ?? []).map((r) => r.filter((s) => s && typeof s === "object" && s.ps && s.pe && Number.isFinite(s.ps.x) && Number.isFinite(s.ps.y) && Number.isFinite(s.pe.x) && Number.isFinite(s.pe.y)).map((s) => ({ ps: { ...s.ps }, pe: { ...s.pe } }))).filter((r) => r.length >= 3); + const root = { segs: base[0] ?? [], kids: [] }; + const extraRoots = base.slice(1).map((segs) => ({ segs, kids: [] })); + const idMap = /* @__PURE__ */ new Map(); + const clips = []; + const allLeaves = () => { + const out = []; + dfsLeaves(root, out); + extraRoots.forEach((r) => dfsLeaves(r, out)); + return out; + }; + let spIdx = 0; + for (const sp of shape.mm?.sp ?? []) { + const myId = spIdx; + spIdx++; + if (!sp?.line?.pt || !sp.line.norm) continue; + const axis = spAxis(sp); + const pos = spPos(sp); + let target = null; + const nid = sp.polyId?.idx; + const cpos = sp.polyId?.pos; + if (Number.isInteger(nid) && nid >= 0) { + const node = nid === 0 ? root : idMap.get(nid); + if (node) { + if (node.kids.length === 0) target = node; + else if (Number.isInteger(cpos) && cpos >= 0 && cpos < node.kids.length) { + target = node.kids[cpos]; + } + } + } + if (!target) target = allLeaves().find((nd) => pointInRegion(sp.line.pt, nd.segs)) ?? null; + if (!target || target.segs.length < 3) continue; + const bb = bboxOf(regionPoints(target.segs)); + 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 }); + idMap.set(myId, target); + continue; + } + const split = splitPolySegments(target.segs, axis, pos); + if (!split) { + clips.push({ sp, bb }); + idMap.set(myId, target); + continue; + } + const nrm = sp.line.norm; + const sideOf = (segs) => { + for (const s of segs) { + for (const p of [s.ps, s.pe]) { + const d = (p.x - sp.line.pt.x) * nrm.x + (p.y - sp.line.pt.y) * nrm.y; + if (Math.abs(d) > 1e-6) return d > 0; + } + } + return true; + }; + const c0 = sideOf(split.a) ? split.a : split.b; + target.kids = [{ segs: c0, kids: [] }, { segs: sideOf(split.a) ? split.b : split.a, kids: [] }]; + target.segs = []; + idMap.set(myId, target); + clips.push({ sp, bb }); + } + const leaves = allLeaves(); + const regions = leaves.map((l) => l.segs).filter((s) => s.length >= 3); + const nodeId = /* @__PURE__ */ new Map([[root, 0]]); + const leafRefs = []; + const walk = (node, id) => { + node.kids.forEach((k, i) => { + if (k.kids.length === 0) leafRefs.push({ idx: id, pos: i }); + else { + const kidId = nodeId.get(k); + if (kidId != null) walk(k, kidId); + } + }); + }; + if (root.kids.length === 0 && root.segs.length >= 3) leafRefs.push({ idx: 0, pos: 0 }); + else walk(root, 0); + extraRoots.forEach((r, i) => { + const eid = base.length + spIdx + i; + idMap.set(eid, r); + if (r.kids.length === 0) leafRefs.push({ idx: eid, pos: 0 }); + else walk(r, eid); + }); + const resolveRef = (idx, pos) => { + const node = idx === 0 ? root : idMap.get(idx); + if (!node) return null; + if (node.kids.length === 0) return node.segs; + if (Number.isInteger(pos) && pos >= 0 && pos < node.kids.length) { + const k = node.kids[pos]; + if (k.kids.length === 0) return k.segs; + const l = []; + dfsLeaves(k, l); + return l[0]?.segs ?? null; + } + return null; + }; + return { regions, clips, leafRefs, resolveRef }; +} +function splitPolySegments(region, axis, pos) { + const pts = regionPoints(region); + const n = pts.length; + const get = (p) => axis === "v" ? p.x : p.y; + const crossings = []; + 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, to) => { + const ps = [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) => { + const segs = []; + 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) }; +} +function hmOf(sub) { + const hm = sub.hm; + return hm ?? {}; +} +function inferSashGlyphs(shape, replayed) { + let rr = null; + let regions; + if (replayed && !Array.isArray(replayed)) { + rr = replayed; + regions = rr.regions; + } else if (replayed) { + regions = replayed; + } else { + rr = replayShape(shape); + regions = rr.regions; + } + const regionBBs = regions.map((r) => bboxOf(regionPoints(r))); + const glyphs = []; + const bdRect = (bd) => { + if (!bd.from || !bd.to) return null; + const r = { + x0: Math.min(bd.from.x, bd.to.x), + y0: Math.min(bd.from.y, bd.to.y), + x1: Math.max(bd.from.x, bd.to.x), + y1: Math.max(bd.from.y, bd.to.y) + }; + const ok = [r.x0, r.y0, r.x1, r.y1].every((v) => Number.isFinite(v)); + return ok && !bd.hidden && r.x1 - r.x0 > 10 && r.y1 - r.y0 > 10 ? r : null; + }; + const kindOf = (sub) => String(sub.type) === "Slide" ? "sliding" : "casement"; + const rectOfRef = (ref) => { + if (!ref || !Number.isInteger(ref.idx) || ref.idx < 0) return null; + if (rr) { + const segs = rr.resolveRef(ref.idx, ref.pos); + if (segs && segs.length >= 3) return bboxOf(regionPoints(segs)); + } + const i = ref.idx; + return i < regionBBs.length ? regionBBs[i] : null; + }; + const iouR = (a, b) => { + const w = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0); + const h = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0); + if (w <= 0 || h <= 0) return 0; + const inter = w * h; + return inter / ((a.x1 - a.x0) * (a.y1 - a.y0) + (b.x1 - b.x0) * (b.y1 - b.y0) - inter); + }; + const pushGlyphs = (r, entries, sub, forceRect = false) => { + const kind = kindOf(sub); + const hm = hmOf(sub); + for (const e of entries) { + const rect = !forceRect ? rectOfRef(e.polyId) ?? r : r; + if (glyphs.some((g) => iouR(rect, g) > 0.15)) continue; + glyphs.push({ + x0: rect.x0, + y0: rect.y0, + x1: rect.x1, + y1: rect.y1, + od: e.od ?? sub.od ?? 90, + openToward: e.openToward ?? sub.openToward ?? hm.openToward, + openDirection: e.openDirection ?? hm.openDirection, + isDoor: Boolean(e.isDoor || sub.isDoor), + wtf: Boolean(e.wtf ?? sub.wtf ?? sub.withTurningFrame), + kind + }); + } + }; + const unmatched = []; + for (const sub of shape.sm ?? []) { + const type = String(sub.type ?? ""); + if (type === "Screen" || type === "DoubleScreen") continue; + const raw = sub.sashes?.length ? sub.sashes : [sub]; + const entries = raw.filter((e) => e && e.enabled !== false); + if (!entries.length) continue; + const n = Math.max(entries.length, type.startsWith("Double") ? 2 : 1); + let rect = sub.bd ? bdRect(sub.bd) : null; + if (!rect) rect = rectOfRef(sub.polyId); + if (rect) { + for (let k = 0; k < n; k++) { + const part = n > 1 ? { x0: rect.x0 + (rect.x1 - rect.x0) * k / n, y0: rect.y0, x1: rect.x0 + (rect.x1 - rect.x0) * (k + 1) / n, y1: rect.y1 } : rect; + pushGlyphs(part, [entries[Math.min(k, entries.length - 1)]], sub, n > 1); + } + continue; + } + unmatched.push({ sub, entries }); + } + if (unmatched.length && regions.length) { + const flatPts = []; + for (const r of regions) { + for (const seg of r) { + if (Number.isFinite(seg.ps?.x) && Number.isFinite(seg.ps?.y)) flatPts.push(seg.ps); + if (Number.isFinite(seg.pe?.x) && Number.isFinite(seg.pe?.y)) flatPts.push(seg.pe); + } + } + const fb = bboxOf(flatPts); + const K = unmatched.reduce((s, u) => s + u.entries.length, 0); + const colW = (fb.x1 - fb.x0) / K; + let i = 0; + for (const u of unmatched) { + for (const e of u.entries) { + glyphs.push({ + x0: fb.x0 + i * colW, + x1: fb.x0 + (i + 1) * colW, + y0: fb.y0, + y1: fb.y1, + od: e.od ?? u.sub.od ?? 90, + openToward: e.openToward ?? u.sub.openToward ?? hmOf(u.sub).openToward, + openDirection: e.openDirection ?? hmOf(u.sub).openDirection, + isDoor: Boolean(e.isDoor || u.sub.isDoor), + kind: kindOf(u.sub) + }); + i++; + } + } + } + return { regions, sashes: glyphs }; +} +export { + DEC_PRESETS, + bboxOf, + inferSashGlyphs, + parseDrawing, + pointInRegion, + regionPoints, + replayShape, + spAxis, + spPos, + splitPolySegments +}; diff --git a/frontend/src/types/drawing.ts b/frontend/src/types/drawing.ts index 9e22c95..3ce770f 100644 --- a/frontend/src/types/drawing.ts +++ b/frontend/src/types/drawing.ts @@ -70,6 +70,28 @@ export interface SashShape { [k: string]: unknown } +/** 装饰格条(自研扩展字段,挂在 FrameShape.decs;ri = 重放区域索引) */ +export interface DecBar { + ri: number + kind: string // 风格预设 id(modern/italian/cream/chinese/european/french/medit/pastoral) + rows: number // 横向分割条数 + cols: number // 纵向分割条数 + bw: number // 条宽 mm + color: string +} + +/** 格条风格预设(对齐源站 decorationBar 8 种装修风格) */ +export const DEC_PRESETS: { kind: string; label: string; rows: number; cols: number; bw: number; color: string }[] = [ + { kind: 'modern', label: '现代轻奢', rows: 1, cols: 1, bw: 25, color: '#2b2b2b' }, + { kind: 'italian', label: '意式简约', rows: 1, cols: 1, bw: 18, color: '#9aa0a6' }, + { kind: 'cream', label: '奶油风', rows: 2, cols: 2, bw: 22, color: '#f3ece2' }, + { kind: 'chinese', label: '中式', rows: 2, cols: 2, bw: 28, color: '#6b4a2b' }, + { kind: 'european', label: '欧式古典', rows: 3, cols: 3, bw: 20, color: '#c9a86a' }, + { kind: 'french', label: '法式', rows: 2, cols: 2, bw: 16, color: '#d8c9a3' }, + { kind: 'medit', label: '地中海', rows: 2, cols: 3, bw: 24, color: '#3a6ea5' }, + { kind: 'pastoral', label: '田园密格', rows: 4, cols: 4, bw: 14, color: '#ffffff' }, +] + export interface FrameShape { type: 'Frame' | string polygon?: { @@ -87,6 +109,7 @@ export interface FrameShape { ps?: Record uid?: string titl?: string + decs?: DecBar[] // 装饰格条(自研扩展) [k: string]: unknown } diff --git a/shots/verify-decbar-2d.png b/shots/verify-decbar-2d.png new file mode 100644 index 0000000..3aecee2 Binary files /dev/null and b/shots/verify-decbar-2d.png differ diff --git a/src/huamenchuang/services/quote_service.py b/src/huamenchuang/services/quote_service.py index f2c5c2d..83376c3 100644 --- a/src/huamenchuang/services/quote_service.py +++ b/src/huamenchuang/services/quote_service.py @@ -92,6 +92,20 @@ def takeoff(dd) -> TakeoffResult: # 玻璃面积(重放后各窗格之和) result.glass_area_m2 += sum(_shoelace_area(r) for r in region_pts) / 1_000_000 + # 装饰格条(自研 decs 扩展): 横条=区域宽×rows, 竖条=区域高×cols + for dec in (shape.get("decs") or []): + if not isinstance(dec, dict): + continue + ri = int(dec.get("ri", -1)) + if not (0 <= ri < len(regions)): + continue + bb = region_bbs[ri] + rows, cols = int(dec.get("rows", 0) or 0), int(dec.get("cols", 0) or 0) + if rows: + result.bars.append(BarItem("dec_bar", round(bb[2] - bb[0]), rows, "格条-横")) + if cols: + result.bars.append(BarItem("dec_bar", round(bb[3] - bb[1]), cols, "格条-竖")) + # 扇框 for sub in (shape.get("sm") or []): bd = sub.get("bd") if isinstance(sub, dict) else None @@ -153,7 +167,7 @@ def quote(drawing: str | dict, prices: dict | None = None, scripts: dict | None """ from huamenchuang.services.formula import FormulaError, eval_formula - p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180} + p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180, "dec_bar": 12} if prices: p.update(prices)