Selaa lähdekoodia

Merge branch 'main' of https://git.fmode.cn/lami/floorplan-quotation

Hermes Agent 2 viikkoa sitten
vanhempi
commit
153e0c1565
1 muutettua tiedostoa jossa 236 lisäystä ja 74 poistoa
  1. 236 74
      AI快速报价_Demo2.optimizing.html

+ 236 - 74
AI快速报价_Demo2.optimizing.html

@@ -921,8 +921,17 @@ function bmNorm(b,W,H){const c=document.createElement("canvas");c.width=b.w;c.he
 function bmIou(a,b){let u=0,n=0;for(let i=0;i<a.length;i++){if(a[i]||b[i])u++;if(a[i]&&b[i])n++;}return u?n/u:0;}
 let DIG_T=null;
 function digTemplates(){if(DIG_T)return DIG_T;DIG_T=[];const c=document.createElement("canvas");c.width=36;c.height=48;const g=c.getContext("2d");
-  for(let dg=0;dg<10;dg++){g.clearRect(0,0,36,48);g.fillStyle="#000";g.font="bold 40px Arial";g.textBaseline="top";g.fillText(""+dg,2,0);
-    DIG_T.push(bmNorm(bmCrop(g.getImageData(0,0,36,48).data,36,48),9,12));}
+  /* 每个数字多字体变体(匹配时取变体间最佳 IoU);"7" 另加手绘风格化变体(顶横+斜杠 / 带中横杠 / 带底钩),适配图纸手写风格 */
+  const fonts=["bold 40px Arial","bold 40px Verdana","bold 40px Tahoma","bold 40px 'Segoe UI'","bold 40px 'Microsoft YaHei'"];
+  for(let dg=0;dg<10;dg++){const vs=[];
+    for(const f of fonts){g.clearRect(0,0,36,48);g.fillStyle="#000";g.font=f;g.textBaseline="top";g.fillText(""+dg,2,0);
+      const cr=bmCrop(g.getImageData(0,0,36,48).data,36,48);if(cr)vs.push(bmNorm(cr,9,12));}
+    if(dg===7){const stroke=fn=>{g.clearRect(0,0,36,48);g.strokeStyle="#000";g.lineWidth=6;g.lineCap="butt";g.beginPath();fn(g);g.stroke();
+        const cr=bmCrop(g.getImageData(0,0,36,48).data,36,48);if(cr)vs.push(bmNorm(cr,9,12));};
+      stroke(q=>{q.moveTo(4,5);q.lineTo(31,5);q.moveTo(29,5);q.lineTo(13,45);});
+      stroke(q=>{q.moveTo(4,5);q.lineTo(31,5);q.moveTo(29,5);q.lineTo(13,45);q.moveTo(10,26);q.lineTo(27,26);});
+      stroke(q=>{q.moveTo(4,5);q.lineTo(31,5);q.moveTo(29,5);q.lineTo(14,40);q.lineTo(21,45);});}
+    DIG_T.push(vs);}
   return DIG_T;}
 function mkCC(W){return mask=>{const vis=new Uint8Array(mask.length),out=[];
   for(let s=0;s<mask.length;s++){if(!mask[s]||vis[s])continue;
@@ -934,12 +943,29 @@ function mkCC(W){return mask=>{const vis=new Uint8Array(mask.length),out=[];
   return out;};}
 /* 读取箭头旁的数字标注(如 3150):vert 由箭头方向决定排布;返回数值,失败返回 0。F=掩码相对 AW 的放大倍数(高分裁剪传 2) */
 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 comps0=cc(mask).filter(c=>c.area>=5*F*F&&Math.max(c.w,c.h)>=3*F&&Math.max(c.w,c.h)<=22*F);
+  /* 剔除极细长线(尺寸界线/引出线,非数字笔画);粘连数字按列投影谷点拆分(如"17"连成方块) */
+  const comps=[];
+  for(const c of comps0){
+    if(c.area<8*F*F)continue;   /* 孤立微小碎块(手写断笔/字脚)不作数字,避免 1700 被插读成 15700 */
+    if((c.w<=2*F&&c.h>5*c.w)||(c.h<=2*F&&c.w>5*c.h))continue;
+    if(c.w>0.85*c.h&&c.w>=8*F){
+      const colS=[];
+      for(let x=0;x<=c.w;x++){let s2=0;for(let y=0;y<=c.h;y++)if(mask[(c.y+y)*W+(c.x+x)])s2++;colS.push(s2);}
+      const mxv=Math.max.apply(null,colS);let bx=-1,bv=1e9;
+      /* 优先在左半区(0.25~0.6 宽)找局部最小谷点(窄字"1"后接宽字时分界偏左),否则退回全域最深谷 */
+      const lo=Math.max(2,Math.floor(c.w*0.25)),hi=Math.min(c.w-2,Math.ceil(c.w*0.6));
+      for(let x=lo;x<=hi;x++){if(colS[x]<=0.5*mxv&&(x===lo||colS[x]<=colS[x-1])&&colS[x]<=colS[x+1]){bx=x;break;}}
+      if(bx<0){for(let x=Math.max(2,Math.floor(c.w*0.25));x<=Math.min(c.w-2,Math.floor(c.w*0.75));x++)if(colS[x]<bv){bv=colS[x];bx=x;}if(bv>0.35*mxv)bx=-1;}
+      if(bx>0){comps.push({x:c.x,y:c.y,w:bx,h:c.h,area:c.area});comps.push({x:c.x+bx,y:c.y,w:c.w-bx,h:c.h,area:c.area});continue;}
+    }
+    comps.push(c);
+  }
   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;
     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:兼容数字水平写在箭头较远处 */
+    return Math.abs(cx-ax)<=(vert?60*F:ar.w/2+60*F)&&Math.abs(cy-ay)<=(vert?ar.h/2+25*F:ar.h/2+20*F);});   /* 横箭头:横向容差沿箭头全长+60F,纵向仅取紧邻带(20F),排除远处无关红字碎片 */
   if(near.length<3||near.length>6)return 0;
   const T=digTemplates();
   /* [排序轴, 排序方向, 90°旋转次数] 覆盖两类排布:数字沿箭头旋转90°(竖排旋1/3、横排旋0/2)与数字水平写在箭头旁(自左而右旋0 / 自右而左旋2) */
@@ -952,8 +978,16 @@ function ocrNumber(mask,W,ar,cc,F){F=F||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 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;
+      for(let dg=0;dg<10;dg++){let v=0;for(const tv of T[dg]){const iv=bmIou(nb,tv);if(iv>v)v=iv;}
+        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;}
+      /* 风格化"7"结构校正:图纸手写 7 底部左扫、右下留空;而"4"的竖笔必贯穿横杠延伸至右下 → 低置信"4"按底部墨量结构改判 7 */
+      if(d1===4&&v1<0.62){let br=0,bl=0;for(let yy=9;yy<12;yy++)for(let xx=6;xx<9;xx++)br+=nb[yy*9+xx];
+        for(let yy2=9;yy2<12;yy2++)for(let xx2=0;xx2<3;xx2++)bl+=nb[yy2*9+xx2];
+        if(br<=3&&bl>=6){let v7=0;for(const tv of T[7]){const iv=bmIou(nb,tv);if(iv>v7)v7=iv;}sc+=v7;d1=7;v1=v7;}
+        else sc+=v1;}
+      else 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 锚点交叉校验复核 */
@@ -963,16 +997,25 @@ function ocrNumber(mask,W,ar,cc,F){F=F||1;
   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;
-function chrTemplates(){if(CHR_T)return CHR_T;
-  const vocab=CAB_NAMES.join("")+"主卧室次客餐厅玄关门书房儿童老人浴厨卫间";
-  const seen={};CHR_T={};const c=document.createElement("canvas");c.width=48;c.height=48;const g=c.getContext("2d");
-  for(const ch of vocab){if(seen[ch])continue;seen[ch]=1;
-    g.clearRect(0,0,48,48);g.fillStyle="#000";g.font="bold 40px 'Microsoft YaHei',sans-serif";g.textBaseline="top";g.fillText(ch,4,2);
-    CHR_T[ch]=bmNorm(bmCrop(g.getImageData(0,0,48,48).data,48,48),20,20);}
-  return CHR_T;}
-/* 读取红框旁的红字柜体名称标注:高分裁剪→红掩码→列投影分字→数字/短横前缀跳过→汉字模板匹配;返回名称串,失败返回 null */
+const CAB_NAMES=["餐边柜","电视柜","阳台柜","衣柜","衣帽间","书柜","书桌","储物柜","玄关柜","卫浴柜","榻榻米","鞋柜","酒柜","橱柜","开放柜","吊柜","地柜","开放式厨房"];
+/* 图纸红字标注候选柜名池:单独柜种 + 空间前缀组合(支持"5-主卧衣柜"式标注) */
+const CAB_POOL=CAB_NAMES.concat(["主卧衣柜","次卧衣柜","儿童衣柜","老人衣柜","主卧衣帽间","次卧衣帽间","儿童衣帽间","书房书柜","书房书桌"]);
+let WORD_T=null;
+function wordTemplates(){if(WORD_T)return WORD_T;
+  /* 整词灰度模板:整词连写渲染(4字体)裁墨界存连续 alpha;匹配时缩放到样本条带尺寸做归一化相关(NCC)——
+     粗笔迹二值化会把字填成实心块丢失字腔结构,连续灰度保留抗锯齿明暗,相关法可辨 */
+  const fonts=["bold 40px 'Microsoft YaHei',sans-serif","bold 40px KaiTi,'KaiTi_GB2312','楷体',serif","bold 40px SimSun,'宋体',serif","bold 40px 'Segoe UI',sans-serif"];
+  WORD_T={};const c=document.createElement("canvas");const g=c.getContext("2d");
+  for(const nm of CAB_POOL){const vs=[];
+    for(const f of fonts){const wpx=48*nm.length+16;c.width=wpx;c.height=56;g.clearRect(0,0,wpx,56);g.fillStyle="#000";g.font=f;g.textBaseline="top";g.fillText(nm,4,4);
+      const gd=g.getImageData(0,0,wpx,56).data;let x0=1e9,y0=1e9,x1=-1,y1=-1;
+      for(let y=0;y<56;y++)for(let x=0;x<wpx;x++)if(gd[(y*wpx+x)*4+3]>60){if(x<x0)x0=x;if(x>x1)x1=x;if(y<y0)y0=y;if(y>y1)y1=y;}
+      if(x1<0)continue;const W2=x1-x0+1,H2=y1-y0+1,al=new Float32Array(W2*H2);
+      for(let y=0;y<H2;y++)for(let x=0;x<W2;x++)al[y*W2+x]=gd[((y0+y)*wpx+x0+x)*4+3]/255;
+      vs.push({w:W2,h:H2,a:al});}
+    WORD_T[nm]=vs;}
+  return WORD_T;}
+/* 读取红框旁的红字柜体名称标注:高分裁剪→红掩码→分字→编号前缀→整词灰度NCC匹配;返回 {pre,nm,sc,tops},失败返回 null */
 function readRedLabel(b0,AW,AH,k){
   const OF=Math.max(2,Math.min(4,Math.round(1/k))),OM=6;   /* 尽量按原图分辨率裁剪,避免降采样糊化笔画 */
   const ox0=Math.max(0,b0.x-OM),oy0=Math.max(0,b0.y-OM);
@@ -982,36 +1025,102 @@ function readRedLabel(b0,AW,AH,k){
   og.drawImage(img,ox0/k,oy0/k,ow/k,oh/k,0,0,ow*OF,oh*OF);
   const dd=og.getImageData(0,0,ow*OF,oh*OF).data;
   const W=ow*OF,H=oh*OF,rm2=new Uint8Array(W*H);
-  for(let i=0;i<W*H;i++){const r=dd[i*4],g2=dd[i*4+1],b2=dd[i*4+2];if(r>140&&r-g2>50&&r-b2>50)rm2[i]=1;}
+  /* 手写红字笔画浅细 → 用比红框提取更灵敏的阈值取墨;redv 保留连续红度(抗锯齿字腔明暗),供整词灰度相关匹配 */
+  const redv=new Float32Array(W*H);
+  for(let i=0;i<W*H;i++){const r=dd[i*4],g2=dd[i*4+1],b2=dd[i*4+2];const rv=r-Math.max(g2,b2)-5;redv[i]=rv>0?(rv>200?200:rv):0;if(r>125&&r-g2>35&&r-b2>35)rm2[i]=1;}
+  /* 长直线清除:裁剪窗可能兜进红框边线/下划线(整行/整列占比>70% 的墨带)→ 整行整列抹掉,只留手写笔画 */
+  for(let y=0;y<H;y++){let c0=0;for(let x=0;x<W;x++)if(rm2[y*W+x])c0++;if(c0>0.7*W)for(let x=0;x<W;x++){rm2[y*W+x]=0;redv[y*W+x]=0;}}
+  for(let x=0;x<W;x++){let c0=0;for(let y=0;y<H;y++)if(rm2[y*W+x])c0++;if(c0>0.7*H)for(let y=0;y<H;y++){rm2[y*W+x]=0;redv[y*W+x]=0;}}
+  /* 墨密度判别:手写标注裁剪窗内墨量占比≤0.15;家具斜纹填充/红框交叉/大段备注占比显著更高 → 判非柜名标注 */
+  let ink0=0;for(let i=0;i<W*H;i++)if(rm2[i])ink0++;
+  if(ink0<20||ink0/(W*H)>0.15)return null;
   const proj=new Int32Array(W);
   for(let x=0;x<W;x++){let s=0;for(let y=0;y<H;y++)if(rm2[y*W+x])s++;proj[x]=s;}
+  /* 分字:字间浅连墨丝的列投影远低于笔画主峰 → 谷值<30%峰值且连续≥2列视为字隙 */
+  let maxP=0;for(let x=0;x<W;x++)if(proj[x]>maxP)maxP=proj[x];
+  const act=maxP>0?Math.max(2,Math.round(0.3*maxP)):1;
   const segs=[];let s0=-1,zc=0;
-  for(let x=0;x<W;x++){if(proj[x]>0){if(s0<0)s0=x;zc=0;}else if(s0>=0){zc++;if(zc>=2){segs.push({x0:s0,x1:x-zc});s0=-1;zc=0;}}}
+  for(let x=0;x<W;x++){if(proj[x]>=act){if(s0<0)s0=x;zc=0;}else if(s0>=0){zc++;if(zc>=2){segs.push({x0:s0,x1:x-zc});s0=-1;zc=0;}}}
   if(s0>=0)segs.push({x0:s0,x1:W-1});
-  let maxH=0;const parts=[];
+  let maxH=0;const raw0=[];
   for(const sg of segs){let y0=H,y1=-1,area=0;
     for(let y=0;y<H;y++)for(let x=sg.x0;x<=sg.x1;x++)if(rm2[y*W+x]){area++;if(y<y0)y0=y;if(y>y1)y1=y;}
-    if(area<4)continue;const p={x:sg.x0,y:y0,w:sg.x1-sg.x0+1,h:y1-y0+1};if(p.h>maxH)maxH=p.h;parts.push(p);}
-  const dbg={bx:b0.x,by:b0.y,bw:b0.w,bh:b0.h,parts:[]};
-  if(parts.length<2||parts.length>8){(window.__lblDbg=window.__lblDbg||[]).push({rej:"count",n:parts.length,...dbg});return null;}
-  const CT=chrTemplates(),DT=digTemplates();let name="";
+    if(area<4)continue;const p={x:sg.x0,y:y0,w:sg.x1-sg.x0+1,h:y1-y0+1};if(p.h>maxH)maxH=p.h;raw0.push(p);}
+  const raw=raw0.filter(p=>p.h>=0.5*maxH);   /* 碎墨过滤:高度不足主字一半的段为笔画残片 */
+  /* 强制拆分:段宽>1.2倍字高 = 多字粘连 → 按字高近似方形等宽切槽 */
+  const parts=[];
+  for(const p0 of raw){
+    if(p0.w>1.2*p0.h){const n=Math.max(2,Math.round(p0.w/p0.h)),swd=p0.w/n;
+      for(let ci=0;ci<n;ci++){const sx0=p0.x+Math.round(ci*swd),sx1=Math.min(W-1,p0.x+Math.round((ci+1)*swd)-1);
+        let yy0=H,yy1=-1,aa=0;
+        for(let y=0;y<H;y++)for(let x=sx0;x<=sx1;x++)if(rm2[y*W+x]){aa++;if(y<yy0)yy0=y;if(y>yy1)yy1=y;}
+        if(aa>=4)parts.push({x:sx0,y:yy0,w:sx1-sx0+1,h:yy1-yy0+1});}}
+    else parts.push(p0);}
+  if(parts.length<2||parts.length>12)return null;
+  if(maxH<8||maxH>30)return null;   /* 字高越界:大字是计算备注、巨字是家具图元,均非柜名标注 */
+  const DT=digTemplates();
+  const info=[];
   for(const p of parts){
     const bmp={w:p.w,h:p.h,a:new Uint8Array(p.w*p.h)};
     for(let y=0;y<p.h;y++)for(let x=0;x<p.w;x++)bmp.a[y*p.w+x]=rm2[(p.y+y)*W+(p.x+x)];
-    if(p.w<=0.45*maxH){dbg.parts.push({w:p.w,h:p.h,cls:"narrow"});continue;}   /* 窄部件(短横/数字前缀/断裂笔画):柜名汉字均为全宽字,一律跳过 */
-    const dn=bmNorm(bmp,9,12);let bD=-1;
-    for(let dg=0;dg<10;dg++){const v=bmIou(dn,DT[dg]);if(v>bD)bD=v;}
-    const cn2=bmNorm(bmp,20,20);let bC=-1,bCh="";
-    for(const ch in CT){const v=bmIou(cn2,CT[ch]);if(v>bC){bC=v;bCh=ch;}}
-    dbg.parts.push({w:p.w,h:p.h,bD:+bD.toFixed(2),bC:+bC.toFixed(2),ch:bCh});
-    if(bD>=0.55&&bD>=bC)continue;   /* 编号数字前缀,跳过 */
-    if(bC>=0.5){name+=bCh;continue;}
-    (window.__lblDbg=window.__lblDbg||[]).push({...dbg,rej:"char"});
-    return null;}   /* 存在无法识别的字符 → 该标注整体不采信 */
-  (window.__lblDbg=window.__lblDbg||[]).push({...dbg,name});
-  if(!name)return null;
-  let hit="";for(const nm0 of CAB_NAMES)if(name.endsWith(nm0)&&nm0.length>hit.length)hit=nm0;
-  return hit?name:null;}
+    const dn=bmNorm(bmp,9,12);let bD=-1,bDd=0;
+    for(let dg=0;dg<10;dg++)for(const tv of DT[dg]){const v=bmIou(dn,tv);if(v>bD){bD=v;bDd=dg;}}
+    if(p.w<=0.45*maxH&&bD<0.4)continue;   /* 窄部件:仅编号数字等细字保留,其余跳过 */
+    info.push({p,digV:bD,digC:bDd});}
+  if(!info.length)return null;
+  /* 首部编号数字("2-电视柜"):仅首个部件可为编号(标注至多一个数字前缀,窄身+高数字分),防把窄汉字误剥 */
+  let st=0,preOut="";
+  if(info.length&&info[0].p.w<=0.4*maxH&&info[0].digV>=0.5){st=1;preOut+=info[0].digC;}
+  /* 整词灰度 NCC:连续红度条带 × 整词字体模板缩放到同尺寸做归一化互相关——
+     不做逐字分割(粗笔迹粘连分字不可靠),连续灰度保留字腔结构,相关性可辨词形 */
+  const gate=dilateMask(rm2,W,H,2);   /* 墨门控:只在笔画邻域内取红度,裁掉纸纹背景对相关度的稀释 */
+  const tryMatch=ws=>{if(!ws.length)return null;let ix0=1e9,iy0=1e9,ix1=-1,iy1=-1;
+    for(const it of ws){const p=it.p;if(p.x<ix0)ix0=p.x;if(p.y<iy0)iy0=p.y;if(p.x+p.w-1>ix1)ix1=p.x+p.w-1;if(p.y+p.h-1>iy1)iy1=p.y+p.h-1;}
+    const ibw=ix1-ix0+1,ibh=iy1-iy0+1;if(ibh<12)return null;   /* 字高过小=碎墨,非柜名 */
+    const G=new Float32Array(ibw*ibh);let gm=0;
+    for(let y=0;y<ibh;y++)for(let x=0;x<ibw;x++)G[y*ibw+x]=gate[(iy0+y)*W+(ix0+x)]?redv[(iy0+y)*W+(ix0+x)]:0;
+    for(let i=0;i<G.length;i++)gm+=G[i];gm/=G.length;
+    let gv=0;for(let i=0;i<G.length;i++){const q=G[i]-gm;gv+=q*q;}
+    if(gv<1)return null;
+    const scored=[],WT=wordTemplates();
+    const tCan=document.createElement("canvas"),tG=tCan.getContext("2d");
+    const tCan2=document.createElement("canvas"),tG2=tCan2.getContext("2d");
+    for(const nm0 of CAB_POOL){const n=nm0.length;
+      /* 字数约束:部件数≈字数(容忍粘连并字±);字宽多档压缩比+整条带兜底,滑窗局部 NCC 容忍编号/杂墨占位 */
+      if(n<ws.length-1||n>ws.length+2)continue;
+      const tws=[];for(const wf of [1,0.85,0.65]){const t=Math.round(ibh*n*wf);if(t>=8&&t<=ibw&&tws.indexOf(t)<0)tws.push(t);}
+      if(tws.indexOf(ibw)<0)tws.push(ibw);
+      let sc=-1;
+      for(const tv of WT[nm0]){
+        tCan.width=tv.w;tCan.height=tv.h;
+        const id=tG.createImageData(tv.w,tv.h);
+        for(let i=0;i<tv.a.length;i++)id.data[i*4+3]=Math.round(tv.a[i]*255);
+        tG.putImageData(id,0,0);
+        for(const tW of tws){
+          tCan2.width=tW;tCan2.height=ibh;tG2.imageSmoothingEnabled=true;
+          tG2.clearRect(0,0,tW,ibh);tG2.drawImage(tCan,0,0,tW,ibh);
+          const td=tG2.getImageData(0,0,tW,ibh).data;
+          const T=new Float32Array(tW*ibh);let tm=0;
+          for(let i=0;i<T.length;i++){T[i]=td[i*4+3]/255;tm+=T[i];}tm/=T.length;
+          let tvv=0;for(let i=0;i<T.length;i++){const q=T[i]-tm;tvv+=q*q;}
+          if(tvv<1e-6)continue;
+          const step=Math.max(1,Math.floor(tW/10));
+          for(let x0=0;x0+tW<=ibw;x0+=step){
+            let wm=0;for(let y2=0;y2<ibh;y2++)for(let x2=0;x2<tW;x2++)wm+=G[y2*ibw+x0+x2];wm/=T.length;
+            let num=0,wvv=0;
+            for(let y2=0;y2<ibh;y2++)for(let x2=0;x2<tW;x2++){const a1=G[y2*ibw+x0+x2]-wm,b1=T[y2*tW+x2]-tm;num+=a1*b1;wvv+=a1*a1;}
+            if(wvv<1)continue;   /* 空窗口(无墨)跳过,不影响其余窗口 */
+            const v=num/Math.sqrt(wvv*tvv);if(v>sc)sc=v;}}}
+      scored.push({nm:nm0,sc});}
+    scored.sort((a,b)=>b.sc-a.sc);
+    if(!scored.length)return null;
+    return {nm:scored[0].nm,sc:scored[0].sc,top:scored.slice(0,5)};};
+  /* 编号前缀两种剥法(严格/宽松)都试,取相关得分最高者 */
+  const cands=[];const m1=tryMatch(info.slice(st));if(m1)cands.push({pre:preOut,m:m1});
+  if(st===0&&info.length>1&&info[0].p.w<=0.55*maxH&&info[0].digV>=0.32){const m2=tryMatch(info.slice(1));if(m2)cands.push({pre:info[0].digV>=0.6?""+info[0].digC:"",m:m2});}
+  cands.sort((a,b)=>b.m.sc-a.m.sc);const pick=cands.length?cands[0]:null;
+  if(!pick||pick.m.sc<0.35)return null;   /* 词形置信不足:宁缺毋错,交由调用方按图纸编号/红框序号中性命名 */
+  return {pre:pick.pre,nm:pick.m.nm,sc:pick.m.sc,tops:pick.m.top};}
 function wallThickness(wm,w,h){const runs=[];
   for(let y=0;y<h;y++){let r=0;for(let x=0;x<=w;x++){const v=x<w?wm[y*w+x]:0;if(v)r++;else{if(r>=3&&r<=40)runs.push(r);r=0;}}}
   for(let x=0;x<w;x++){let r=0;for(let y=0;y<=h;y++){const v=y<h?wm[y*w+x]:0;if(v)r++;else{if(r>=3&&r<=40)runs.push(r);r=0;}}}
@@ -1034,9 +1143,18 @@ async function runAI(){
   const ink=new Uint8Array(AW*AH),redm=new Uint8Array(AW*AH);
   let rx0=1e9,ry0=1e9,rx1=-1,ry1=-1,rc=0,col=0,fp=0;
   for(let i=0;i<AW*AH;i++){const r=d[i*4],g=d[i*4+1],b=d[i*4+2];fp=(fp+r*3+g*5+b*7)%1000000;
-    if(r>140&&r-g>50&&r-b>50){rc++;redm[i]=1;if(i%AW<rx0)rx0=i%AW;if(i%AW>rx1)rx1=i%AW;const y=(i/AW)|0;if(y<ry0)ry0=y;if(y>ry1)ry1=y;}
-    else if(.299*r+.587*g+.114*b<140)ink[i]=1;
+    if(.299*r+.587*g+.114*b<140)ink[i]=1;
     else if(Math.max(r,g,b)-Math.min(r,g,b)>15)col++;}
+  /* 红判定改在原生分辨率做:高分辨率源图直接降采样会把细红线稀释成粉红而漏检(箭头断裂/红框缺失/OCR 误读);
+     原生分辨率下红芯不稀释。笔画红有两类:纯红(237,28,36)与橙红(237,70,20),G 均<130;
+     而橙/黄/粉色块 G≥150 → 用 g<130 排除色块、保留两类红笔画;再按格 any-pool 投到 AW 栅格,细线保持连续 */
+  const NW=img.naturalWidth,NH=img.naturalHeight;
+  const nc=document.createElement("canvas");nc.width=NW;nc.height=NH;const ng=nc.getContext("2d");
+  ng.drawImage(img,0,0);const nd=ng.getImageData(0,0,NW,NH).data;
+  for(let y=0;y<NH;y++){const cy=Math.min(AH-1,(y*k)|0);
+    for(let x=0;x<NW;x++){const j=y*NW+x;const r=nd[j*4],g=nd[j*4+1],b=nd[j*4+2];
+      if(r>140&&r-g>50&&r-b>50&&g<130){const cx=Math.min(AW-1,(x*k)|0);const c2=cy*AW+cx;
+        if(!redm[c2]){redm[c2]=1;rc++;if(cx<rx0)rx0=cx;if(cx>rx1)rx1=cx;if(cy<ry0)ry0=cy;if(cy>ry1)ry1=cy;}}}}
   logAI(ph("· 结构扫描:"+AW+"×"+AH+" 栅格化("+(AW*AH)+"像素),墨线像素 "+ink.reduce((a,v)=>a+v,0)+",图像指纹 #"+fp));await S2(200);
   /* 判定① 图纸类型:平面户型图(彩色填充) vs CAD图(黑白线图) */
   const colR=col/(AW*AH);
@@ -1087,41 +1205,69 @@ 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);
+    if(!S.redRects.length){S.redbox=false;   /* 有红候选像素但无闭合红框矩形(图纸自带零散红线/色块)→ 降级场景A 全自动,避免"场景B 却 0 柜体" */
+      logAI("· 判定② 修正:检测到红色像素但无闭合红框矩形 → 降级为场景A(无红框,AI全自动 ±5-8%)","w");
+      $("dc-red").textContent="无红框 · 场景A";$("dc-rule").textContent="无红框规则 ±5-8%";$("q-ai-scene-chip").textContent="户型图 · 场景A 无红框";}
+    if(S.redbox){
     /* 红色尺寸标注检测:先剥离红框笔画(避免贴着红框的尺寸箭头因连通域合并被过滤),共线实线段合并为双头箭头,单长条亦视为箭头 */
     S.redArrow=null;
+    /* 剥红框:红框笔画整体位于其 bbox 内 → 按 bbox±1 整带清零即可除净框笔画/框内杂线;
+       尺寸箭头在框 bbox 外(常距框2-3px),±1 不外扩伤箭(旧 ±3 外扩会误删贴框箭头) */
     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);
+    for(const rc of S.redRects)for(let yy=Math.max(0,rc.y-1);yy<=Math.min(AH-1,rc.y+rc.h+1);yy++)for(let xx=Math.max(0,rc.x-1);xx<=Math.min(AW-1,rc.x+rc.w+1);xx++)redm2[yy*AW+xx]=0;
+    const arrows=cc(runMask(redm2)).filter(c=>Math.max(c.w,c.h)>=16&&c.area/Math.max(c.w,c.h)<=6);   /* 厚度判据(面积/最长边≤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};}
+    /* 候选箭头列表:单长条 + 共线合并双头箭头;按长降序逐个 OCR,首个读出纯整数者胜出(橙色小数标注含 . / m 无法读成纯整数 → 自然排除) */
+    const candArrows=[];
+    for(const c of arrows)candArrows.push({len:Math.max(c.w,c.h)/k,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,...abb(a1,a2)};}}
+        if(gap>=0&&gap<=40)candArrows.push({len:(Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y))/k,...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,...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 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");}
+        if(gap>=0&&gap<=40)candArrows.push({len:(Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x))/k,...abb(a1,a2)});}}
+    candArrows.sort((a,b)=>b.len-a.len);
+    S.redArrow=candArrows[0]||null;
+    if(candArrows.length){
+      /* OCR 高分裁剪:降采样后数字过小易误读 → 从原图按 2 倍 AW 比例裁出箭头邻域,用红色掩码识别数字;逐候选尝试 */
+      const OM=60,CS=Math.max(2,Math.min(6,1/k));let ocrHit=null;   /* CS:裁剪倍率取 1/k → 裁剪≈原生分辨率,数字不稀释;F=CS 使 ocrNumber 容差与 AW 坐标成比例 */
+      for(const ar of candArrows){
+        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=Math.round(ow*CS);oc2.height=Math.round(oh*CS);
+        const og2=oc2.getContext("2d");og2.imageSmoothingEnabled=true;
+        og2.drawImage(img,ox0/k,oy0/k,ow/k,oh/k,0,0,oc2.width,oc2.height);
+        const d2=og2.getImageData(0,0,oc2.width,oc2.height).data;
+        const rm2=new Uint8Array(oc2.width*oc2.height);
+        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&&g2<130)rm2[i2]=1;}
+        const cands=ocrNumber(rm2,oc2.width,{x:(ar.x-ox0)*CS,y:(ar.y-oy0)*CS,w:ar.w*CS,h:ar.h*CS},mkCC(oc2.width),CS);
+        if(cands.length){ocrHit={ar,cands};break;}}
+      if(ocrHit){S.redArrow=ocrHit.ar;$("q-red-anno").value=ocrHit.cands[0];S.redAnnoCands=ocrHit.cands;
+        logAI("· 红色尺寸标注检测:箭头长 "+Math.round(ocrHit.ar.len)+"px → OCR 读出:<b>"+ocrHit.cands[0]+"</b>"+(ocrHit.cands.length>1?"(存在含糊候选 "+ocrHit.cands.join("/")+",将以 600mm 锚点交叉复核)":"")+"(已自动填入,如有出入可在输入框修改)→ 据此建立全图比例","b");}
+      else{$("q-red-anno").value="";S.redAnnoCands=[];   /* 清空默认值:OCR 失败时不拿假设值校准,回退 600mm 锚点比例 */
+        logAI("· 红色尺寸标注检测:箭头长 "+Math.round(S.redArrow.len)+"px,OCR 未能置信读出标注值 → 暂用 600mm 锚点比例;如知标注数值请在输入框手动填入以校准","e");}}}
   }
   /* 图纸已用红字标注柜体名称(如"2-电视柜")→ 识别之,对应红框柜体直接采用图纸名称,系统不再二次命名 */
   if(S.redbox){const LM=dilateMask(redm,AW,AH,3);
+    /* 剥离红框轮廓:红字标注常紧贴红框,膨胀后与框连成一片被尺寸过滤漏检 → 沿每个红框周界清除 4px 边带,使标注独立成块 */
+    for(const r0 of S.redRects){const bw2=4;
+      for(let xx=Math.max(0,r0.x-bw2);xx<=Math.min(AW-1,r0.x+r0.w+bw2);xx++){
+        for(let yy=Math.max(0,r0.y-bw2);yy<=Math.min(AH-1,r0.y+bw2);yy++)LM[yy*AW+xx]=0;
+        for(let yy=Math.max(0,r0.y+r0.h-bw2);yy<=Math.min(AH-1,r0.y+r0.h+bw2);yy++)LM[yy*AW+xx]=0;}
+      for(let yy=Math.max(0,r0.y-bw2);yy<=Math.min(AH-1,r0.y+r0.h+bw2);yy++){
+        for(let xx=Math.max(0,r0.x-bw2);xx<=Math.min(AW-1,r0.x+bw2);xx++)LM[yy*AW+xx]=0;
+        for(let xx=Math.max(0,r0.x+r0.w-bw2);xx<=Math.min(AW-1,r0.x+r0.w+bw2);xx++)LM[yy*AW+xx]=0;}}
+    const labC=[];
     for(const b0 of cc(LM)){
-      if(b0.w<18||b0.w>0.4*AW||b0.h<7||b0.h>40||b0.area<40)continue;
-      const txt=readRedLabel(b0,AW,AH,k);if(txt)S.redLabels.push({x:b0.x,y:b0.y,w:b0.w,h:b0.h,text:txt});}
+      if(b0.w<12||b0.w>100||b0.h<6||b0.h>44||b0.area<30)continue;
+      const rr=readRedLabel(b0,AW,AH,k);if(rr)labC.push({b0,rr});}
+    /* 采纳策略:高置信整词名直接采用(显著领先次优才信);词形读不准时不猜柜种,
+       回退"柜体N"沿用图纸自身编号——宁缺毋错,避免给柜体安上不存在的柜种名 */
+    labC.sort((a,b)=>b.rr.sc-a.rr.sc);const usedNm={},usedNum={};
+    for(const c of labC){const r=c.rr;let text=null;
+      const conf=r.sc>=0.55&&(!r.tops[1]||r.sc-r.tops[1].sc>=0.05);
+      if(conf&&!usedNm[r.nm]){usedNm[r.nm]=1;text=(r.pre?r.pre+"-":"")+r.nm;}
+      else if(r.pre&&!usedNum[r.pre]){usedNum[r.pre]=1;text="柜体"+r.pre;}
+      if(text)S.redLabels.push({x:c.b0.x,y:c.b0.y,w:c.b0.w,h:c.b0.h,text});}
     if(S.redLabels.length)logAI("· 图纸红字柜体名称识别:"+S.redLabels.length+" 处("+S.redLabels.map(l=>l.text).join("、")+")→ 对应红框柜体直接按图纸标注命名,系统不二次命名","b");}
   setProg(0,"done");setProg(1,"running");
   /* 模式判定:示意图(实心厨房色块) vs 彩色户型图(墙体分离) */
