refactor(arch): redesign the code structure

This commit is contained in:
Horldsence
2026-09-10 22:38:27 +08:00
parent 21e8a8bf52
commit 83d2e24448
42 changed files with 2514 additions and 251 deletions
+185
View File
@@ -0,0 +1,185 @@
<div id="q1-localization">
<style>
#q1-localization .q1-plot { width:100%; display:block; overflow:hidden; }
#q1-localization .q1-legend { display:flex; flex-wrap:wrap; gap:12px; margin-block:12px; }
#q1-localization .q1-legend span { display:inline-flex; align-items:center; gap:6px; }
#q1-localization .q1-swatch { display:inline-block; width:18px; height:3px; }
#q1-localization .q1-stats { display:flex; flex-wrap:wrap; gap:12px 24px; margin-block:12px; }
#q1-localization .q1-status { margin-block:8px; }
#q1-localization .q1-plot text { fill:var(--foreground); font-size:12px; font-weight:400; }
#q1-localization details { margin-block:12px; }
#q1-localization summary { cursor:pointer; }
</style>
<h2>带误差的交会定位</h2>
<div class="viz-controls">
<label class="form-label">观测案例
<select class="form-select" data-q1="case" aria-label="观测案例"></select>
</label>
<label class="form-label">视图
<select class="form-select" data-q1="view" aria-label="视图">
<option value="region">定位区域放大</option>
<option value="global">检测点全景</option>
</select>
</label>
<label class="form-label">已加入测点 <span data-q1="step-label" class="tabular-nums"></span>
<input class="form-range" data-q1="step" type="range" min="1" max="3" value="3" step="1" aria-label="已加入测点数量">
</label>
</div>
<div class="q1-stats tabular-nums" aria-live="polite" data-q1="stats"></div>
<svg class="q1-plot" data-q1="plot" role="img" aria-label="检测角域、定位多边形、直径线段和两个包围圆"></svg>
<div class="q1-legend text-small">
<span><i class="q1-swatch" style="background:var(--viz-series-1)"></i>定位区域</span>
<span><i class="q1-swatch" style="background:var(--viz-series-2)"></i>直径线段及直径圆</span>
<span><i class="q1-swatch" style="background:var(--viz-series-3)"></i>最小包围圆</span>
<span>□ 检测点 + 合成真值 ◎ 覆盖反例顶点</span>
</div>
<div class="q1-status text-small" data-q1="status" aria-live="polite"></div>
<details>
<summary>检测点与示向度</summary>
<div class="table-responsive">
<table class="table table-sm tabular-nums">
<thead><tr><th>测点</th><th class="text-end">x / m</th><th class="text-end">y / m</th><th class="text-end">示向度 / °</th><th class="text-end">误差半角 / °</th></tr></thead>
<tbody data-q1="rows"></tbody>
</table>
</div>
</details>
<script>
(() => {
const root=document.getElementById('q1-localization');
const cases=/*__Q1_DATA__*/;
const get=name=>root.querySelector('[data-q1="'+name+'"]');
const picker=get('case'), step=get('step'), view=get('view'), svg=get('plot');
const NS='http://www.w3.org/2000/svg';
const fmt=x=>Number.isFinite(x)?x.toLocaleString('zh-CN',{maximumFractionDigits:4}):'—';
const names={polygon:'有界多边形',segment:'线段',point:'单点',empty:'空集',unbounded:'无界区域'};
function node(tag,attrs={},parent=svg,text=null) {
const el=document.createElementNS(NS,tag);
Object.entries(attrs).forEach(([k,v])=>el.setAttribute(k,String(v)));
if(text!==null)el.textContent=text;
parent.appendChild(el);return el;
}
cases.forEach((c,i)=>picker.add(new Option(c.name,i)));
picker.value=String(Math.max(0,cases.findIndex(c=>c.source==='official_practice')));
function load() {
const c=cases[+picker.value];
step.max=String(c.measurements.length);step.value=step.max;draw();
}
function draw() {
const c=cases[+picker.value], n=+step.value, ms=c.measurements.slice(0,n), r=c.frames[n-1];
get('step-label').textContent=n+' / '+c.measurements.length;
get('stats').replaceChildren();
const values=[['区域',names[r.status]],['直径 D',r.diameter_infinite?'∞':fmt(r.diameter_m)+' m'],
['最小包围半径',r.minimum_enclosing_circle?fmt(r.minimum_enclosing_circle.radius_m)+' m':'—']];
values.forEach(([label,value])=>{const span=document.createElement('span');span.textContent=label+':'+value;get('stats').appendChild(span);});
const source=c.source==='official_practice'?'官方演练观测;真实源位置未公开':'本地合成案例';
get('status').textContent=source+' · '+(r.status==='unbounded'?'阴影只显示视窗内部分,真实区域无界':
r.status==='empty'?'这些观测在给定误差界下不相容':
'直径圆'+(r.diameter_circle.covers?'可以覆盖':'不能覆盖,最大超出 '+fmt(r.diameter_circle.max_excess_m)+' m')+
' · 清除半径余量 '+fmt(r.clearance_certificate.remaining_margin_m)+' m');
get('rows').replaceChildren();
ms.forEach((m,i)=>{
const tr=document.createElement('tr');
[String(i+1),fmt(m.x),fmt(m.y),fmt(m.bearing_deg),fmt(m.half_angle_deg)].forEach((v,j)=>{
const td=document.createElement('td');td.textContent=v;if(j)td.className='text-end';tr.appendChild(td);
});get('rows').appendChild(tr);
});
svg.replaceChildren();
const width=Math.max(280,root.getBoundingClientRect().width);
const height=Math.max(310,Math.min(440,width*.65));
svg.setAttribute('viewBox',`0 0 ${width} ${height}`);
svg.style.height=height+'px';
node('title',{},svg,c.name+':'+names[r.status]);
node('desc',{},svg,get('status').textContent);
const l=64,t=20,right=20,bottom=46,w=width-l-right,h=height-t-bottom;
const finite=['polygon','segment','point'].includes(r.status);
let pts=(view.value==='region'&&finite)?r.vertices.map(p=>[...p]):ms.map(m=>[m.x,m.y]);
if(!pts.length)pts=[[0,0]];
if(view.value==='global'&&finite)pts.push(...r.vertices);
if(c.target)pts.push(c.target);
if(finite&&view.value==='region') {
[r.diameter_circle,r.minimum_enclosing_circle].forEach(circle=>{
const [x,y]=circle.center,d=circle.radius_m;
pts.push([x-d,y-d],[x+d,y+d]);
});
}
const xs=pts.map(p=>p[0]),ys=pts.map(p=>p[1]);
const cx=(Math.min(...xs)+Math.max(...xs))/2, cy=(Math.min(...ys)+Math.max(...ys))/2;
let dx=Math.max(...xs)-Math.min(...xs),dy=Math.max(...ys)-Math.min(...ys);
const minSpan=finite&&view.value==='region'?Math.max(2,(r.diameter_m||0)*.08):200;
const scale=Math.min(w/Math.max(dx*1.3,minSpan),h/Math.max(dy*1.3,minSpan));
const xmin=cx-w/(2*scale),xmax=cx+w/(2*scale),ymin=cy-h/(2*scale),ymax=cy+h/(2*scale);
const X=x=>l+(x-xmin)*scale,Y=y=>t+h-(y-ymin)*scale;
const defs=node('defs');const clip=node('clipPath',{id:'q1-clip'},defs);
node('rect',{x:l,y:t,width:w,height:h},clip);
for(let i=0;i<4;i++) {
const x=xmin+(xmax-xmin)*i/3, y=ymin+(ymax-ymin)*i/3;
node('line',{x1:X(x),x2:X(x),y1:t,y2:t+h,stroke:'var(--border)'});
node('line',{x1:l,x2:l+w,y1:Y(y),y2:Y(y),stroke:'var(--border)'});
const tick=z=>Math.abs(z)>=1e6?z.toExponential(1):Math.abs(xmax-xmin)<20?z.toFixed(1):Math.round(z).toString();
node('text',{x:X(x),y:t+h+19,'text-anchor':i===0?'start':i===3?'end':'middle'},svg,tick(x));
node('text',{x:l-8,y:Y(y)+4,'text-anchor':'end'},svg,tick(y));
}
node('text',{x:l+w/2,y:height-7,'text-anchor':'middle',class:'axis-title','data-axis':'x'},svg,'x / m');
node('text',{x:14,y:t+h/2,'text-anchor':'middle',transform:`rotate(-90 14 ${t+h/2})`,class:'axis-title','data-axis':'y'},svg,'y / m');
const layer=node('g',{'clip-path':'url(#q1-clip)'});
let displayPoly;
if(finite)displayPoly=r.vertices;
else if(r.status==='empty')displayPoly=[];
else {
// Display clipping ONLY: Python's result remains explicitly unbounded.
displayPoly=[[xmin,ymin],[xmax,ymin],[xmax,ymax],[xmin,ymax]];
ms.forEach(m=>{
const theta=m.bearing_deg*Math.PI/180,d=m.half_angle_deg*Math.PI/180;
const normals=[[Math.sin(theta-d),-Math.cos(theta-d)],[-Math.sin(theta+d),Math.cos(theta+d)],[-Math.cos(theta),-Math.sin(theta)]];
normals.forEach(([a,b])=>{
const bound=a*m.x+b*m.y,out=[];
for(let i=0;i<displayPoly.length;i++) {
const p=displayPoly[i],q=displayPoly[(i+1)%displayPoly.length];
const f=a*p[0]+b*p[1]-bound,g=a*q[0]+b*q[1]-bound;
if(f<=1e-8)out.push(p);
if((f<0&&g>0)||(f>0&&g<0))out.push([p[0]+(q[0]-p[0])*f/(f-g),p[1]+(q[1]-p[1])*f/(f-g)]);
}displayPoly=out;
});
});
}
if(displayPoly.length>=3)node('polygon',{points:displayPoly.map(p=>X(p[0])+','+Y(p[1])).join(' '),fill:'var(--viz-series-1)','fill-opacity':.18,stroke:'var(--viz-series-1)','stroke-width':1.5},layer);
const reach=Math.hypot(xmax-xmin,ymax-ymin)+Math.max(...ms.map(m=>Math.hypot(m.x-cx,m.y-cy)))*2;
ms.forEach((m,i)=>{
[-m.half_angle_deg,0,m.half_angle_deg].forEach(offset=>{
const a=(m.bearing_deg+offset)*Math.PI/180;
node('line',{x1:X(m.x),y1:Y(m.y),x2:X(m.x+reach*Math.cos(a)),y2:Y(m.y+reach*Math.sin(a)),
stroke:'var(--foreground)','stroke-opacity':offset===0?.35:.22,'stroke-width':1,'stroke-dasharray':offset===0?'none':'4 4'},layer);
});
if(m.x>=xmin&&m.x<=xmax&&m.y>=ymin&&m.y<=ymax) {
node('rect',{x:X(m.x)-4,y:Y(m.y)-4,width:8,height:8,fill:'var(--foreground)'},layer);
node('text',{x:Math.min(l+w-22,X(m.x)+9),y:Math.max(t+14,Y(m.y)-9)},layer,'S'+(i+1));
}
});
if(finite) {
[r.diameter_circle,r.minimum_enclosing_circle].forEach((circle,i)=>{
node('circle',{cx:X(circle.center[0]),cy:Y(circle.center[1]),r:circle.radius_m*scale,
fill:'none',stroke:`var(--viz-series-${i+2})`,'stroke-width':2,'stroke-dasharray':i===0?'6 4':'none'},layer);
});
const [a,b]=r.diameter_pair;
node('line',{x1:X(a[0]),y1:Y(a[1]),x2:X(b[0]),y2:Y(b[1]),stroke:'var(--viz-series-2)','stroke-width':2.5},layer);
r.vertices.forEach((p,i)=>{
const mark=node('circle',{cx:X(p[0]),cy:Y(p[1]),r:4,fill:'var(--viz-series-1)'},layer);
node('title',{},mark,`V${i+1} (${fmt(p[0])}, ${fmt(p[1])})`);
});
const witness=r.diameter_circle.witness_vertex;
if(witness)node('circle',{cx:X(witness[0]),cy:Y(witness[1]),r:8,
fill:'none',stroke:'var(--viz-series-2)','stroke-width':2},layer);
}
if(c.target&&c.target[0]>=xmin&&c.target[0]<=xmax&&c.target[1]>=ymin&&c.target[1]<=ymax) {
const x=X(c.target[0]),y=Y(c.target[1]);
node('path',{d:`M${x-5},${y}h10 M${x},${y-5}v10`,stroke:'var(--foreground)','stroke-width':2},layer);
}
node('rect',{x:l,y:t,width:w,height:h,fill:'none',stroke:'var(--border)','data-chart-frame':''});
}
picker.addEventListener('change',load);step.addEventListener('input',draw);view.addEventListener('change',draw);
new ResizeObserver(draw).observe(root);
load();
})();
</script>
</div>
+25
View File
@@ -0,0 +1,25 @@
:root {
color-scheme:light dark;
--background:light-dark(#ffffff,#191c21);
--foreground:light-dark(#24282e,#eceff3);
--border:light-dark(#d9dfe5,#424a55);
--muted-foreground:light-dark(#56616c,#b4bdc8);
--viz-series-1:light-dark(#146e9a,#6cc8ed);
--viz-series-2:light-dark(#c6503a,#ff947d);
--viz-series-3:light-dark(#198064,#64d6ac);
--font-size-base:14px;
}
body { max-width:1000px; margin:0 auto; padding:24px; background:var(--background);color:var(--foreground);font:var(--font-size-base)/1.6 system-ui,"Microsoft YaHei",sans-serif; }
h2 {font-size:1.4em;font-weight:500;margin-block:0 18px;}
.viz-controls {display:flex;gap:16px;flex-wrap:wrap;align-items:end;}
.form-label {display:flex;flex-direction:column;gap:6px;flex:1 1 180px;min-width:0;}
.form-select {font:inherit;color:var(--foreground);background:var(--background);border:1px solid var(--border);padding:8px;max-width:100%;border-radius:5px;}
.form-range {width:100%;min-height:30px;accent-color:var(--viz-series-1);}
.text-small {font-size:12px;}
.tabular-nums {font-variant-numeric:tabular-nums;}
.table-responsive {overflow-x:auto;}
.table {width:100%;border-collapse:collapse;}
.table th,.table td {padding:6px 8px;border-bottom:1px solid var(--border);}
.table th {font-weight:500;text-align:left;}
.table .text-end {text-align:right;}
@media(max-width:480px) {body{padding:12px;}.form-label{flex-basis:100%;}.form-select{font-size:16px;}}
+57
View File
@@ -0,0 +1,57 @@
"""Offline numerical comparison; timings are local measurements, not API latency."""
from dataclasses import replace
import json
import math
from pathlib import Path
from statistics import median
import sys
from time import perf_counter
from mcm26b.geometry import (Measurement, solve, smallest_enclosing_circle,
first_bearing_candidate_disk)
from mcm26b.geometry.localization import _enclosing_circle_reference
ROOT = Path(__file__).resolve().parents[1]
def timed(function, points, repeats):
durations = []
for _ in range(repeats):
start = perf_counter()
result = function(points)
durations.append(perf_counter()-start)
return result, median(durations)*1000
def main():
timings = []
for size in [4, 20, 60]:
points = [(20*math.cos(i*math.tau/size), 20*math.sin(i*math.tau/size)) for i in range(size)]
old, old_ms = timed(_enclosing_circle_reference, points, 3)
new, new_ms = timed(smallest_enclosing_circle, points, 11)
timings.append(dict(vertices=size, reference_ms=old_ms, incremental_ms=new_ms,
speedup=old_ms/new_ms, radius_difference_m=abs(old[1]-new[1])))
source = next((ROOT/'output/q1/practice').glob('*_observations.json'))
rows = json.loads(source.read_text(encoding='utf-8'))['measurements']
observations = [Measurement(**m) for m in rows]
sensitivity = []
for margin in [0, 0.005]:
ms = [replace(m, half_angle_deg=m.half_angle_deg+margin) for m in observations]
result, duration = timed(solve, ms, 31)
sensitivity.append(dict(additional_half_angle_deg=margin, solve_ms=duration,
diameter_m=result['diameter_m'],
mec_radius_m=result['minimum_enclosing_circle']['radius_m'],
diameter_circle_excess_m=result['diameter_circle']['max_excess_m'],
clearance_margin_m=result['clearance_certificate']['remaining_margin_m']))
report = dict(python=sys.version.split()[0], timing_method='median perf_counter; warm local process',
timing_scope='MEC only; not whole solver speedup or simulator/network performance',
circle_benchmarks=timings, practice_sensitivity=sensitivity,
q2_first_bearing_certificate=first_bearing_candidate_disk(Measurement(0, 0, 0, 1.005)))
output = ROOT/'output/q1/optimization_report.json'
output.write_text(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False)+'\n', encoding='utf-8')
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
+71
View File
@@ -0,0 +1,71 @@
"""Build reproducible examples and the interactive visualization from Python results."""
from dataclasses import asdict
import json
import math
from pathlib import Path
from mcm26b import Measurement, solve
ROOT = Path(__file__).resolve().parents[1]
ASSETS = Path(__file__).resolve().parent / "assets"
def measured(stations, target, errors):
return [Measurement(x,y,(math.degrees(math.atan2(target[1]-y,target[0]-x))+e)%360)
for (x,y),e in zip(stations,errors)]
def scenarios():
target=(80,90)
normal=measured([(-600,-250),(650,-100),(100,700)],target,[.65,-.55,.35])
h=20*math.sqrt(3)
triangle=[Measurement(-1000,0,1),Measurement(540,-500*math.sqrt(3),121),
Measurement(520,h+500*math.sqrt(3),241)]
return [
dict(id='normal',name='三点交会',source='synthetic',target=target,measurements=normal),
dict(id='triangle',name='直径圆反例',source='synthetic',target=(20,h/3),measurements=triangle),
dict(id='poor',name='近共线检测点',source='synthetic',target=(900,50),
measurements=measured([(0,0),(100,0),(200,0)],(900,50),[0,0,0])),
dict(id='empty',name='不相容观测',source='synthetic',target=None,
measurements=[Measurement(-100,0,180),Measurement(100,0,0)]),
dict(id='segment',name='零误差退化线段',source='synthetic',target=(40,0),
measurements=[Measurement(0,0,0,0),Measurement(100,0,180,0)]),
]
def main():
cases=scenarios()
practice=ROOT/'output/q1/practice'
for path in sorted(practice.glob('*_observations.json')):
doc=json.loads(path.read_text(encoding='utf-8'))
cases.append(dict(id='practice-'+doc['case_code'],name='官方演练 '+doc['case_code'],
source='official_practice',target=None,
measurements=[Measurement(**m) for m in doc['measurements']]))
examples=ROOT/'examples/q1'
results=ROOT/'output/q1'
examples.mkdir(parents=True,exist_ok=True)
results.mkdir(parents=True,exist_ok=True)
dataset=[]
for case in cases:
ms=case['measurements']
frames=[solve(ms[:n]) for n in range(1,len(ms)+1)]
data={**case,'measurements':[asdict(m) for m in ms],'frames':frames}
dataset.append(data)
if case['source']=='synthetic':
(examples/(case['id']+'.json')).write_text(json.dumps({k:v for k,v in data.items() if k!='frames'},ensure_ascii=False,indent=2),encoding='utf-8')
(results/(case['id']+'_result.json')).write_text(json.dumps(frames[-1],ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8')
(results/'demo_data.json').write_text(json.dumps(dataset,ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8')
template=(ASSETS / 'q1.template.html').read_text(encoding='utf-8')
fragment=template.replace('/*__Q1_DATA__*/',json.dumps(dataset,ensure_ascii=False,allow_nan=False))
(ROOT/'output'/'q1'/'question1_full.html').write_text(fragment,encoding='utf-8')
# A standalone companion uses the same fragment and computation results.
theme=(ASSETS / 'standalone.css').read_text(encoding='utf-8')
page='<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>B题问题一交会定位</title><style>'+theme+'</style><body>'+fragment+'</body></html>'
(results/'question1.html').write_text(page,encoding='utf-8')
print(json.dumps({'cases':len(dataset),'fragment_bytes':len(fragment.encode('utf-8')),
'standalone':str(results/'question1.html')},ensure_ascii=False))
if __name__=='__main__':
main()
+121
View File
@@ -0,0 +1,121 @@
"""Collect a few bearings for Q1 from an ALREADY OPEN practice session.
There is deliberately no test-start API, formal mode, or /clear call here.
The documented robot API does not expose mode: verify the UI before running.
"""
import argparse
import json
import math
from pathlib import Path
import time
import uuid
from urllib.error import URLError
from urllib.request import Request, urlopen
from mcm26b import Measurement, solve
class PracticeClient:
def __init__(self, robot_id, output, base_url='http://127.0.0.1:2026'):
self.robot_id = robot_id
self.output = output
self.base_url = base_url.rstrip('/')
self.prefix = uuid.uuid4().hex[:12]
self.count = 0
self.events = []
self.deadline = None
def action(self, path, **extra):
if path not in ('/enter', '/measure', '/exit'):
raise ValueError('采集器只允许进入、检测、退出')
if self.deadline is not None and path != '/exit' and time.monotonic() > self.deadline-15:
raise TimeoutError('剩余时间不足,预留退出时间')
self.count += 1
payload = dict(arena_id='default', robot_id=self.robot_id,
request_id=f'q1-{self.prefix}-{self.count}', **extra)
encoded = json.dumps(payload, allow_nan=False).encode('utf-8')
for attempt in range(3):
request = Request(self.base_url+path, data=encoded,
headers={'Content-Type':'application/json'}, method='POST')
try:
with urlopen(request, timeout=5) as response:
status = response.status
body = json.load(response)
# A retried action is recorded/consumed only once.
self.events.append(dict(path=path, request=payload, response=body, retries=attempt))
self.output.write_text(json.dumps(self.events,ensure_ascii=False,indent=2),encoding='utf-8')
if status != 200 or body.get('accepted') is not True:
raise RuntimeError(f'动作未被接受: {status}, {body}')
if path == '/enter':
self.deadline = time.monotonic()+body['remaining_real_duration_s']
return body
except URLError:
if attempt == 2:
raise
time.sleep(.25)
def measure(self, p, channel):
return self.action('/measure',position={'x':p[0],'y':p[1]},channel=channel)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--robot-id',required=True)
parser.add_argument('--practice-confirmed',action='store_true',required=True,
help='仅在界面确认为问题3演练且接口已就绪时提供')
parser.add_argument('--case-code',required=True,help='界面显示的演练案例编码')
parser.add_argument('--output',type=Path,default=Path('output/q1/practice'))
args=parser.parse_args()
args.output.mkdir(parents=True,exist_ok=True)
log_path=args.output/f'{args.case_code}_actions.json'
if log_path.exists():
parser.error('同名采集日志已存在,请使用新的输出目录,避免覆盖证据')
client=PracticeClient(args.robot_id,log_path)
observations=[]
channel=None
first=None
entered=False
repeat=None
try:
client.action('/enter')
entered=True
for p in [(0,0),(800,0),(-800,0),(0,800),(0,-800)]:
for c in range(1,21):
response=client.measure(p,c)
if response['measure_result']=='direction':
first=(p,response['svd_deg'])
observations.append(Measurement(*p,response['svd_deg']))
channel=c
break
if channel is not None:
break
if channel is None:
raise RuntimeError('预设采样点未取得direction;本次不扩展为全域搜索')
p,theta=first
repeat=client.measure(p,channel)
a=math.radians(theta)
# For an omnidirectional source seen initially at <=1500m, these
# 700m-forward, +/-200m-lateral points remain within 1000m of its wedge.
for lateral in [200,-200,300,-300]:
q=(p[0]+700*math.cos(a)-lateral*math.sin(a),
p[1]+700*math.sin(a)+lateral*math.cos(a))
response=client.measure(q,channel)
if response['measure_result']=='direction':
observations.append(Measurement(*q,response['svd_deg']))
if len(observations)>=3:
break
data={'name':'官方问题3演练观测','source':'official_practice','case_code':args.case_code,
'channel':channel,'ground_truth':None,'measurements':[m.__dict__ for m in observations],
'repeated_station_response':repeat,'result':solve(observations),
'scope':'仅采集问题一观测,不执行问题三全清除任务;没有调用clear'}
path=args.output/f'{args.case_code}_observations.json'
path.write_text(json.dumps(data,ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8')
print(json.dumps({'observations':str(path),'count':len(observations),'result':data['result']},ensure_ascii=False,indent=2))
finally:
if entered:
client.action('/exit')
if __name__=='__main__':
main()
+94
View File
@@ -0,0 +1,94 @@
"""Rasterize our restricted SVG primitives with Pillow, without a browser.
This inspects geometry/labels at both widths/themes. It does not emulate HTML/CSS.
"""
import math
from pathlib import Path
import re
import xml.etree.ElementTree as ET
from PIL import Image,ImageDraw,ImageFont
ROOT=Path(__file__).resolve().parents[1]
S=2
FONT=ImageFont.truetype('C:/Windows/Fonts/msyh.ttc',12*S)
def render(path):
root=ET.fromstring(path.read_text(encoding='utf-8'))
width,height=map(float,(root.get('width'),root.get('height')))
bg='#191c21' if 'dark' in path.stem else '#ffffff'
image=Image.new('RGBA',(round(width*S),round(height*S)),bg)
clip=next(el for el in root.iter() if el.tag.endswith('clipPath'))[0]
clipbox=tuple(float(clip.get(k))*S for k in ('x','y','width','height'))
def color(value,opacity=1):
if value in (None,'none'): return None
value=value.lstrip('#')
return tuple(int(value[i:i+2],16) for i in (0,2,4))+(round(opacity*255),)
def draw(el,clipped=False):
nonlocal image
tag=el.tag.rsplit('}',1)[-1]
if tag in ('defs','title','desc'):return
if tag in ('svg','g'):
for child in el:draw(child,clipped or bool(el.get('clip-path')))
return
layer=Image.new('RGBA',image.size,(0,0,0,0));d=ImageDraw.Draw(layer)
num=lambda key,default=0:float(el.get(key,default))*S
fill=color(el.get('fill'),float(el.get('fill-opacity',1)))
stroke=color(el.get('stroke'),float(el.get('stroke-opacity',1)))
sw=max(1,round(num('stroke-width',1)))
if tag=='line':
xy=[(num('x1'),num('y1')),(num('x2'),num('y2'))]
dash=el.get('stroke-dasharray','none')
if dash=='none': d.line(xy,fill=stroke,width=sw)
else:
a,b=xy;dist=math.dist(a,b);unit=((b[0]-a[0])/max(dist,1e-15),(b[1]-a[1])/max(dist,1e-15))
on,off=[float(v)*S for v in dash.split()]
# Bound offscreen ray work to the finite exported viewport vicinity.
segments=min(100000,int(dist/(on+off))+1)
for i in range(segments):
lo=i*(on+off);hi=min(lo+on,dist)
d.line([(a[0]+lo*unit[0],a[1]+lo*unit[1]),(a[0]+hi*unit[0],a[1]+hi*unit[1])],fill=stroke,width=sw)
elif tag=='rect':
box=(num('x'),num('y'),num('x')+num('width'),num('y')+num('height'))
d.rectangle(box,fill=fill,outline=stroke,width=sw)
elif tag=='circle':
x,y,r=num('cx'),num('cy'),num('r');box=(x-r,y-r,x+r,y+r)
if el.get('stroke-dasharray') not in (None,'none'):
for a in range(0,360,18):d.arc(box,a,a+10,fill=stroke,width=sw)
else:d.ellipse(box,fill=fill,outline=stroke,width=sw)
elif tag=='polygon':
points=[tuple(float(v)*S for v in pair.split(',')) for pair in el.get('points').split()]
d.polygon(points,fill=fill)
if stroke:d.line(points+[points[0]],fill=stroke,width=sw,joint='curve')
elif tag=='path':
for x,y,axis,length in re.findall(r'M([\d.e+-]+),([\d.e+-]+)([hv])([\d.e+-]+)',el.get('d')):
x,y,length=float(x)*S,float(y)*S,float(length)*S
d.line([(x,y),(x+length,y) if axis=='h' else (x,y+length)],fill=stroke,width=sw)
elif tag=='text':
value=el.text or '';x,y=num('x'),num('y');anchor=el.get('text-anchor','start')
length=d.textlength(value,font=FONT)
if el.get('transform'):
tile=Image.new('RGBA',(math.ceil(length+8*S),20*S));td=ImageDraw.Draw(tile)
td.text((4*S,0),value,font=FONT,fill=fill)
tile=tile.rotate(90,expand=True)
layer.alpha_composite(tile,(round(x-tile.width/2),round(y-tile.height/2)))
else:
x-=length/2 if anchor=='middle' else length if anchor=='end' else 0
d.text((x,y),value,font=FONT,fill=fill,anchor='ls')
else:raise ValueError('unsupported SVG primitive '+tag)
if clipped:
x,y,w,h=clipbox
mask=Image.new('L',image.size);ImageDraw.Draw(mask).rectangle((x,y,x+w,y+h),fill=255)
transparent=Image.new('RGBA',image.size)
layer=Image.composite(layer,transparent,mask)
image=Image.alpha_composite(image,layer)
draw(root)
output=path.with_suffix('.png');image.convert('RGB').save(output)
return str(output.relative_to(ROOT))
if __name__=='__main__':
for path in sorted((ROOT/'output/q1/figures').glob('*.svg')):print(render(path))
+61
View File
@@ -0,0 +1,61 @@
/* Run only our generated local script in a minimal DOM, with no browser or network.
* This checks event handlers and serializes the produced SVG for static inspection.
* It is not a replacement for a native browser layout test.
*/
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),assert=require('node:assert');
const rootDir=path.resolve(__dirname,'..');
const source=fs.readFileSync(path.join(rootDir,'output/q1/question1_full.html'),'utf8');
const script=source.match(/<script>([\s\S]*?)<\/script>/)[1];
class Element {
constructor(tag) {this.tag=tag;this.attrs={};this.children=[];this.style={};this.events={};this.value='';this.max='3';this.textContent='';}
setAttribute(k,v){this.attrs[k]=String(v);}
appendChild(x){this.children.push(x);return x;}
replaceChildren(...x){this.children=x;this.textContent='';}
addEventListener(k,f){this.events[k]=f;}
add(o){this.children.push(o);}
getBoundingClientRect(){return {width:width};}
}
const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
const palette={light:{'--foreground':'#24282e','--border':'#d9dfe5','--viz-series-1':'#146e9a','--viz-series-2':'#c6503a','--viz-series-3':'#198064'},dark:{'--foreground':'#eceff3','--border':'#424a55','--viz-series-1':'#6cc8ed','--viz-series-2':'#ff947d','--viz-series-3':'#64d6ac'}};
function serialize(el,theme){
if(el.tag==='text')Object.assign(el.attrs,{fill:palette[theme]['--foreground'],'font-size':12,'font-family':'Microsoft YaHei, sans-serif'});
const attrs=Object.entries(el.attrs).map(([k,v])=>`${k}="${esc(String(v).replace(/var\(([^)]+)\)/g,(_,n)=>palette[theme][n]||n))}"`).join(' ');
return `<${el.tag} ${attrs}>${esc(el.textContent)}${el.children.map(x=>serialize(x,theme)).join('')}</${el.tag}>`;
}
function text(el){return el.textContent+el.children.map(text).join(' ');}
const elements={};
for(const key of ['case','step','view','plot','step-label','stats','status','rows'])elements[key]=new Element(key==='plot'?'svg':'div');
elements.view.value='region';
const root=new Element('div');
root.querySelector=s=>{const m=s.match(/data-q1="([^"]+)"/);assert(m&&elements[m[1]],'missing selector '+s);return elements[m[1]];};
const document={getElementById:id=>{assert.equal(id,'q1-localization');return root;},createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)};
let width=736;
vm.runInNewContext(script,{document,Option:function(label,value){this.label=label;this.value=String(value);},ResizeObserver:class{constructor(f){this.f=f;}observe(){}},Number,Math,String,Object,console},{timeout:5000});
const output=path.join(rootDir,'output/q1/figures');fs.mkdirSync(output,{recursive:true});
let checks=0;
for(let i=0;i<elements.case.children.length;i++) {
elements.case.value=String(i);elements.case.events.change();
for(let n=1;n<=+elements.step.max;n++) {
elements.step.value=String(n);elements.step.events.input();
assert.equal(elements['step-label'].textContent,`${n} / ${elements.step.max}`);
for(const v of ['global','region']) {
elements.view.value=v;elements.view.events.change();
const svg=serialize(elements.plot,'light');
assert(!/NaN|undefined|Infinity/.test(svg));
assert(svg.includes('data-axis="x"')&&svg.includes('data-axis="y"'));
checks++;
}
}
}
for(const w of [360,736])for(const theme of ['light','dark'])for(const index of [1,elements.case.children.length-1]) {
width=w;elements.case.value=String(index);elements.case.events.change();
elements.view.value='region';elements.view.events.change();
const height=elements.plot.attrs.viewBox.split(' ')[3];
const svg=serialize(elements.plot,theme).replace('<svg ',`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" `);
const filename=(index===1?'counterexample':'practice')+`-${w}-${theme}.svg`;
fs.writeFileSync(path.join(output,filename),svg,'utf8');
}
const report={interaction_states_checked:checks,missing_selectors:0,nonfinite_svg_values:0,browser_layout_verified:false,
note:'Browser local-file navigation blocked by browser URL policy. Event handlers checked in minimal DOM; SVG statically inspected separately.'};
fs.writeFileSync(path.join(rootDir,'output/q1/visualization_checks.json'),JSON.stringify(report,null,2));
console.log(report);