|
|
@@ -130,6 +130,10 @@
|
|
|
text-align: center; cursor: pointer; transition: all 0.2s; background: #FAFBFC;
|
|
|
}
|
|
|
.upload-area:hover { border-color: var(--accent); background: #EBF8FF; }
|
|
|
+ .upload-area.dragover { border-color: var(--primary); background: #EBF8FF; }
|
|
|
+ .drop-zone { position: relative; }
|
|
|
+ .drop-zone .drop-hint { display:none; position:absolute; inset:0; background:rgba(49,130,206,.14); border:2px dashed var(--primary); color:#2B6CB0; font-weight:700; font-size:14px; align-items:center; justify-content:center; pointer-events:none; z-index:5; }
|
|
|
+ .drop-zone.dragover .drop-hint { display:flex; }
|
|
|
.upload-area .icon { font-size: 36px; margin-bottom: 8px; }
|
|
|
.upload-area .text { font-size: 14px; color: var(--text-muted); }
|
|
|
.upload-area .types { font-size: 12px; color: #A0AEC0; margin-top: 4px; }
|
|
|
@@ -388,7 +392,7 @@
|
|
|
|
|
|
<div id="q-upload-zone-unified" class="upload-area" onclick="document.getElementById('q-file-unified').click()" style="padding:16px;margin-bottom:10px;">
|
|
|
<div class="icon">📐</div>
|
|
|
- <div class="text">点击上传户型图 / CAD图纸</div>
|
|
|
+ <div class="text">点击或拖拽上传户型图 / CAD图纸</div>
|
|
|
<div class="types">户型图支持 JPG、PNG、WEBP;CAD支持 DWG、DXF、PDF | 上传后自动进入 AI 解析</div>
|
|
|
</div>
|
|
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
|
|
@@ -403,11 +407,11 @@
|
|
|
<div style="margin-bottom:12px;">
|
|
|
<div style="font-size:14px;font-weight:600;color:var(--primary);">🎨 图纸识别标准规则设置(户型图 / CAD 通用)</div>
|
|
|
</div>
|
|
|
- <div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">平面户型图与CAD图纸可统一用红色框标注范围,AI抓取红框边界做完整测量;无红框时 AI 全自动识别;尺寸标注箭头支持红/蓝两色(蓝色自动 OCR 读数)。</div>
|
|
|
- <div style="font-size:12px;font-weight:600;color:#2D3748;margin-bottom:8px;">标注颜色(红框圈注 / 蓝色尺寸标注):</div>
|
|
|
+ <div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">平面户型图与CAD图纸可统一用红色框标注范围,AI抓取红框边界做完整测量;无红框时 AI 全自动识别;尺寸标注统一为红色(红箭头+红色数字自动 OCR 读数)。</div>
|
|
|
+ <div style="font-size:12px;font-weight:600;color:#2D3748;margin-bottom:8px;">标注颜色(红框圈注 / 红色尺寸标注):</div>
|
|
|
<div style="margin-bottom:14px;">
|
|
|
- <select style="width:100%;" disabled title="红框=柜体圈注;蓝色箭头=尺寸标注(OCR 自动读数)">
|
|
|
- <option value="red" selected>红色框(柜体圈注)+蓝色箭头(尺寸标注)· 隐式参照物为厨房台面深度600mm</option>
|
|
|
+ <select style="width:100%;" disabled title="红框=柜体圈注;红色箭头/红色数字=尺寸标注(OCR 自动读数)">
|
|
|
+ <option value="red" selected>红色框(柜体圈注)+红色尺寸标注(箭头+数字 OCR 自动读数)· 隐式参照物为厨房台面深度600mm</option>
|
|
|
</select>
|
|
|
</div>
|
|
|
<div style="padding:10px 12px;background:#F0FFF4;border:1px solid #9AE6B4;border-radius:8px;font-size:12px;color:#276749;line-height:1.8;">
|
|
|
@@ -455,10 +459,14 @@
|
|
|
</div>
|
|
|
|
|
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:stretch;">
|
|
|
- <div style="flex:3;min-width:380px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:#fff;">
|
|
|
- <div style="font-size:11px;color:var(--text-muted);padding:7px 10px;background:#F7FAFC;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
|
|
+ <div id="q-canvas-drop" class="drop-zone" style="flex:3;min-width:380px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:#fff;">
|
|
|
+ <div class="drop-hint">📥 松开即更换户型图(JPG / PNG / WEBP,自动重新识别)</div>
|
|
|
+ <div style="font-size:11px;color:var(--text-muted);padding:7px 10px;background:#F7FAFC;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:8px;">
|
|
|
<span>📐 图纸识别结果叠加(<span style="color:#805AD5;">厨房</span> / <span style="color:#3182CE;">柜体</span>;图纸自带红框不二次叠加)</span>
|
|
|
- <span id="q-ai-scene-chip" style="font-size:11px;font-weight:600;color:#2B6CB0;">等待识别…</span>
|
|
|
+ <span style="display:flex;align-items:center;gap:8px;">
|
|
|
+ <button class="btn btn-outline btn-sm" onclick="document.getElementById('q-file-unified').click()" title="点击选择新图纸,或直接将图纸拖拽到本区域">🔄 更换户型图</button>
|
|
|
+ <span id="q-ai-scene-chip" style="font-size:11px;font-weight:600;color:#2B6CB0;">等待识别…</span>
|
|
|
+ </span>
|
|
|
</div>
|
|
|
<canvas id="q-ai-canvas" style="width:100%;display:block;background:#fff;"></canvas>
|
|
|
</div>
|
|
|
@@ -473,7 +481,7 @@
|
|
|
</div>
|
|
|
|
|
|
<div id="q-red-anno-row" style="display:none;margin-top:14px;background:#FFF5F5;border:1px solid #FED7D7;border-radius:8px;padding:8px 12px;font-size:12px;color:#C53030;">
|
|
|
- 🎯 标注箭头(红/蓝):长度已检测,标注值 = <input type="number" id="q-red-anno" value="2400" style="width:80px;padding:2px 6px;">(蓝色标注会先尝试 OCR 自动读数;单位自动推断:与 600mm 台面深度锚点交叉校验 mm/cm/m,锚点未定位默认 mm)→ 据此建立全图比例(±2-5%)<span id="q-red-anno-infer" style="color:#97266D;font-weight:600;"></span>
|
|
|
+ 🎯 红色标注箭头:长度已检测,标注值 = <input type="number" id="q-red-anno" value="2400" style="width:80px;padding:2px 6px;">(红色标注会先尝试 OCR 自动读数;单位自动推断:与 600mm 台面深度锚点交叉校验 mm/cm/m,锚点未定位默认 mm)→ 据此建立全图比例(±2-5%)<span id="q-red-anno-infer" style="color:#97266D;font-weight:600;"></span>
|
|
|
</div>
|
|
|
<div id="q-ai-results" style="display:none;gap:16px;flex-wrap:wrap;margin-top:16px;align-items:stretch;">
|
|
|
<div style="flex:3;min-width:380px;border:1px solid var(--border);border-radius:8px;background:#fff;overflow:hidden;">
|
|
|
@@ -755,6 +763,8 @@ const RULES=[["目测估算像素/毫米比例","用图纸标注(如有)或
|
|
|
/* rate 默认15 → 单价=所选材质档最高单价(双饰面3500);厨房=宽(最长边)/高(最短边)/总长度/单价 均可调 */
|
|
|
const S={ft:"plan",redbox:false,scale:null,scale600:null,scaleSrc:"",objs:[],mat:0,rate:15,
|
|
|
ruleX:Object.fromEntries(EXTRA.map(e=>[e.id,!e.off])),kitExtras:mkExtras("",true),kitW:null,kitH:null,kitLen:null,kitPrice:null,brand:0,door:2};
|
|
|
+/* 状态兜底:旧版存档/异常恢复可能缺失 objs 等数组字段 → 统一回退空数组,杜绝 undefined.length / undefined.forEach 崩溃 */
|
|
|
+function ensureS(){if(!Array.isArray(S.objs))S.objs=[];if(!Array.isArray(S.redRects))S.redRects=[];if(!Array.isArray(S.redLabels))S.redLabels=[];}
|
|
|
const $=id=>document.getElementById(id);
|
|
|
const fmt=n=>(Math.round(n)||0).toLocaleString("zh-CN");
|
|
|
const fmt2=n=>(Math.round(n*100)/100).toLocaleString("zh-CN",{maximumFractionDigits:2});
|
|
|
@@ -769,6 +779,14 @@ function annoInfer(sc600){if(!S.redArrow||!S.redArrow.len)return null;
|
|
|
function annoScale(){const r=annoInfer(S.scale600||0);return r?r.scale:0;}
|
|
|
function renderAnnoInfer(){const el=$("q-red-anno-infer");if(!el)return;const inf=S.redArrow?annoInfer(S.scale600||0):null;
|
|
|
el.textContent=inf?(inf.estM>0?"|推断单位:"+inf.unit+"(标注 "+fmt2(inf.vm)+"m vs 600mm锚点反推 "+fmt2(inf.estM)+"m)":"|600mm锚点未定位,标注默认按 mm"):"";}
|
|
|
+/* OCR 含糊候选复核:存在多个候选值时,取与 600mm 锚点反推箭头实长偏差最小的候选(改善须≥0.05对数距离才改判,避免噪音翻转) */
|
|
|
+function annoPick(sc600){if(!S.redArrow||!S.redAnnoCands||S.redAnnoCands.length<2||!(sc600>0))return;
|
|
|
+ const estM=S.redArrow.len*sc600;if(!(estM>0))return;
|
|
|
+ const dist=v=>{let bd=1e9;for(const c of[v/1000,v/100,v])bd=Math.min(bd,Math.abs(Math.log(c/estM)));return bd;};
|
|
|
+ let bi=0,bd=dist(S.redAnnoCands[0]);
|
|
|
+ for(let i=1;i<S.redAnnoCands.length;i++){const d=dist(S.redAnnoCands[i]);if(d+0.05<bd){bd=d;bi=i;}}
|
|
|
+ if(bi>0){$("q-red-anno").value=S.redAnnoCands[bi];
|
|
|
+ logAI("· OCR 候选含糊("+S.redAnnoCands.join("/")+")→ 按 600mm 锚点反推箭头实长 "+fmt2(estM)+"m 复核,改判为 <b>"+S.redAnnoCands[bi]+"</b>","w");}}
|
|
|
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
|
|
function precision(){if(S.ft==="cad")return{cls:"exact",txt:"精确值 ±0.1-1%"};if(S.redbox)return{cls:"est",txt:"估算值 ±2-5%(红框+标注)"};return S.scale?{cls:"est",txt:"估算值 ±5-8%(600mm锚点)"}:{cls:"na",txt:"精度:待识别"};}
|
|
|
function toast(msg,cls){const t=document.createElement("div");t.className="toast "+(cls||"toast-info");t.textContent=msg;document.body.appendChild(t);setTimeout(()=>t.remove(),2600);}
|
|
|
@@ -798,7 +816,7 @@ function qNewQuote(){qResetEditor();switchView("editor");qOpenEditorStep(1);}
|
|
|
/* 新建报价单:完整清空上一单状态与识别UI,避免携带旧数据 */
|
|
|
function qResetEditor(){
|
|
|
img=null;overlays=[];ctx.clearRect(0,0,cv.width,cv.height);
|
|
|
- Object.assign(S,{ft:"plan",redbox:false,scale:null,scale600:null,scaleSrc:"",objs:[],mat:0,rate:15,kitW:null,kitH:null,kitLen:null,kitPrice:null,kitBox:null,redArrow:null,brand:0,door:2});
|
|
|
+ Object.assign(S,{ft:"plan",redbox:false,scale:null,scale600:null,scaleSrc:"",objs:[],mat:0,rate:15,kitW:null,kitH:null,kitLen:null,kitPrice:null,kitBox:null,redArrow:null,redRects:[],redLabels:[],brand:0,door:2});
|
|
|
S.ruleX=Object.fromEntries(EXTRA.map(e=>[e.id,!e.off]));S.kitExtras=mkExtras("",true);
|
|
|
$("q-file-unified").value="";$("q-btn-analyze").disabled=true;$("q-step2-next-btn").disabled=true;
|
|
|
$("q-status-drawing").textContent="⬜ 图纸:未上传";$("q-status-drawing").style.background="#EDF2F7";$("q-status-drawing").style.color="var(--text-muted)";
|
|
|
@@ -831,7 +849,7 @@ function draw(){if(!img)return;const k=cv.width/img.naturalWidth;ctx.clearRect(0
|
|
|
ctx.lineWidth=6;ctx.strokeStyle="rgba(255,255,255,.92)";ctx.strokeText(lb,lx,ly);
|
|
|
ctx.fillStyle=o.t==="panel"?"#38A169":"#E53E3E";ctx.fillText(lb,lx,ly);
|
|
|
ctx.textAlign="left";}});}
|
|
|
-function showImg(im){img=im;cv.width=1000;cv.height=Math.round(1000*im.naturalHeight/im.naturalWidth);draw();
|
|
|
+function showImg(im){img=im;overlays=[];cv.width=1000;cv.height=Math.round(1000*im.naturalHeight/im.naturalWidth);draw();
|
|
|
$("q-btn-analyze").disabled=false;
|
|
|
$("q-status-drawing").textContent="✅ 图纸:已上传("+im.naturalWidth+"×"+im.naturalHeight+")";
|
|
|
$("q-status-drawing").style.background="#C6F6D5";$("q-status-drawing").style.color="#276749";}
|
|
|
@@ -862,10 +880,23 @@ function genSample(u,cad){
|
|
|
const im=new Image();im.src=c.toDataURL("image/png");return im;
|
|
|
}
|
|
|
function qUseMockDrawing(isU,isCad){const im=genSample(isU,isCad);im.onload=()=>{showImg(im);toast(isCad?"已载入示例CAD图(黑白线图)":isU?"已载入示例户型图(U型厨房+红框)":"已载入示例户型图(L型厨房+红框)");qStartAnalysis();};}
|
|
|
-function qHandleUnified(inp){const f=inp.files[0];if(!f)return;
|
|
|
+/* 统一图纸载入入口:点击上传 / 拖拽上传 / 更换户型图 共用,载入后自动重新识别 */
|
|
|
+function qLoadFile(f){if(!f)return;
|
|
|
if(/\.(pdf|dwg|dxf)$/i.test(f.name)){alert("PDF/DWG 请先转 JPG/PNG(Demo 限制)");return;}
|
|
|
+ if(f.type&&f.type.indexOf("image")!==0&&!/\.(jpe?g|png|webp|bmp)$/i.test(f.name)){alert("仅支持图片格式:JPG / PNG / WEBP");return;}
|
|
|
const rd=new FileReader();rd.onload=()=>{const im=new Image();im.onload=()=>{showImg(im);qStartAnalysis();};im.src=rd.result;};rd.readAsDataURL(f);}
|
|
|
-function qStartAnalysis(){if(!img){toast("请先上传图纸或选择示例","toast-error");return;}qOpenEditorStep(2);runAI();}
|
|
|
+function qHandleUnified(inp){qLoadFile(inp.files[0]);inp.value="";} /* 清空 value:同名文件可重复选择触发 onchange */
|
|
|
+/* 拖拽上传绑定:dragover 高亮提示,drop 载入并自动重新识别 */
|
|
|
+function qBindDrop(el){if(!el)return;
|
|
|
+ ["dragenter","dragover"].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();e.stopPropagation();el.classList.add("dragover");}));
|
|
|
+ ["dragleave","dragend"].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();e.stopPropagation();el.classList.remove("dragover");}));
|
|
|
+ el.addEventListener("drop",e=>{e.preventDefault();e.stopPropagation();el.classList.remove("dragover");
|
|
|
+ const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0];qLoadFile(f);});}
|
|
|
+function qStartAnalysis(){if(!img){toast("请先上传图纸或选择示例","toast-error");return;}qOpenEditorStep(2);
|
|
|
+ runAI().catch(e=>{ /* 识别异常兜底:清叠加、重绘画布、提示重试,避免留下错位/半残页面 */
|
|
|
+ overlays=[];if(img)draw();setProg(0,"");setProg(1,"");setProg(2,"");
|
|
|
+ logAI("⚠ AI 识别引擎异常:"+(e&&e.message?e.message:e)+"。画布已重置,请重试或更换图纸。","e");
|
|
|
+ toast("AI 识别异常,请重试","toast-error");});}
|
|
|
/* ========== AI 识别引擎 v3(墙体分离 + 柜体特征分类) ========== */
|
|
|
function logAI(html,cls){const el=$("q-ai-log");const s=document.createElement("span");if(cls)s.className=cls;s.innerHTML=html;el.appendChild(s);el.scrollTop=el.scrollHeight;}
|
|
|
function setProg(i,st){$("q-ai-prog-"+i).className="ai-progress-step "+(st||"");}
|
|
|
@@ -877,7 +908,7 @@ function dilateMask(m,w,h,it){let cur=m;for(let t=0;t<it;t++){const o=new Uint8A
|
|
|
for(let y=1;y<h-1;y++)for(let x=1;x<w-1;x++){const i=y*w+x;
|
|
|
if(cur[i]){o[i-1]=1;o[i+1]=1;o[i-w]=1;o[i+w]=1;}}
|
|
|
cur=o;}return cur;}
|
|
|
-/* ========== 标注数字 OCR(模板匹配 0-9,支持 90° 旋转,用于蓝色尺寸标注自动读数) ========== */
|
|
|
+/* ========== 标注数字 OCR(模板匹配 0-9,支持 90° 旋转,用于红色尺寸标注自动读数) ========== */
|
|
|
function bmCrop(data,w,h){let x0=1e9,y0=1e9,x1=-1,y1=-1;for(let y=0;y<h;y++)for(let x=0;x<w;x++){if(data[(y*w+x)*4+3]>120){if(x<x0)x0=x;if(x>x1)x1=x;if(y<y0)y0=y;if(y>y1)y1=y;}}
|
|
|
if(x1<0)return null;const W=x1-x0+1,H=y1-y0+1,a=new Uint8Array(W*H);
|
|
|
for(let y=0;y<H;y++)for(let x=0;x<W;x++)a[y*W+x]=data[((y0+y)*w+x0+x)*4+3]>120?1:0;return {w:W,h:H,a};}
|
|
|
@@ -905,22 +936,32 @@ function mkCC(W){return mask=>{const vis=new Uint8Array(mask.length),out=[];
|
|
|
function ocrNumber(mask,W,ar,cc,F){F=F||1;
|
|
|
const comps=cc(mask).filter(c=>c.area>=5*F*F&&Math.max(c.w,c.h)>=3*F&&Math.max(c.w,c.h)<=16*F);
|
|
|
const ax=ar.x+ar.w/2,ay=ar.y+ar.h/2,vert=ar.h>=ar.w;
|
|
|
- const near=comps.filter(c=>{const cx=c.x+c.w/2,cy=c.y+c.h/2;return Math.abs(cx-ax)<=25*F&&Math.abs(cy-ay)<=(vert?ar.h:ar.w)/2+25*F;});
|
|
|
+ const near=comps.filter(c=>{const cx=c.x+c.w/2,cy=c.y+c.h/2;
|
|
|
+ if(vert&&cx>=ar.x-2*F&&cx<=ar.x+ar.w+2*F&&(cy<=ar.y+8*F||cy>=ar.y+ar.h-8*F))return false; /* 剔除竖箭头两端箭头头 */
|
|
|
+ if(!vert&&cy>=ar.y-2*F&&cy<=ar.y+ar.h+2*F&&(cx<=ar.x+8*F||cx>=ar.x+ar.w-8*F))return false; /* 剔除横箭头两端箭头头 */
|
|
|
+ return Math.abs(cx-ax)<=60*F&&Math.abs(cy-ay)<=(vert?ar.h:ar.w)/2+25*F;}); /* 水平容差扩至60F:兼容数字水平写在箭头较远处 */
|
|
|
if(near.length<3||near.length>6)return 0;
|
|
|
const T=digTemplates();
|
|
|
- const combos=vert?[[-1,1],[1,3]]:[[1,0],[-1,2]]; /* [排序方向, 90°旋转次数] 仅几何自洽组合:逆时针90°标注=自下而上+旋1;顺时针90°=自上而下+旋3;水平=自左而右+旋0 / 倒置=自右而左+旋2 */
|
|
|
- let best=null;
|
|
|
- for(const cb of combos){const dir=cb[0],rq=cb[1];
|
|
|
- const list=near.slice().sort((a,b)=>dir*((vert?a.y:a.x)-(vert?b.y:b.x)));
|
|
|
- let s="",sc=0,ok=true;
|
|
|
+ /* [排序轴, 排序方向, 90°旋转次数] 覆盖两类排布:数字沿箭头旋转90°(竖排旋1/3、横排旋0/2)与数字水平写在箭头旁(自左而右旋0 / 自右而左旋2) */
|
|
|
+ const combos=vert?[["y",-1,1],["y",1,3],["x",1,0],["x",-1,2]]:[["x",1,0],["x",-1,2],["y",-1,1],["y",1,3]];
|
|
|
+ let res=[];
|
|
|
+ for(const cb of combos){const dir=cb[1],rq=cb[2];
|
|
|
+ const list=near.slice().sort((a,b)=>dir*((cb[0]==="y"?a.y:a.x)-(cb[0]==="y"?b.y:b.x)));
|
|
|
+ let s="",sc=0,ok=true;const amb=[];
|
|
|
for(const c of list){let b={w:c.w+1,h:c.h+1,a:new Uint8Array((c.w+1)*(c.h+1))};
|
|
|
for(let y=0;y<=c.h;y++)for(let x=0;x<=c.w;x++)b.a[y*b.w+x]=mask[(c.y+y)*W+(c.x+x)];
|
|
|
for(let q=0;q<rq;q++)b=bmRot(b);
|
|
|
- const nb=bmNorm(b,9,12);let bd=-1,bi=0;
|
|
|
- for(let dg=0;dg<10;dg++){const v=bmIou(nb,T[dg]);if(v>bd){bd=v;bi=dg;}}
|
|
|
- if(bd<0.45){ok=false;break;}sc+=bd;s+=bi;}
|
|
|
- if(ok&&(!best||sc>best.sc))best={s,sc};}
|
|
|
- if(!best)return 0;const n=+best.s;return best.s.length>=3&&n>=100&&n<=99999?n:0;}
|
|
|
+ const nb=bmNorm(b,9,12);let v1=-1,d1=0,v2=-1,d2=-1;
|
|
|
+ for(let dg=0;dg<10;dg++){const v=bmIou(nb,T[dg]);if(v>v1){v2=v1;d2=d1;v1=v;d1=dg;}else if(v>v2){v2=v;d2=dg;}}
|
|
|
+ if(v1<0.45){ok=false;break;}sc+=v1;s+=d1;
|
|
|
+ if(d2>=0&&d2!==d1&&v1-v2<=0.03)amb.push([s.length-1,d2]);} /* 数字含糊(前两模板得分差≤0.03)→ 记录备选位 */
|
|
|
+ if(!ok)continue;
|
|
|
+ /* 含糊位枚举变体(最多3位),返回候选数组:首选为最高模板得分,其余交由 600mm 锚点交叉校验复核 */
|
|
|
+ let vars=[{s,sc}];for(const[p,dg]of amb.slice(0,3)){const ex=[];for(const it of vars)ex.push({s:it.s.slice(0,p)+dg+it.s.slice(p+1),sc:it.sc});vars=vars.concat(ex);}
|
|
|
+ for(const it of vars){const n=+it.s;if(it.s.length>=3&&n>=100&&n<=99999)res.push(it);}}
|
|
|
+ if(!res.length)return [];
|
|
|
+ res.sort((a,b)=>b.sc-a.sc);const out=[];for(const it of res){if(out.indexOf(+it.s)<0)out.push(+it.s);}
|
|
|
+ return out;}
|
|
|
/* ---- 图纸红字柜体名称识别(如"2-电视柜"):柜种词汇表 + 汉字模板匹配 ---- */
|
|
|
const CAB_NAMES=["餐边柜","电视柜","阳台柜","衣柜","衣帽间","书柜","书桌","储物柜","玄关柜","卫浴柜","榻榻米","鞋柜","酒柜","橱柜","开放柜","吊柜","地柜"];
|
|
|
let CHR_T=null;
|
|
|
@@ -977,6 +1018,7 @@ function wallThickness(wm,w,h){const runs=[];
|
|
|
if(!runs.length)return 6;runs.sort((a,b)=>a-b);return runs[runs.length>>1];}
|
|
|
async function runAI(){
|
|
|
$("q-ai-log").innerHTML="";overlays=[];S.redbox=false;S.scale=null;S.scale600=null;S.scaleSrc="";S.objs=[];S.ft="plan";S.redRects=[];S.redLabels=[];S.kitBox=null;S.redArrow=null;S.kitW=null;S.kitH=null;S.kitLen=null;S.kitPrice=null;
|
|
|
+ if(img)draw(); /* 重置后立即重绘干净画布:避免新图纸复用上一张图的叠加红框,出现“错位页面” */
|
|
|
$("q-ai-results").style.display="none";
|
|
|
$("q-red-anno-row").style.display="none";
|
|
|
$("q-step2-next-btn").disabled=true;
|
|
|
@@ -1031,7 +1073,6 @@ async function runAI(){
|
|
|
for(let x=0;x<AW;x++){let run=0,st=0;for(let y2=0;y2<=AH;y2++){const v=y2<AH?mk[y2*AW+x]:0;if(v){if(!run)st=y2;run++;}else{if(run>=12)for(let y3=st;y3<y2;y3++)s[y3*AW+x]=1;run=0;}}}
|
|
|
return s;};
|
|
|
if(S.redbox){
|
|
|
- const solid=runMask(redm);
|
|
|
/* 闭合红圈/红框 → 内部"孔洞"即柜体边界(兼容粗笔圈注与细线矩形) */
|
|
|
const ext=new Uint8Array(AW*AH),stk=[];
|
|
|
for(let x=0;x<AW;x++){if(!redm[x])stk.push(x);if(!redm[(AH-1)*AW+x])stk.push((AH-1)*AW+x);}
|
|
|
@@ -1046,44 +1087,35 @@ async function runAI(){
|
|
|
S.redRects.push({x:Math.max(0,h.x-2),y:Math.max(0,h.y-2),w:Math.min(AW-h.x-2,h.w+4),h:Math.min(AH-h.y-2,h.h+4)});}
|
|
|
S.redRects.sort((a,b)=>b.w*b.h-a.w*a.h);
|
|
|
logAI(ph("· 红框矩形提取:"+S.redRects.length+" 个柜体边界框(直接取图纸红框为柜体边界,不二次收紧;已过滤虚线箭头/标注文字)"),"w");await S2(200);
|
|
|
- /* 红箭头标注检测(共线实线段合并为双头箭头) */
|
|
|
+ /* 红色尺寸标注检测:先剥离红框笔画(避免贴着红框的尺寸箭头因连通域合并被过滤),共线实线段合并为双头箭头,单长条亦视为箭头 */
|
|
|
S.redArrow=null;
|
|
|
- const arrows=cc(solid).filter(c=>Math.max(c.w,c.h)>=18&&Math.min(c.w,c.h)<=6);
|
|
|
+ const redm2=redm.slice();
|
|
|
+ for(const rc of S.redRects)for(let yy=Math.max(0,rc.y-3);yy<=Math.min(AH-1,rc.y+rc.h+3);yy++)for(let xx=Math.max(0,rc.x-3);xx<=Math.min(AW-1,rc.x+rc.w+3);xx++)redm2[yy*AW+xx]=0;
|
|
|
+ const arrows=cc(runMask(redm2)).filter(c=>Math.max(c.w,c.h)>=18&&Math.min(c.w,c.h)<=6);
|
|
|
+ const abb=(a1,a2)=>({x:Math.min(a1.x,a2.x),y:Math.min(a1.y,a2.y),w:Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x),h:Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y)});
|
|
|
+ for(const c of arrows){const len=Math.max(c.w,c.h)/k;if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,x:c.x,y:c.y,w:c.w,h:c.h};}
|
|
|
for(let i1=0;i1<arrows.length;i1++)for(let j=i1+1;j<arrows.length;j++){const a1=arrows[i1],a2=arrows[j];
|
|
|
if(a1.h>a1.w&&a2.h>a2.w&&Math.abs(a1.x-a2.x)<=4){const gap=Math.max(a1.y,a2.y)-Math.min(a1.y+a1.h,a2.y+a2.h);
|
|
|
if(gap>=0&&gap<=40){const len=(Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y))/k;
|
|
|
- if(!S.redArrow||len>S.redArrow.len)S.redArrow={len};}}
|
|
|
+ if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,...abb(a1,a2)};}}
|
|
|
else if(a1.w>a1.h&&a2.w>a2.h&&Math.abs(a1.y-a2.y)<=4){const gap=Math.max(a1.x,a2.x)-Math.min(a1.x+a1.w,a2.x+a2.w);
|
|
|
if(gap>=0&&gap<=40){const len=(Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x))/k;
|
|
|
- if(!S.redArrow||len>S.redArrow.len)S.redArrow={len};}}}
|
|
|
- if(S.redArrow)logAI("· 红箭头标注检测:长 "+Math.round(S.redArrow.len)+"px → 请在结果面板确认标注值,尺寸单位由 600mm 台面深度锚点交叉推断(场景B ±2-5%)","w");
|
|
|
- }
|
|
|
- /* 蓝色尺寸标注箭头(部分图纸标注为蓝色双头箭头,如 3150):红箭头优先;单长条亦视为箭头 */
|
|
|
- if(!S.redArrow){
|
|
|
- const bm=new Uint8Array(AW*AH);for(let i=0;i<AW*AH;i++){const r=d[i*4],g=d[i*4+1],b2=d[i*4+2];if(r<150&&b2>140&&b2-r>60&&b2-g>25)bm[i]=1;}
|
|
|
- const bcomps=cc(runMask(bm)).filter(c=>Math.max(c.w,c.h)>=18&&Math.min(c.w,c.h)<=8);
|
|
|
- let bl=0,bcomp=null;
|
|
|
- for(const c of bcomps){const len=Math.max(c.w,c.h)/k;if(len>bl){bl=len;bcomp=c;}}
|
|
|
- for(let i1=0;i1<bcomps.length;i1++)for(let j=i1+1;j<bcomps.length;j++){const a1=bcomps[i1],a2=bcomps[j];
|
|
|
- if(a1.h>a1.w&&a2.h>a2.w&&Math.abs(a1.x-a2.x)<=4){const gap=Math.max(a1.y,a2.y)-Math.min(a1.y+a1.h,a2.y+a2.h);
|
|
|
- if(gap>=0&&gap<=40){const len=(Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y))/k;if(len>bl){bl=len;bcomp=a1;}}}
|
|
|
- else if(a1.w>a1.h&&a2.w>a2.h&&Math.abs(a1.y-a2.y)<=4){const gap=Math.max(a1.x,a2.x)-Math.min(a1.x+a1.w,a2.x+a2.w);
|
|
|
- if(gap>=0&&gap<=40){const len=(Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x))/k;if(len>bl){bl=len;bcomp=a1;}}}}
|
|
|
- if(bcomp){S.redArrow={len:bl,blue:true};$("q-red-anno").value=""; /* 蓝色标注值未知:清空默认值,避免误套 2400 */
|
|
|
- logAI("· 蓝色尺寸标注箭头检测:长 "+Math.round(bl)+"px(本图标注为蓝色双头箭头)→ 尝试 OCR 读取邻近数字","w");
|
|
|
- /* OCR 高分裁剪:降采样后数字过小易误读 → 从原图按 2 倍 AW 比例裁出箭头邻域再识别 */
|
|
|
- const OF=2,OM=30;
|
|
|
- const ox0=Math.max(0,bcomp.x-OM),oy0=Math.max(0,bcomp.y-OM);
|
|
|
- const ow=Math.min(AW-ox0,bcomp.w+2*OM),oh=Math.min(AH-oy0,bcomp.h+2*OM);
|
|
|
+ if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,...abb(a1,a2)};}}}
|
|
|
+ if(S.redArrow){logAI("· 红色尺寸标注检测:箭头长 "+Math.round(S.redArrow.len)+"px → 尝试 OCR 读取邻近红色数字","w");
|
|
|
+ /* OCR 高分裁剪:降采样后数字过小易误读 → 从原图按 2 倍 AW 比例裁出箭头邻域,用红色掩码识别数字 */
|
|
|
+ const OF=2,OM=60,ar=S.redArrow; /* 邻域扩至60:兼容数字水平写在箭头较远处的排布 */
|
|
|
+ const ox0=Math.max(0,ar.x-OM),oy0=Math.max(0,ar.y-OM);
|
|
|
+ const ow=Math.min(AW-ox0,ar.w+2*OM),oh=Math.min(AH-oy0,ar.h+2*OM);
|
|
|
const oc2=document.createElement("canvas");oc2.width=ow*OF;oc2.height=oh*OF;
|
|
|
const og2=oc2.getContext("2d");og2.imageSmoothingEnabled=true;
|
|
|
og2.drawImage(img,ox0/k,oy0/k,ow/k,oh/k,0,0,ow*OF,oh*OF);
|
|
|
const d2=og2.getImageData(0,0,ow*OF,oh*OF).data;
|
|
|
- const bm2=new Uint8Array(ow*OF*oh*OF);
|
|
|
- for(let i2=0;i2<bm2.length;i2++){const r2=d2[i2*4],g2=d2[i2*4+1],b3=d2[i2*4+2];if(r2<150&&b3>140&&b3-r2>60&&b3-g2>25)bm2[i2]=1;}
|
|
|
- const num=ocrNumber(bm2,ow*OF,{x:(bcomp.x-ox0)*OF,y:(bcomp.y-oy0)*OF,w:bcomp.w*OF,h:bcomp.h*OF},mkCC(ow*OF),OF);
|
|
|
- if(num){$("q-red-anno").value=num;logAI("· 蓝色标注 OCR 读出:<b>"+num+"</b>(已自动填入,如有出入可在输入框修改)→ 据此建立全图比例","b");}
|
|
|
- else logAI("· OCR 未能置信读出蓝色标注值 → 请在下方输入框手动填写标注数值(默认按 mm)","e");}
|
|
|
+ const rm2=new Uint8Array(ow*OF*oh*OF);
|
|
|
+ for(let i2=0;i2<rm2.length;i2++){const r2=d2[i2*4],g2=d2[i2*4+1],b3=d2[i2*4+2];if(r2>140&&r2-g2>50&&r2-b3>50)rm2[i2]=1;}
|
|
|
+ const cands=ocrNumber(rm2,ow*OF,{x:(ar.x-ox0)*OF,y:(ar.y-oy0)*OF,w:ar.w*OF,h:ar.h*OF},mkCC(ow*OF),OF);
|
|
|
+ if(cands.length){$("q-red-anno").value=cands[0];S.redAnnoCands=cands;
|
|
|
+ logAI("· 红色尺寸标注 OCR 读出:<b>"+cands[0]+"</b>"+(cands.length>1?"(存在含糊候选 "+cands.join("/")+",将以 600mm 锚点交叉复核)":"")+"(已自动填入,如有出入可在输入框修改)→ 据此建立全图比例","b");}
|
|
|
+ else logAI("· OCR 未能置信读出红色标注值 → 请在下方输入框确认标注数值(默认 2400,单位 mm)","e");}
|
|
|
}
|
|
|
/* 图纸已用红字标注柜体名称(如"2-电视柜")→ 识别之,对应红框柜体直接采用图纸名称,系统不再二次命名 */
|
|
|
if(S.redbox){const LM=dilateMask(redm,AW,AH,3);
|
|
|
@@ -1160,6 +1192,7 @@ async function runAI(){
|
|
|
if(kitchen){
|
|
|
/* 600mm 隐式参照物先建立图像比例;场景B 有红箭头标注时,标注值与锚点交叉校验推断尺寸单位后再校准 */
|
|
|
const sc=0.6/(kitchen.dd/k);S.scale600=sc;S.scale=sc;S.scaleSrc="AI定位厨房台面深度=600mm锚点(全图通用)";
|
|
|
+ annoPick(sc);
|
|
|
if(S.redArrow){const inf=annoInfer(sc);if(inf){S.scale=inf.scale;S.scaleSrc="红箭头标注校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";
|
|
|
logAI("· 尺寸单位推断(场景B):标注值 "+(+$("q-red-anno").value)+" → 推断单位 "+inf.unit+"("+fmt2(inf.vm)+"m) vs 600mm锚点反推箭头长 "+fmt2(inf.estM)+"m → 1px="+(inf.scale*1000).toFixed(2)+"mm","w");}}
|
|
|
overlays.push({t:"kitchen",x:kitchen.x/k,y:kitchen.y/k,w:kitchen.w/k,h:kitchen.h/k,label:"厨房"});
|
|
|
@@ -1348,6 +1381,7 @@ async function runAI(){
|
|
|
else dNat=Math.min(b0.w,b0.h)/k;
|
|
|
/* 600mm 隐式参照物应用于场景A/B:先建锚点比例;场景B 标注值与锚点交叉校验推断尺寸单位(mm/cm/m)后校准 */
|
|
|
S.scale600=0.6/dNat;
|
|
|
+ annoPick(S.scale600);
|
|
|
const inf=S.redArrow?annoInfer(S.scale600):null;
|
|
|
if(inf){S.scale=inf.scale;S.scaleSrc="红箭头标注校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";
|
|
|
logAI("· 尺寸单位推断(场景B):标注值 "+(+$("q-red-anno").value)+" → 推断单位 "+inf.unit+"("+fmt2(inf.vm)+"m) vs 600mm锚点反推箭头长 "+fmt2(inf.estM)+"m,偏差 "+Math.round(Math.abs(inf.vm-inf.estM)/inf.estM*100)+"% → 1px="+(inf.scale*1000).toFixed(2)+"mm","w");}
|
|
|
@@ -1426,10 +1460,10 @@ async function runAI(){
|
|
|
$("q-step2-next-btn").disabled=false;
|
|
|
}
|
|
|
function fh(){return parseFloat($("q-floor-height").value)||2.5;}
|
|
|
-function qFhChanged(src){const v=(src?src.value:$("q-floor-height").value)||"2.5";
|
|
|
+function qFhChanged(src){ensureS();const v=(src?src.value:$("q-floor-height").value)||"2.5";
|
|
|
$("q-floor-height").value=v;const i2=$("q-floor-height2");if(i2)i2.value=v;
|
|
|
if(S.objs.length||S.kitBox){renderAIResults();recalc();}}
|
|
|
-function renderAIResults(){
|
|
|
+function renderAIResults(){ensureS();
|
|
|
const sc=dimScale();
|
|
|
$("q-red-anno-row").style.display=S.redArrow?"block":"none";
|
|
|
renderAnnoInfer();
|
|
|
@@ -1454,7 +1488,7 @@ function renderAIResults(){
|
|
|
|
|
|
/* ========== 对象表(步骤3) ========== */
|
|
|
let KIT_TR=null,TOTAL_TR=null;
|
|
|
-function rebuildObjs(){
|
|
|
+function rebuildObjs(){ensureS();
|
|
|
const tb=$("objBody");tb.innerHTML="";KIT_TR=null;TOTAL_TR=null;
|
|
|
/* 厨房行:与柜体合并同表;宽=最长边、高=最短边、总长度(L/U型自动算,可改)、单价(元/m,默认材质档最高,可改) */
|
|
|
if(S.kitW>0&&S.kitH>0){const tr=document.createElement("tr");KIT_TR=tr;
|
|
|
@@ -1569,7 +1603,7 @@ function calcExtras(p,isKit){
|
|
|
|
|
|
/* ========== 计算引擎 ========== */
|
|
|
let LASTQ={baseSum:0,kSum:0,wSum:0,varArea:0,cabArea:0,cabAmt:0,baseRows:[],valueRows:[]};
|
|
|
-function recalc(){
|
|
|
+function recalc(){ensureS();
|
|
|
const pr=precision(),sc=dimScale();
|
|
|
$("precBadge").className="badge "+pr.cls;$("precBadge").textContent=pr.txt;
|
|
|
$("q-quote-prec").className="badge "+pr.cls;$("q-quote-prec").textContent=pr.txt;
|
|
|
@@ -1652,7 +1686,7 @@ let QUOTES=[];
|
|
|
try{QUOTES=JSON.parse(localStorage.getItem("aiq_new_quotes")||"[]");}catch(e){QUOTES=[];}
|
|
|
function saveQuotes(){try{localStorage.setItem("aiq_new_quotes",JSON.stringify(QUOTES));}catch(e){}}
|
|
|
function snapshot(){return JSON.stringify({ft:S.ft,redbox:S.redbox,scale:S.scale,scaleSrc:S.scaleSrc,mat:S.mat,rate:S.rate,kitW:S.kitW,kitH:S.kitH,kitLen:S.kitLen,kitPrice:S.kitPrice,kitEx:S.kitExtras,ruleX:S.ruleX,brand:S.brand,door:S.door,objs:S.objs.map(o=>({name:o.name,kind:o.kind,src:o.src,w:o.w,h:o.h,lenM:o.lenM!=null?o.lenM:null,hgtM:o.hgtM!=null?o.hgtM:null,price:o.price!=null?o.price:null,ex:o.extras||{}}))});}
|
|
|
-function qSaveQuote(silent){
|
|
|
+function qSaveQuote(silent){ensureS();
|
|
|
if(!S.objs.length&&!LASTQ.baseSum&&!LASTQ.wSum){if(!silent)toast("暂无计价内容,请先上传图纸完成AI识别","toast-error");return;}
|
|
|
const m=MATS[S.mat],fixed=LASTQ.wSum;
|
|
|
const q={id:Date.now(),name:("报价单 "+(QUOTES.length+1)),date:new Date().toLocaleString("zh-CN"),mat:m.n,rate:S.rate,
|
|
|
@@ -1679,6 +1713,7 @@ function renderQuoteList(){
|
|
|
function qOpenQuote(q){
|
|
|
try{const snap=JSON.parse(q.snap);
|
|
|
Object.assign(S,{ft:snap.ft,redbox:snap.redbox,scale:snap.scale,scaleSrc:snap.scaleSrc,mat:snap.mat,rate:snap.rate,kitW:snap.kitW!=null?snap.kitW:null,kitH:snap.kitH!=null?snap.kitH:null,kitLen:snap.kitLen!=null?snap.kitLen:null,kitPrice:snap.kitPrice!=null?snap.kitPrice:null,objs:snap.objs});
|
|
|
+ ensureS(); /* 旧版存档可能缺 objs 等字段 → 回退空数组,避免恢复后 undefined 崩溃 */
|
|
|
S.kitExtras=snap.kitEx||mkExtras("",true);
|
|
|
S.ruleX=Object.assign(Object.fromEntries(EXTRA.map(e=>[e.id,!e.off])),snap.ruleX||{});
|
|
|
S.brand=snap.brand!=null?snap.brand:0;
|
|
|
@@ -1835,9 +1870,10 @@ function renderRules(){
|
|
|
}
|
|
|
|
|
|
/* ========== 事件与初始化 ========== */
|
|
|
-$("q-red-anno").oninput=()=>{if(S.redArrow){const old=dimScale();const inf=annoInfer(S.scale600||0);if(!inf)return;S.scale=inf.scale;S.scaleSrc=(S.redArrow.blue?"蓝":"红")+"标注箭头校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";const nw=dimScale();if(S.kitBox&&old&&nw)S.kitBox={wM:S.kitBox.wM/old*nw,hM:S.kitBox.hM/old*nw};renderAnnoInfer();recalc();renderAIResults();}};
|
|
|
+$("q-red-anno").oninput=()=>{if(S.redArrow){const old=dimScale();const inf=annoInfer(S.scale600||0);if(!inf)return;S.scale=inf.scale;S.scaleSrc="红标注箭头校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";const nw=dimScale();if(S.kitBox&&old&&nw)S.kitBox={wM:S.kitBox.wM/old*nw,hM:S.kitBox.hM/old*nw};renderAnnoInfer();recalc();renderAIResults();}};
|
|
|
$("btnFb").onclick=()=>{const px=+$("fbPx").value||0,mm=+$("fbMm").value||0;if(px>0&&mm>0){S.scale=(mm/1000)/px;S.scaleSrc="用户补充已知尺寸";recalc();toast("比例已建立");}else toast("请填写像素数与毫米数","toast-error");};
|
|
|
|
|
|
+qBindDrop($("q-upload-zone-unified"));qBindDrop($("q-canvas-drop")); /* 点击 + 拖拽双通道上传/更换图纸 */
|
|
|
renderTiers();renderRules();renderQuoteList();rebuildObjs();recalc();
|
|
|
</script>
|
|
|
</body>
|