@@ -1412,9 +1558,10 @@ async function runAI(){
           logAI("· 厨房总长:U型 "+fmt2(kLong)+" + "+fmt2(kLong)+" + "+fmt2(kShort)+" − 2.2 = <b>"+fmt2(Math.max(kLong*2+kShort-2.2,0))+"m</b>(可计费总长)","b");}
       }
       logAI(ph("· 厨房台面带聚类→比例建立:"+S.scaleSrc+" → 1px="+(S.scale*1000).toFixed(2)+"mm"),"b");
-    }else{logAI("· 未能自动定位厨房台面带 → 请在下一步补充一个已知尺寸","e");
-      if(!S.scale&&S.redArrow){S.scale=annoScale();S.scaleSrc="红箭头标注校准(场景B)";logAI("· 已用红箭头标注建立比例(600mm锚点未定位,标注默认按 mm):1px="+(S.scale*1000).toFixed(2)+"mm(柜体尺寸可换算)","b");}}
+    }else{logAI("· 未能自动定位厨房台面带 → 请在下一步补充一个已知尺寸","e");}
   }
+  /* 比例兜底(示意图/彩色两路径通用):600mm 锚点未定位时,用红箭头标注建立比例(默认按 mm),确保柜体尺寸可换算 */
+  if(!S.scale&&S.redArrow){S.scale=annoScale();if(S.scale){S.scaleSrc="红箭头标注校准(600mm锚点未定位,标注默认按 mm)";logAI("· 已用红箭头标注建立比例:1px="+(S.scale*1000).toFixed(2)+"mm(柜体尺寸可换算)","b");}}
   draw();await S2(200);
   setProg(1,"done");setProg(2,"running");
   /* 柜体输出(场景B:直接取图纸红框为柜体边界,不二次抓取;场景A:特征识别) */
