合并完成
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"""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
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from geometry import Measurement, solve, smallest_enclosing_circle, first_bearing_candidate_disk
|
||||
from geometry.localization import _enclosing_circle_reference
|
||||
|
||||
|
||||
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()
|
||||
@@ -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
|
||||
import sys
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT))
|
||||
from geometry import Measurement, solve
|
||||
|
||||
|
||||
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=(ROOT/'visualization/q1.template.html').read_text(encoding='utf-8')
|
||||
fragment=template.replace('/*__Q1_DATA__*/',json.dumps(dataset,ensure_ascii=False,allow_nan=False))
|
||||
(ROOT/'visualization/question1.html').write_text(fragment,encoding='utf-8')
|
||||
# A standalone companion uses the same fragment and computation results.
|
||||
theme=(ROOT/'visualization/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()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""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 sys
|
||||
import time
|
||||
import uuid
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from geometry 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()
|
||||
@@ -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))
|
||||
@@ -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,'visualization/question1.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,'&').replace(/</g,'<').replace(/"/g,'"');
|
||||
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);
|
||||
@@ -0,0 +1,131 @@
|
||||
import math
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from geometry import Measurement, solve, smallest_enclosing_circle
|
||||
from geometry.localization import halfplanes, distance
|
||||
|
||||
|
||||
class GeometryTests(unittest.TestCase):
|
||||
def test_equilateral_from_legal_bearings(self):
|
||||
h = 20 * math.sqrt(3)
|
||||
stations = [Measurement(-1000, 0, 1),
|
||||
Measurement(540, -500 * math.sqrt(3), 121),
|
||||
Measurement(520, h + 500 * math.sqrt(3), 241)]
|
||||
result = solve(stations)
|
||||
self.assertEqual(result['status'], 'polygon')
|
||||
self.assertEqual(len(result['vertices']), 3)
|
||||
self.assertAlmostEqual(result['diameter_m'], 40, places=7)
|
||||
self.assertAlmostEqual(result['minimum_enclosing_circle']['radius_m'], 40/math.sqrt(3), places=7)
|
||||
self.assertFalse(result['diameter_circle']['covers'])
|
||||
self.assertFalse(result['guaranteed_clearable_at_mec_center'])
|
||||
|
||||
def test_no_observations_and_single_bearing(self):
|
||||
self.assertEqual(solve([])['status'], 'unbounded')
|
||||
r = solve([Measurement(20, 30, 359.8)])
|
||||
self.assertEqual(r['status'], 'unbounded')
|
||||
self.assertTrue(r['diameter_infinite'])
|
||||
self.assertIsNone(r['diameter_m'])
|
||||
|
||||
def test_inconsistent(self):
|
||||
result = solve([Measurement(-100, 0, 180), Measurement(100, 0, 0)])
|
||||
self.assertEqual(result['status'], 'empty')
|
||||
|
||||
def test_exact_segment_and_point(self):
|
||||
segment = [Measurement(0, 0, 0, 0), Measurement(100, 0, 180, 0)]
|
||||
r = solve(segment)
|
||||
self.assertEqual(r['status'], 'segment')
|
||||
self.assertAlmostEqual(r['diameter_m'], 100)
|
||||
self.assertTrue(r['diameter_circle']['covers'])
|
||||
r = solve(segment + [Measurement(30, -30, 90, 0)])
|
||||
self.assertEqual(r['status'], 'point')
|
||||
self.assertAlmostEqual(r['vertices'][0][0], 30)
|
||||
self.assertAlmostEqual(r['diameter_m'], 0)
|
||||
|
||||
def test_forward_ray_not_line(self):
|
||||
r = solve([Measurement(0, 0, 0, 0), Measurement(-1, 0, 180, 0)])
|
||||
self.assertEqual(r['status'], 'empty')
|
||||
|
||||
def test_angle_wrap_and_repeated_station(self):
|
||||
a = [Measurement(-500, -1, 359.9), Measurement(0, -500, 90)]
|
||||
b = [Measurement(-500, -1, -.1), Measurement(0, -500, 450)]
|
||||
self.assertAlmostEqual(solve(a)['diameter_m'], solve(b)['diameter_m'])
|
||||
self.assertAlmostEqual(solve(a+a)['diameter_m'], solve(a)['diameter_m'])
|
||||
|
||||
def test_close_parallel_remains_finite(self):
|
||||
# Two nearly parallel wedges can still be bounded, very far away.
|
||||
r = solve([Measurement(0, 0, 1), Measurement(0, 100, -1.000001)])
|
||||
self.assertEqual(r['status'], 'polygon')
|
||||
self.assertGreater(r['diameter_m'], 1e9)
|
||||
|
||||
def test_circle_known_shapes(self):
|
||||
c, r = smallest_enclosing_circle([(0,0),(2,0),(2,2),(0,2)])
|
||||
self.assertEqual(c, (1,1))
|
||||
self.assertAlmostEqual(r, math.sqrt(2))
|
||||
c, r = smallest_enclosing_circle([(0,0),(2,0),(.4,.2)])
|
||||
self.assertAlmostEqual(r, 1)
|
||||
c, r = smallest_enclosing_circle([(0,0),(1,0),(3,0)])
|
||||
self.assertAlmostEqual(r, 1.5)
|
||||
|
||||
def test_random_bounded_truth_containment_and_monotonicity(self):
|
||||
rng = random.Random(20260910)
|
||||
for _ in range(100):
|
||||
target = (rng.uniform(-300,300), rng.uniform(-300,300))
|
||||
ms = []
|
||||
for i in range(4):
|
||||
ang = (i*90 + rng.uniform(-20,20))*math.pi/180
|
||||
radius = rng.uniform(300,1200)
|
||||
p = (target[0]+radius*math.cos(ang), target[1]+radius*math.sin(ang))
|
||||
bearing = math.degrees(math.atan2(target[1]-p[1],target[0]-p[0]))
|
||||
ms.append(Measurement(*p, bearing+rng.uniform(-.99,.99)))
|
||||
r3, r4 = solve(ms[:3]), solve(ms)
|
||||
self.assertEqual(r4['status'], 'polygon')
|
||||
self.assertLessEqual(r4['diameter_m'], r3['diameter_m']+1e-5)
|
||||
for m in ms:
|
||||
for a,b,c in halfplanes(m):
|
||||
self.assertLessEqual(a*target[0]+b*target[1], c+1e-7)
|
||||
mec = r4['minimum_enclosing_circle']
|
||||
self.assertLessEqual(distance(mec['center'], target), mec['radius_m']+1e-5)
|
||||
for v in r4['vertices']:
|
||||
self.assertLessEqual(distance(mec['center'], v), mec['radius_m']+1e-5)
|
||||
|
||||
def test_translation_rotation_scaling(self):
|
||||
ms = [Measurement(-500,0,1), Measurement(0,-500,89), Measurement(400,400,225)]
|
||||
original = solve(ms)
|
||||
a=.37
|
||||
changed = [Measurement(1e6+3*(m.x*math.cos(a)-m.y*math.sin(a)),
|
||||
-2e6+3*(m.x*math.sin(a)+m.y*math.cos(a)),
|
||||
m.bearing_deg+math.degrees(a)) for m in ms]
|
||||
moved=solve(changed)
|
||||
self.assertAlmostEqual(moved['diameter_m'], original['diameter_m']*3, places=6)
|
||||
|
||||
def test_invalid_input(self):
|
||||
for args in [(float('nan'),0,0),(0,0,float('inf')),(0,0,0,90),(0,0,0,-1),(True,0,0)]:
|
||||
with self.assertRaises(ValueError): Measurement(*args)
|
||||
|
||||
def test_independent_rectangle_clipping_reference(self):
|
||||
# A second construction: clip a demonstrably inactive finite box.
|
||||
# The production algorithm never uses this box.
|
||||
rng=random.Random(4001)
|
||||
for _ in range(60):
|
||||
ms=[]
|
||||
for x,y in [(-500,-300),(600,-200),(0,700)]:
|
||||
ms.append(Measurement(x,y,math.degrees(math.atan2(-y,-x))+rng.uniform(-.9,.9)))
|
||||
poly=[(-2000.,-2000.),(2000.,-2000.),(2000.,2000.),(-2000.,2000.)]
|
||||
for m in ms:
|
||||
for a,b,c in halfplanes(m):
|
||||
out=[]
|
||||
for p,q in zip(poly,poly[1:]+poly[:1]):
|
||||
f=a*p[0]+b*p[1]-c;g=a*q[0]+b*q[1]-c
|
||||
if f<=0:out.append(p)
|
||||
if f*g<0:
|
||||
t=f/(f-g);out.append((p[0]+t*(q[0]-p[0]),p[1]+t*(q[1]-p[1])))
|
||||
poly=out
|
||||
self.assertTrue(poly)
|
||||
self.assertTrue(all(max(abs(x),abs(y))<1900 for x,y in poly))
|
||||
expected=max(math.dist(p,q) for p in poly for q in poly)
|
||||
self.assertAlmostEqual(solve(ms)['diameter_m'],expected,places=6)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from geometry import Measurement, solve, smallest_enclosing_circle, first_bearing_candidate_disk
|
||||
from geometry.localization import _enclosing_circle_reference, distance
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class CertificateTests(unittest.TestCase):
|
||||
def test_incremental_circle_against_exhaustive_reference(self):
|
||||
rng = random.Random(271828)
|
||||
for _ in range(250):
|
||||
points = [(rng.uniform(-20, 20), rng.uniform(-20, 20))
|
||||
for _ in range(rng.randrange(1, 20))]
|
||||
expected_c, expected_r = _enclosing_circle_reference(points)
|
||||
center, radius = smallest_enclosing_circle(points)
|
||||
self.assertAlmostEqual(radius, expected_r, places=7)
|
||||
self.assertLess(distance(center, expected_c), 1e-6)
|
||||
self.assertTrue(all(distance(center, p) <= radius for p in points))
|
||||
|
||||
def test_circle_large_cocircular_duplicate_and_collinear_sets(self):
|
||||
# More than Python's recursion limit, with analytically known answers.
|
||||
points = [(1e6+30*math.cos(i*math.tau/1500),
|
||||
-2e6+30*math.sin(i*math.tau/1500)) for i in range(1500)]
|
||||
center, radius = smallest_enclosing_circle(points+points[:100])
|
||||
self.assertLess(distance(center, (1e6, -2e6)), 1e-7)
|
||||
self.assertAlmostEqual(radius, 30, places=7)
|
||||
c, r = smallest_enclosing_circle([(i, i*2) for i in range(1500)])
|
||||
self.assertAlmostEqual(r, 1499*math.sqrt(5)/2)
|
||||
self.assertEqual(c, (749.5, 1499))
|
||||
self.assertEqual(smallest_enclosing_circle([(3, 4)]*10), ((3, 4), 0))
|
||||
|
||||
def test_witness_and_clearance_margin_from_saved_observations(self):
|
||||
path = next((ROOT/'output/q1/practice').glob('*_observations.json'))
|
||||
rows = json.loads(path.read_text(encoding='utf-8'))['measurements']
|
||||
result = solve([Measurement(**m) for m in rows])
|
||||
circle = result['diameter_circle']
|
||||
self.assertFalse(circle['covers'])
|
||||
self.assertIn(circle['witness_vertex'], result['vertices'])
|
||||
self.assertAlmostEqual(distance(circle['witness_vertex'], circle['center'])-circle['radius_m'],
|
||||
circle['max_excess_m'])
|
||||
for i in circle['outside_vertex_indices']:
|
||||
self.assertGreater(distance(result['vertices'][i], circle['center']),
|
||||
circle['radius_m']+circle['coverage_tolerance_m'])
|
||||
cert = result['clearance_certificate']
|
||||
self.assertGreater(cert['remaining_margin_m'], 7.9)
|
||||
self.assertTrue(result['guaranteed_clearable_at_mec_center'])
|
||||
self.assertAlmostEqual(cert['remaining_margin_m']+cert['radius_with_margin_m'], 20)
|
||||
|
||||
def test_clearance_at_threshold_is_conservative(self):
|
||||
for length, expected in [(39.999, True), (40, False), (40.001, False)]:
|
||||
r = solve([Measurement(0, 0, 0, 0), Measurement(length, 0, 180, 0)])
|
||||
self.assertEqual(r['guaranteed_clearable_at_mec_center'], expected)
|
||||
self.assertTrue(r['diameter_circle']['covers'])
|
||||
self.assertIsNone(r['diameter_circle']['witness_vertex'])
|
||||
|
||||
def test_first_sector_disk_covers_all_angles_and_distances(self):
|
||||
for angle in [0, 359.9, -180]:
|
||||
for delta in [0, 1, 1.005, 45]:
|
||||
m = Measurement(-800, 1200, angle, delta)
|
||||
cert = first_bearing_candidate_disk(m)
|
||||
enclosing = cert['sector_enclosing_circle']
|
||||
candidate = cert['sufficient_candidate_disk']
|
||||
for j in range(21):
|
||||
for k in range(11):
|
||||
phi = math.radians(angle-delta+2*delta*j/20)
|
||||
p = (m.x+150*k*math.cos(phi), m.y+150*k*math.sin(phi))
|
||||
self.assertLessEqual(distance(p, enclosing['center']), enclosing['radius_m']+1e-9)
|
||||
if candidate:
|
||||
for t in range(8):
|
||||
q = (candidate['center'][0]+candidate['radius_m']*math.cos(t*math.tau/8),
|
||||
candidate['center'][1]+candidate['radius_m']*math.sin(t*math.tau/8))
|
||||
self.assertLessEqual(distance(p, q), 1000+1e-9)
|
||||
if delta <= 1.005:
|
||||
self.assertTrue(cert['candidate_nonempty_certified'])
|
||||
self.assertGreater(candidate['radius_m'], 249.88)
|
||||
|
||||
def test_candidate_equality_and_no_certificate(self):
|
||||
equality = first_bearing_candidate_disk(Measurement(0, 0, 0, 0), max_range_m=2000)
|
||||
self.assertTrue(equality['candidate_nonempty_certified'])
|
||||
self.assertEqual(equality['sufficient_candidate_disk']['radius_m'], 0)
|
||||
no_certificate = first_bearing_candidate_disk(Measurement(0, 0, 0), max_range_m=3000)
|
||||
self.assertIsNone(no_certificate['sufficient_candidate_disk'])
|
||||
self.assertNotIn('candidate_empty', no_certificate)
|
||||
self.assertTrue(no_certificate['distance_only'])
|
||||
with self.assertRaises(ValueError):
|
||||
first_bearing_candidate_disk(Measurement(0, 0, 0, 46))
|
||||
|
||||
def test_cli_explicit_rounding_margin(self):
|
||||
base = [sys.executable, '-m', 'geometry', 'examples/q1/triangle.json']
|
||||
outputs = [json.loads(subprocess.check_output(base+args, cwd=ROOT, encoding='utf-8'))
|
||||
for args in [[], ['--rounding-margin-deg', '0.005']]]
|
||||
self.assertEqual(outputs[0]['error_policy']['effective_half_angles_deg'], [1, 1, 1])
|
||||
self.assertEqual(outputs[1]['error_policy']['effective_half_angles_deg'], [1.005]*3)
|
||||
self.assertGreater(outputs[1]['diameter_m'], outputs[0]['diameter_m'])
|
||||
bad = subprocess.run(base+['--rounding-margin-deg', '-1'], cwd=ROOT, capture_output=True)
|
||||
self.assertNotEqual(bad.returncode, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Offline verification of captured practice evidence; never contacts simulator."""
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from geometry import Measurement, solve
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class PracticeReplayTests(unittest.TestCase):
|
||||
def test_action_log_and_derived_geometry(self):
|
||||
folder=ROOT/'output/q1/practice'
|
||||
files=list(folder.glob('*_actions.json'))
|
||||
self.assertTrue(files, '缺少本次演练采集证据')
|
||||
for path in files:
|
||||
events=json.loads(path.read_text(encoding='utf-8'))
|
||||
pos=(0,0);channel=1;virtual=0
|
||||
self.assertEqual(events[0]['path'],'/enter')
|
||||
self.assertEqual(events[-1]['path'],'/exit')
|
||||
self.assertEqual(events[-1]['response']['exit_reason'],'user_exit')
|
||||
ids=[]
|
||||
for event in events:
|
||||
self.assertIn(event['path'],('/enter','/measure','/exit'))
|
||||
request,response=event['request'],event['response']
|
||||
self.assertTrue(response['accepted'])
|
||||
ids.append(request['request_id'])
|
||||
if event['path']=='/measure':
|
||||
new=(request['position']['x'],request['position']['y'])
|
||||
virtual+=math.dist(pos,new)/5+5+(request['channel']!=channel)
|
||||
pos,channel=new,request['channel']
|
||||
self.assertAlmostEqual(virtual,response['virtual_time_s'],places=5)
|
||||
self.assertEqual(len(ids),len(set(ids)))
|
||||
observation_path=path.with_name(path.name.replace('_actions','_observations'))
|
||||
data=json.loads(observation_path.read_text(encoding='utf-8'))
|
||||
ms=[Measurement(**m) for m in data['measurements']]
|
||||
recomputed=solve(ms)
|
||||
self.assertAlmostEqual(recomputed['diameter_m'],data['result']['diameter_m'],places=9)
|
||||
self.assertEqual(data['source'],'official_practice')
|
||||
self.assertIsNone(data['ground_truth'])
|
||||
directions=[e for e in events if e['response'].get('measure_result')=='direction']
|
||||
self.assertEqual(directions[0]['request']['position'],directions[1]['request']['position'])
|
||||
self.assertEqual(directions[0]['response']['svd_deg'],directions[1]['response']['svd_deg'])
|
||||
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -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>
|
||||
File diff suppressed because one or more lines are too long
@@ -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;}}
|
||||
Reference in New Issue
Block a user