fix(geom8): §3 внешние углы — корректная геометрия визуализации

Было: продолжение рисовалось от next-vertex назад через v, дуга центрировалась
у next-vertex с углом из произвольного направления — углы отображались
неправильно (не у тех вершин, не в тех направлениях).

Стало: для каждой вершины v вычисляются prev/next, направления u=(v-prev)/|·|
(входящая сторона), w=(next-v)/|·| (исходящая). Продолжение u рисуется от v
дальше. Дуга — сектор у v от u-направления до w-направления, sweep
определяется через знак векторного произведения (u×w). Подпись угла —
по биссектрисе дуги на радиусе Rlabel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-28 08:56:35 +03:00
parent 640ca245ee
commit e22405516b
+18 -15
View File
@@ -1433,26 +1433,29 @@ function buildP3(){
function pts(nv){ const v=[];for(let i=0;i<nv;i++){const a=-Math.PI/2+2*Math.PI*i/nv;v.push({x:cx+R*Math.cos(a),y:cy+R*Math.sin(a)});}return v; }
function drawNormal(){
const vs=pts(n);
const extLen=44, Rarc=24, Rlabel=42;
let s='<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;max-width:400px;background:var(--card);border:1px solid var(--border);border-radius:14px">';
s+='<polygon points="'+vs.map(v=>v.x+','+v.y).join(' ')+'" fill="rgba(217,119,6,.10)" stroke="#d97706" stroke-width="2.5" stroke-linejoin="round"/>';
vs.forEach((v,i)=>{
const nxt=vs[(i+1)%n];
const ext=40;
const dx=v.x-nxt.x,dy=v.y-nxt.y;
const len=Math.hypot(dx,dy);
const ex=v.x+ext*dx/len,ey=v.y+ext*dy/len;
s+='<line x1="'+nxt.x+'" y1="'+nxt.y+'" x2="'+ex+'" y2="'+ey+'" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4 2"/>';
const prev=vs[(i-1+n)%n], next=vs[(i+1)%n];
const ux=v.x-prev.x, uy=v.y-prev.y, ul=Math.hypot(ux,uy);
const wx=next.x-v.x, wy=next.y-v.y, wl=Math.hypot(wx,wy);
const u={x:ux/ul, y:uy/ul}, w={x:wx/wl, y:wy/wl};
const ex=v.x+extLen*u.x, ey=v.y+extLen*u.y;
s+='<line x1="'+v.x+'" y1="'+v.y+'" x2="'+ex+'" y2="'+ey+'" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4 2"/>';
const sx=v.x+Rarc*u.x, sy=v.y+Rarc*u.y;
const tx=v.x+Rarc*w.x, ty=v.y+Rarc*w.y;
const cross=u.x*w.y - u.y*w.x;
const sweep=cross>0?1:0;
const extAngle=360/n;
const startAng=Math.atan2(v.y-nxt.y,v.x-nxt.x)*180/Math.PI;
const endAng=startAng-extAngle;
const ra=Rext,sa=startAng*(Math.PI/180),ea=endAng*(Math.PI/180);
const x1=nxt.x+ra*Math.cos(sa),y1=nxt.y+ra*Math.sin(sa);
const x2=nxt.x+ra*Math.cos(ea),y2=nxt.y+ra*Math.sin(ea);
const large=extAngle>180?1:0;
s+='<path d="M '+x1+' '+y1+' A '+ra+' '+ra+' 0 '+large+' 0 '+x2+' '+y2+'" fill="rgba(245,158,11,.35)" stroke="#f59e0b" stroke-width="1.5"/>';
s+='<text x="'+nxt.x+'" y="'+nxt.y+'" text-anchor="middle" dominant-baseline="middle" font-size="9" fill="#92400e" dx="'+(nxt.x-cx)*0.4+'" dy="'+(nxt.y-cy)*0.4+'">'+(extAngle.toFixed(1))+'°</text>';
s+='<path d="M '+v.x+' '+v.y+' L '+sx+' '+sy+' A '+Rarc+' '+Rarc+' 0 0 '+sweep+' '+tx+' '+ty+' Z" fill="rgba(245,158,11,.40)" stroke="#f59e0b" stroke-width="1.5"/>';
let aU=Math.atan2(u.y,u.x), aW=Math.atan2(w.y,w.x);
if(sweep===1){ if(aW<aU) aW+=2*Math.PI; } else { if(aW>aU) aW-=2*Math.PI; }
const mid=(aU+aW)/2;
const lx=v.x+Rlabel*Math.cos(mid), ly=v.y+Rlabel*Math.sin(mid);
s+='<text x="'+lx+'" y="'+ly+'" text-anchor="middle" dominant-baseline="middle" font-size="11" fill="#92400e" font-weight="800">'+extAngle.toFixed(1).replace(/\.0$/,'')+'°</text>';
});
vs.forEach((v,i)=>{
vs.forEach(v=>{
s+='<circle cx="'+v.x+'" cy="'+v.y+'" r="5" fill="#d97706" stroke="#fff" stroke-width="2"/>';
});
s+='</svg>';