@@ -1435,7 +1582,7 @@ async function runAI(){
     const nm=(rn?rn:"")+tp;
     S.objs.push({name:nm,kind:"cab",src,w:Math.round(c.w/k),h:Math.round(c.h/k),extras:mkExtras(nm,false)});
     overlays.push({t:"cab",x:c.x/k,y:c.y/k,w:c.w/k,h:c.h/k,label:cn+"·"+tp});
-    const tag=nameOv?"名称取自图纸红字标注(系统不二次命名)":"空间="+(rn||"未识别")+" → <b>"+tp+"</b>";
+    const tag=nameOv?(nameOv.indexOf("红框柜体")===0?"图纸红字未能置信读出 → 按红框序号命名(可在报价编辑中改名)":"名称取自图纸红字标注(系统不二次命名)"):"空间="+(rn||"未识别")+" → <b>"+tp+"</b>";
     if(S.scale||S.ft==="cad"){const wM=c.w/k*dimScale(),hM=c.h/k*dimScale();
       logAI("· "+nm+"["+src+"] "+tag+":实测 "+Math.round(c.w/k)+"×"+Math.round(c.h/k)+"px × "+(dimScale()*1000).toFixed(2)+"mm/px → 最长边 "+fmt2(Math.max(wM,hM))+"m × 高 "+fmt2(fh())+"m → 投影面积 = <b>"+fmt2(Math.max(wM,hM)*fh())+"㎡</b>");}
     else logAI("· "+nm+"["+src+"] "+tag+":"+Math.round(c.w/k)+"px × "+Math.round(c.h/k)+"px(比例未建立,补充已知尺寸后自动换算)","w");};
@@ -1450,7 +1597,8 @@ async function runAI(){
   for(let ri=0;ri<S.redRects.length;ri++){if(cn>=8)break;const c=S.redRects[ri];
     if(kitchen){const ov=Math.max(0,Math.min(c.x+c.w,kitchen.x+kitchen.w)-Math.max(c.x,kitchen.x))*Math.max(0,Math.min(c.y+c.h,kitchen.y+kitchen.h)-Math.max(c.y,kitchen.y));
       if(ov>=0.9*kitchen.w*kitchen.h)continue;}   // 红框整包厨房台面带=校准框,非柜体圈注
-    pushCab(c,"红框",rcName.get(ri)||null);}
+    /* 场景B 无配对红字标注的红框:不按空间底色猜柜种(户型图房间色块判读不可靠),按红框序号中性命名,用户可在报价编辑中改名 */
+    pushCab(c,"红框",rcName.get(ri)||(S.redbox?"红框柜体"+(ri+1):null));}
   if(S.redbox)logAI("· 场景B:柜体边界直接取图纸红框,系统不二次抓取(不合并 AI 特征抓取、不二次红框标注)","w");
   else for(const c of wards){if(cn>=8)break;pushCab(c,"AI");}   /* 场景A 无红框,无需红框去重 */
   logAI(ph("· 柜体识别:"+cn+" 个(最长边×层高=投影面积,均为到顶高柜)"),"b");await S2(200);
@@ -1726,7 +1874,8 @@ function qOpenQuote(q){
     $("dc-rule").textContent="存档恢复(原图未保存)";
     $("q-ai-scene-chip").textContent="存档报价单(原图未保存)";$("q-status-drawing").textContent="⬜ 图纸:未随单保存";
     $("q-btn-analyze").disabled=true;$("q-step2-next-btn").disabled=false;
-    S.objs.forEach(o=>{if(!o.extras)o.extras=mkExtras(o.name||"",false);});
+    /* 存档→打开闭环:存档内增值项勾选/数量/尺寸/备注(ex)原样恢复,未存档时才重建默认,避免用户勾选丢失 */
+    S.objs.forEach(o=>{if(o.ex){o.extras=o.ex;delete o.ex;}if(!o.extras||!Object.keys(o.extras).length)o.extras=mkExtras(o.name||"",false);});
     rebuildObjs();recalc();renderAIResults();
     $("editor-title").textContent=q.name;
     switchView("editor");qOpenEditorStep(4);
@@ -1798,7 +1947,7 @@ async function qExportExcel(){
       if(j.styles){const dec=s=>new TextDecoder().decode(Uint8Array.from(atob(s),c=>c.charCodeAt(0)));
         stylesRaw=dec(j.styles);themeRaw=j.theme?dec(j.theme):"";}}
   }catch(e){}
-  if(!stylesRaw){toast("导出失败:无法读取参考表样式(请确认桌面存在 6.18号欧阳路预估报价.xlsx 且服务已启动)","toast-error");return;}
+  /* 样式闭环:参考表样式取不到(部署环境无 /ref_parts 或桌面无参考表)→ 回退 xlsxBuild 内置默认样式继续导出,不再中断 */
   const t=(x,s)=>({v:x,s:s});
   const rows=[],merges=[],heights={};
   const R=arr=>{rows.push(arr);heights[rows.length-1]=18;};
@@ -1848,9 +1997,20 @@ async function qExportExcel(){
   const bytes=xlsxBuild({rows,merges,heights,cols:COLS,stylesRaw,themeRaw});
   let bin="";for(let i=0;i<bytes.length;i++)bin+=String.fromCharCode(bytes[i]);
   const fname=b.t+"_"+d.getFullYear()+p2(d.getMonth()+1)+p2(d.getDate())+"_"+p2(d.getHours())+p2(d.getMinutes())+".xlsx";
-  fetch("/save_xlsx",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:fname,data:btoa(bin)})})
-    .then(r=>r.json()).then(j=>{toast(j.ok?"已导出到桌面 ✓ "+fname:"导出失败:"+j.err,j.ok?"toast-success":"toast-error");})
-    .catch(e=>toast("导出失败:"+e.message,"toast-error"));
+  /* 导出闭环:服务端写桌面(/save_xlsx)→ 备用接口(/api/export/excel)→ 浏览器下载,三级兜底保证任何部署环境都能导出 */
+  const b64=btoa(bin);
+  const download=()=>{const a=document.createElement("a");
+    a.href=URL.createObjectURL(new Blob([bytes],{type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}));
+    a.download=fname;document.body.appendChild(a);a.click();
+    setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},800);
+    toast("已导出(浏览器下载):"+fname,"toast-success");};
+  fetch("/save_xlsx",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:fname,data:b64})})
+    .then(r=>{if(!r.ok)throw new Error("http"+r.status);return r.json();})
+    .then(j=>{if(j.ok)toast("已导出到桌面 ✓ "+fname,"toast-success");else throw new Error(j.err||"write failed");})
+    .catch(()=>fetch("/api/export/excel",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:fname,data:b64})})
+      .then(r=>{if(!r.ok)throw new Error("http"+r.status);return r.json();})
+      .then(j=>{toast("已导出到桌面 ✓ "+(j.filename||fname),"toast-success");})
+      .catch(download));
 }
 
 /* ========== 规则配置表 ========== */
@@ -1870,8 +2030,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="红标注箭头校准(推断单位"+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");};
+$("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};S.kitW=+Math.max(S.kitBox.wM,S.kitBox.hM).toFixed(2);S.kitH=+Math.min(S.kitBox.wM,S.kitBox.hM).toFixed(2);}   /* 厨房宽/高随新比例同步重算,总长度闭环 */
+  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();renderAIResults();toast("比例已建立,柜体尺寸已换算为米");}else toast("请填写像素数与毫米数","toast-error");};   /* 补比例后同步刷新第二步结果面板(px→m),兑现"补充已知尺寸后自动换算" */
 
 qBindDrop($("q-upload-zone-unified"));qBindDrop($("q-canvas-drop"));   /* 点击 + 拖拽双通道上传/更换图纸 */
 renderTiers();renderRules();renderQuoteList();rebuildObjs();recalc();