gitcad 0.7.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitcad/bench/__init__.py +0 -0
- gitcad/bench/corpus.py +260 -0
- gitcad/bench/scorecard.py +176 -0
- gitcad/bridge.py +72 -0
- gitcad/convert.py +30 -0
- gitcad/explore.py +99 -0
- gitcad/fasteners.py +112 -0
- gitcad/init.py +134 -0
- gitcad/lots.py +136 -0
- gitcad/mcp/__init__.py +15 -0
- gitcad/mcp/server.py +1065 -0
- gitcad/merge3.py +289 -0
- gitcad/pcba.py +114 -0
- gitcad/release.py +223 -0
- gitcad/render.py +177 -0
- gitcad/requirements.py +204 -0
- gitcad/review.py +230 -0
- gitcad/viewer/__init__.py +19 -0
- gitcad/viewer/boardsvg.py +80 -0
- gitcad/viewer/page.py +577 -0
- gitcad/viewer/server.py +433 -0
- gitcad-0.7.7.dist-info/METADATA +70 -0
- gitcad-0.7.7.dist-info/RECORD +25 -0
- gitcad-0.7.7.dist-info/WHEEL +4 -0
- gitcad-0.7.7.dist-info/entry_points.txt +11 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Board → SVG: a 2D rendering of the board model (viewer + docs + diffs).
|
|
2
|
+
|
|
3
|
+
KiCad-conventional colors on the site's dark palette. Sheet coordinates are
|
|
4
|
+
y-up; SVG is y-down — flipped once via a group transform. Deterministic
|
|
5
|
+
output (canonical float formatting), so board renders diff cleanly too.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from xml.sax.saxutils import escape
|
|
11
|
+
|
|
12
|
+
from gitcad.ecad.board import Board
|
|
13
|
+
|
|
14
|
+
_C = {
|
|
15
|
+
"bg": "#0d1117", "outline": "#8b949e", "board": "#0f2418",
|
|
16
|
+
"top": "#c74e39", "bottom": "#3d7dca", "pad": "#c9a227",
|
|
17
|
+
"via": "#9aa3b2", "hole": "#0d1117", "silk": "#c9d1d9",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _f(v: float) -> str:
|
|
22
|
+
return f"{v:.3f}".rstrip("0").rstrip(".")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def board_to_svg(board: Board, *, margin: float = 3.0) -> str:
|
|
26
|
+
minx, miny, maxx, maxy = board.bbox()
|
|
27
|
+
w, h = maxx - minx + 2 * margin, maxy - miny + 2 * margin
|
|
28
|
+
out: list[str] = []
|
|
29
|
+
out.append(
|
|
30
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_f(w)} {_f(h)}" '
|
|
31
|
+
f'width="{_f(w)}mm" height="{_f(h)}mm" style="background:{_C["bg"]}">'
|
|
32
|
+
)
|
|
33
|
+
# One flip: board y-up -> svg y-down.
|
|
34
|
+
out.append(f'<g transform="translate({_f(margin - minx)},{_f(maxy + margin)}) scale(1,-1)">')
|
|
35
|
+
|
|
36
|
+
pts = " ".join(f"{_f(x)},{_f(y)}" for x, y in board.outline)
|
|
37
|
+
out.append(f'<polygon points="{pts}" fill="{_C["board"]}" '
|
|
38
|
+
f'stroke="{_C["outline"]}" stroke-width="0.15"/>')
|
|
39
|
+
|
|
40
|
+
def track_lines(layer: str, color: str, opacity: str) -> None:
|
|
41
|
+
for t in board.tracks:
|
|
42
|
+
if t.layer == layer:
|
|
43
|
+
out.append(f'<line x1="{_f(t.x1)}" y1="{_f(t.y1)}" x2="{_f(t.x2)}" y2="{_f(t.y2)}" '
|
|
44
|
+
f'stroke="{color}" stroke-width="{_f(t.width)}" '
|
|
45
|
+
f'stroke-linecap="round" opacity="{opacity}"/>')
|
|
46
|
+
|
|
47
|
+
track_lines("bottom", _C["bottom"], "0.8")
|
|
48
|
+
track_lines("top", _C["top"], "0.9")
|
|
49
|
+
|
|
50
|
+
for comp in board.components:
|
|
51
|
+
for pad, bx, by, rot in comp.placed_pads():
|
|
52
|
+
w_, h_ = (pad.h, pad.w) if round(rot) % 180 == 90 else (pad.w, pad.h)
|
|
53
|
+
if pad.shape == "circle":
|
|
54
|
+
out.append(f'<circle cx="{_f(bx)}" cy="{_f(by)}" r="{_f(max(w_, h_) / 2)}" '
|
|
55
|
+
f'fill="{_C["pad"]}"/>')
|
|
56
|
+
else:
|
|
57
|
+
rx = ' rx="0.2"' if pad.shape == "obround" else ""
|
|
58
|
+
out.append(f'<rect x="{_f(bx - w_ / 2)}" y="{_f(by - h_ / 2)}" '
|
|
59
|
+
f'width="{_f(w_)}" height="{_f(h_)}" fill="{_C["pad"]}"{rx}/>')
|
|
60
|
+
if pad.drill is not None:
|
|
61
|
+
out.append(f'<circle cx="{_f(bx)}" cy="{_f(by)}" r="{_f(pad.drill / 2)}" '
|
|
62
|
+
f'fill="{_C["hole"]}"/>')
|
|
63
|
+
|
|
64
|
+
for v in board.vias:
|
|
65
|
+
out.append(f'<circle cx="{_f(v.x)}" cy="{_f(v.y)}" r="{_f(v.diameter / 2)}" fill="{_C["via"]}"/>')
|
|
66
|
+
out.append(f'<circle cx="{_f(v.x)}" cy="{_f(v.y)}" r="{_f(v.drill / 2)}" fill="{_C["hole"]}"/>')
|
|
67
|
+
|
|
68
|
+
for m in board.mounting_holes:
|
|
69
|
+
out.append(f'<circle cx="{_f(m.x)}" cy="{_f(m.y)}" r="{_f(m.drill / 2)}" '
|
|
70
|
+
f'fill="{_C["hole"]}" stroke="{_C["outline"]}" stroke-width="0.1"/>')
|
|
71
|
+
|
|
72
|
+
# Reference designators (counter-flipped so text reads upright).
|
|
73
|
+
for comp in board.components:
|
|
74
|
+
cy = comp.y + 1.4
|
|
75
|
+
out.append(f'<text x="{_f(comp.x)}" y="{_f(-cy)}" transform="scale(1,-1)" '
|
|
76
|
+
f'fill="{_C["silk"]}" font-family="monospace" font-size="1.1" '
|
|
77
|
+
f'text-anchor="middle">{escape(comp.ref)}</text>')
|
|
78
|
+
|
|
79
|
+
out.append("</g></svg>")
|
|
80
|
+
return "".join(out) + "\n"
|
gitcad/viewer/page.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""The viewer page — one self-contained HTML string, zero external assets.
|
|
2
|
+
|
|
3
|
+
WebGL2 with flat shading via fragment-shader derivatives, orbit/zoom, live
|
|
4
|
+
reload by content-hash polling. Design review additions: a Schematics tab
|
|
5
|
+
(the electrical sheets underlying a 3D assembly, served by /api/schematics)
|
|
6
|
+
and a measure tool — raycast picking with vertex snap, two picks give
|
|
7
|
+
distance + per-axis deltas. Monospace dark aesthetic to match gitcad.xyz.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
PAGE = r"""<!DOCTYPE html>
|
|
11
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
13
|
+
<title>gitcad viewer</title>
|
|
14
|
+
<style>
|
|
15
|
+
:root{--bg:#0d1117;--ink:#c9d1d9;--dim:#8b949e;--acc:#58a6ff;--line:#21262d}
|
|
16
|
+
*{margin:0;box-sizing:border-box}
|
|
17
|
+
body{background:var(--bg);color:var(--ink);height:100vh;overflow:hidden;
|
|
18
|
+
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
19
|
+
#gl,#board{position:fixed;inset:0;width:100%;height:100%}
|
|
20
|
+
#board{display:none;align-items:center;justify-content:center;padding:24px}
|
|
21
|
+
#board svg{max-width:100%;max-height:100%}
|
|
22
|
+
#sheets{position:fixed;inset:0;display:none;overflow:auto;padding:44px 24px 24px}
|
|
23
|
+
.sheet-card{background:#fff;border-radius:4px;margin:0 auto 18px;max-width:1200px;padding:10px}
|
|
24
|
+
.sheet-card svg{display:block;width:100%;height:auto}
|
|
25
|
+
.sheet-name{color:var(--dim);margin:0 auto 4px;max-width:1200px}
|
|
26
|
+
.sheet-err{color:#f85149;margin:0 auto 18px;max-width:1200px}
|
|
27
|
+
#tabs{position:fixed;left:12px;top:10px;display:flex;gap:8px;z-index:5}
|
|
28
|
+
.tab{color:var(--dim);cursor:pointer;padding:2px 10px;border:1px solid var(--line);
|
|
29
|
+
border-radius:4px;background:rgba(13,17,23,.8);user-select:none}
|
|
30
|
+
.tab.on{color:var(--acc);border-color:var(--acc)}
|
|
31
|
+
.tab.tool.on{color:#3fb950;border-color:#3fb950}
|
|
32
|
+
#hud{position:fixed;left:12px;bottom:10px;color:var(--dim);pointer-events:none;white-space:pre}
|
|
33
|
+
#hud b{color:var(--ink)}
|
|
34
|
+
#measure{position:fixed;right:12px;bottom:10px;color:#3fb950;pointer-events:none;
|
|
35
|
+
white-space:pre;text-align:right}
|
|
36
|
+
#err{position:fixed;top:38px;left:12px;color:#f85149;white-space:pre-wrap}
|
|
37
|
+
#logo{position:fixed;right:12px;top:10px;color:var(--acc);font-weight:700}
|
|
38
|
+
#sel{position:fixed;left:12px;bottom:64px;color:#d29922;pointer-events:none;white-space:pre}
|
|
39
|
+
#checks{position:fixed;inset:0;display:none;overflow:auto;padding:44px 24px 24px;
|
|
40
|
+
font-size:13px}
|
|
41
|
+
.chk{max-width:900px;margin:0 auto 14px;border:1px solid var(--line);
|
|
42
|
+
border-radius:6px;padding:10px 14px}
|
|
43
|
+
.chk h3{margin:0 0 6px;font-size:13px}
|
|
44
|
+
.chk .ok{color:#3fb950} .chk .bad{color:#f85149}
|
|
45
|
+
.chk li{color:#f85149;margin-left:1.2em;list-style:disc}
|
|
46
|
+
#review{position:fixed;inset:0;display:none;overflow:auto;padding:44px 24px 24px;
|
|
47
|
+
font-size:13px}
|
|
48
|
+
.rev{max-width:1100px;margin:0 auto 16px;border:1px solid var(--line);
|
|
49
|
+
border-radius:6px;padding:10px 14px}
|
|
50
|
+
.rev h3{margin:0 0 6px;font-size:13px} .rev small{color:var(--dim)}
|
|
51
|
+
.rev .bad{color:#f85149} .rev .good{color:#3fb950}
|
|
52
|
+
.rev .sxs{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
|
|
53
|
+
.rev .pane{flex:1;min-width:300px;background:#fff;border-radius:4px;padding:6px}
|
|
54
|
+
.rev .pane h4{color:#57606a;margin:0 0 4px;font-size:11px}
|
|
55
|
+
.rev .pane svg{max-width:100%;height:auto;display:block}
|
|
56
|
+
#explodebox{position:fixed;right:12px;top:40px;display:none;align-items:center;
|
|
57
|
+
gap:8px;color:var(--dim);z-index:5}
|
|
58
|
+
#explodebox input{width:140px;accent-color:var(--acc)}
|
|
59
|
+
</style></head><body>
|
|
60
|
+
<canvas id="gl"></canvas><div id="board"></div><div id="sheets"></div>
|
|
61
|
+
<div id="checks"></div><div id="review"></div>
|
|
62
|
+
<div id="tabs"></div>
|
|
63
|
+
<div id="explodebox"><span>explode</span>
|
|
64
|
+
<input id="explodeslider" type="range" min="0" max="100" value="0"></div>
|
|
65
|
+
<div id="hud"></div><div id="sel"></div><div id="measure"></div>
|
|
66
|
+
<div id="err"></div><div id="logo">gitcad</div>
|
|
67
|
+
<script>
|
|
68
|
+
"use strict";
|
|
69
|
+
const canvas = document.getElementById("gl");
|
|
70
|
+
const gl = canvas.getContext("webgl2", {antialias: true});
|
|
71
|
+
const hud = document.getElementById("hud"), err = document.getElementById("err");
|
|
72
|
+
const measureHud = document.getElementById("measure");
|
|
73
|
+
|
|
74
|
+
const VS = `#version 300 es
|
|
75
|
+
in vec3 pos; in vec3 col; uniform mat4 mvp; out vec3 vPos; out vec3 vCol;
|
|
76
|
+
void main(){ vPos = pos; vCol = col; gl_Position = mvp * vec4(pos, 1.0);
|
|
77
|
+
gl_PointSize = 9.0; }`;
|
|
78
|
+
const FS = `#version 300 es
|
|
79
|
+
precision highp float; in vec3 vPos; in vec3 vCol; out vec4 color;
|
|
80
|
+
uniform bool flat_col; uniform bool zebra;
|
|
81
|
+
void main(){
|
|
82
|
+
if(flat_col){ color = vec4(vCol, 1.0); return; }
|
|
83
|
+
vec3 n = normalize(cross(dFdx(vPos), dFdy(vPos)));
|
|
84
|
+
if(zebra){
|
|
85
|
+
// surface-quality inspection: iso-normal bands (Gauss-map stripes).
|
|
86
|
+
// Smooth surfaces show smoothly flowing stripes; tangency breaks and
|
|
87
|
+
// creases show as stripe kinks. Per-facet at tessellation density.
|
|
88
|
+
float s = fract(6.0 * dot(n, normalize(vec3(1.0, 0.7, 0.4))));
|
|
89
|
+
float band = s < 0.5 ? 0.2 : 1.0;
|
|
90
|
+
color = vec4(vec3(0.85, 0.9, 1.0) * band, 1.0); return;
|
|
91
|
+
}
|
|
92
|
+
float l = 0.25 + 0.65 * max(dot(n, normalize(vec3(0.5, 0.4, 0.8))), 0.0)
|
|
93
|
+
+ 0.18 * max(dot(n, normalize(vec3(-0.6, -0.3, 0.2))), 0.0);
|
|
94
|
+
color = vec4(vCol * l, 1.0);
|
|
95
|
+
}`;
|
|
96
|
+
|
|
97
|
+
function compile(type, src){
|
|
98
|
+
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
|
|
99
|
+
if(!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw gl.getShaderInfoLog(s);
|
|
100
|
+
return s;
|
|
101
|
+
}
|
|
102
|
+
const prog = gl.createProgram();
|
|
103
|
+
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
|
|
104
|
+
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
|
|
105
|
+
gl.bindAttribLocation(prog, 0, "pos"); gl.bindAttribLocation(prog, 1, "col");
|
|
106
|
+
gl.linkProgram(prog); gl.useProgram(prog);
|
|
107
|
+
const uMvp = gl.getUniformLocation(prog, "mvp");
|
|
108
|
+
const uFlat = gl.getUniformLocation(prog, "flat_col");
|
|
109
|
+
const uZebra = gl.getUniformLocation(prog, "zebra");
|
|
110
|
+
gl.enable(gl.DEPTH_TEST);
|
|
111
|
+
let zebraOn = location.hash.includes("zebra");
|
|
112
|
+
gl.uniform1i(uZebra, zebraOn ? 1 : 0);
|
|
113
|
+
|
|
114
|
+
let nIndices = 0, center = [0,0,0], radius = 50;
|
|
115
|
+
let yaw = 0.7, pitch = 0.5, dist = 3; // dist in units of radius
|
|
116
|
+
let meshPos = null, meshIdx = null; // LIVE positions (explode applied)
|
|
117
|
+
let basePos = null, baseCols = null; // as-designed positions/colors
|
|
118
|
+
let groups = []; // {name, part, i0, i1, v0, v1, centroid}
|
|
119
|
+
let selected = -1, explode = 0;
|
|
120
|
+
let vb = null, cb = null;
|
|
121
|
+
const vao = gl.createVertexArray();
|
|
122
|
+
|
|
123
|
+
function upload(mesh){
|
|
124
|
+
gl.bindVertexArray(vao);
|
|
125
|
+
vb = gl.createBuffer();
|
|
126
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, vb);
|
|
127
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(mesh.positions), gl.STATIC_DRAW);
|
|
128
|
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
|
|
129
|
+
const nVerts = mesh.positions.length / 3;
|
|
130
|
+
let cols = mesh.colors;
|
|
131
|
+
if(!cols || cols.length !== mesh.positions.length){
|
|
132
|
+
cols = new Array(mesh.positions.length);
|
|
133
|
+
for(let i = 0; i < nVerts; i++){ cols[3*i] = 0.35; cols[3*i+1] = 0.62; cols[3*i+2] = 0.85; }
|
|
134
|
+
}
|
|
135
|
+
cb = gl.createBuffer();
|
|
136
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, cb);
|
|
137
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(cols), gl.STATIC_DRAW);
|
|
138
|
+
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);
|
|
139
|
+
const ib = gl.createBuffer();
|
|
140
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib);
|
|
141
|
+
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(mesh.indices), gl.STATIC_DRAW);
|
|
142
|
+
nIndices = mesh.indices.length;
|
|
143
|
+
basePos = new Float32Array(mesh.positions);
|
|
144
|
+
baseCols = new Float32Array(cols);
|
|
145
|
+
meshPos = new Float32Array(mesh.positions);
|
|
146
|
+
meshIdx = new Uint32Array(mesh.indices);
|
|
147
|
+
// group index/vertex ranges + centroids, for selection and explode
|
|
148
|
+
groups = []; selected = -1;
|
|
149
|
+
let i0 = 0;
|
|
150
|
+
for(const g of (mesh.groups || [])){
|
|
151
|
+
const i1 = i0 + g.triangles * 3;
|
|
152
|
+
let v0 = Infinity, v1 = -1;
|
|
153
|
+
for(let k = i0; k < i1; k++){ const v = meshIdx[k];
|
|
154
|
+
if(v < v0) v0 = v; if(v > v1) v1 = v; }
|
|
155
|
+
let cx = 0, cy = 0, cz = 0, n = Math.max(1, v1 - v0 + 1);
|
|
156
|
+
for(let v = v0; v <= v1; v++){ cx += basePos[3*v]; cy += basePos[3*v+1]; cz += basePos[3*v+2]; }
|
|
157
|
+
groups.push({name: g.name, part: g.part, i0, i1, v0, v1,
|
|
158
|
+
centroid: [cx/n, cy/n, cz/n]});
|
|
159
|
+
i0 = i1;
|
|
160
|
+
}
|
|
161
|
+
const [lo, hi] = mesh.bbox;
|
|
162
|
+
center = [(lo[0]+hi[0])/2, (lo[1]+hi[1])/2, (lo[2]+hi[2])/2];
|
|
163
|
+
radius = Math.max(1e-6, Math.hypot(hi[0]-lo[0], hi[1]-lo[1], hi[2]-lo[2]) / 2);
|
|
164
|
+
const s = mesh.stats;
|
|
165
|
+
const dims = `bbox ${(hi[0]-lo[0]).toFixed(2)} x ${(hi[1]-lo[1]).toFixed(2)} x ${(hi[2]-lo[2]).toFixed(2)} mm`;
|
|
166
|
+
if(mesh.groups){
|
|
167
|
+
const names = mesh.groups.map(g => `${g.name}(${g.part})`).join(" · ");
|
|
168
|
+
hud.textContent = `assembly: ${names}\n${dims}\n${s.triangles} tris · kernel ${s.kernel} · drag orbit · wheel zoom`;
|
|
169
|
+
} else {
|
|
170
|
+
hud.textContent = `${s.features} features · ${s.triangles} tris · vol ${s.volume_mm3} mm³ · ${dims}\n` +
|
|
171
|
+
`kernel ${s.kernel} · drag orbit · wheel zoom`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// -- tiny matrix math ---------------------------------------------------------
|
|
176
|
+
function perspective(fovy, aspect, near, far){
|
|
177
|
+
const f = 1/Math.tan(fovy/2), nf = 1/(near-far);
|
|
178
|
+
return [f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)*nf,-1, 0,0,2*far*near*nf,0];
|
|
179
|
+
}
|
|
180
|
+
function mul(a,b){ const o=new Array(16).fill(0);
|
|
181
|
+
for(let i=0;i<4;i++)for(let j=0;j<4;j++)for(let k=0;k<4;k++)o[j*4+i]+=a[k*4+i]*b[j*4+k];
|
|
182
|
+
return o; }
|
|
183
|
+
function lookAt(eye, at, up){
|
|
184
|
+
const z=norm3(sub3(eye,at)), x=norm3(cross3(up,z)), y=cross3(z,x);
|
|
185
|
+
return [x[0],y[0],z[0],0, x[1],y[1],z[1],0, x[2],y[2],z[2],0,
|
|
186
|
+
-dot3(x,eye),-dot3(y,eye),-dot3(z,eye),1];
|
|
187
|
+
}
|
|
188
|
+
const sub3=(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]];
|
|
189
|
+
const add3=(a,b)=>[a[0]+b[0],a[1]+b[1],a[2]+b[2]];
|
|
190
|
+
const scl3=(a,s)=>[a[0]*s,a[1]*s,a[2]*s];
|
|
191
|
+
const cross3=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
|
|
192
|
+
const dot3=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
|
|
193
|
+
const norm3=a=>{const l=Math.hypot(...a)||1;return[a[0]/l,a[1]/l,a[2]/l];};
|
|
194
|
+
|
|
195
|
+
function eyePos(){
|
|
196
|
+
const d = dist * radius;
|
|
197
|
+
return [center[0] + d*Math.cos(pitch)*Math.cos(yaw),
|
|
198
|
+
center[1] + d*Math.cos(pitch)*Math.sin(yaw),
|
|
199
|
+
center[2] + d*Math.sin(pitch)];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// -- measure tool -------------------------------------------------------------
|
|
203
|
+
let measureMode = false;
|
|
204
|
+
let picks = []; // up to 2 picked [x,y,z]
|
|
205
|
+
const measVao = gl.createVertexArray();
|
|
206
|
+
const measVb = gl.createBuffer(), measCb = gl.createBuffer();
|
|
207
|
+
gl.bindVertexArray(measVao);
|
|
208
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, measVb);
|
|
209
|
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
|
|
210
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, measCb);
|
|
211
|
+
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);
|
|
212
|
+
gl.bindVertexArray(null);
|
|
213
|
+
|
|
214
|
+
function pickRay(px, py){
|
|
215
|
+
// camera basis matching perspective(0.8, aspect) + lookAt(eye, center, +z)
|
|
216
|
+
const w = canvas.clientWidth, h = canvas.clientHeight;
|
|
217
|
+
const eye = eyePos();
|
|
218
|
+
const fwd = norm3(sub3(center, eye));
|
|
219
|
+
const right = norm3(cross3(fwd, [0,0,1]));
|
|
220
|
+
const up = cross3(right, fwd);
|
|
221
|
+
const t = Math.tan(0.8/2);
|
|
222
|
+
const nx = (2*px/w - 1) * t * (w/h), ny = (1 - 2*py/h) * t;
|
|
223
|
+
return {orig: eye, dir: norm3(add3(fwd, add3(scl3(right, nx), scl3(up, ny))))};
|
|
224
|
+
}
|
|
225
|
+
function rayTriangle(o, d, a, b, c){ // Moller-Trumbore; returns t or null
|
|
226
|
+
const e1 = sub3(b,a), e2 = sub3(c,a), p = cross3(d, e2), det = dot3(e1, p);
|
|
227
|
+
if(Math.abs(det) < 1e-12) return null;
|
|
228
|
+
const inv = 1/det, tv = sub3(o, a), u = dot3(tv, p) * inv;
|
|
229
|
+
if(u < 0 || u > 1) return null;
|
|
230
|
+
const q = cross3(tv, e1), v = dot3(d, q) * inv;
|
|
231
|
+
if(v < 0 || u + v > 1) return null;
|
|
232
|
+
const t = dot3(e2, q) * inv;
|
|
233
|
+
return t > 1e-9 ? t : null;
|
|
234
|
+
}
|
|
235
|
+
function raycast(px, py){
|
|
236
|
+
if(!meshPos) return null;
|
|
237
|
+
const {orig, dir} = pickRay(px, py);
|
|
238
|
+
let best = null, bestTri = null, bestI = -1;
|
|
239
|
+
for(let i = 0; i < meshIdx.length; i += 3){
|
|
240
|
+
const a = [meshPos[3*meshIdx[i]], meshPos[3*meshIdx[i]+1], meshPos[3*meshIdx[i]+2]];
|
|
241
|
+
const b = [meshPos[3*meshIdx[i+1]], meshPos[3*meshIdx[i+1]+1], meshPos[3*meshIdx[i+1]+2]];
|
|
242
|
+
const c = [meshPos[3*meshIdx[i+2]], meshPos[3*meshIdx[i+2]+1], meshPos[3*meshIdx[i+2]+2]];
|
|
243
|
+
const t = rayTriangle(orig, dir, a, b, c);
|
|
244
|
+
if(t !== null && (best === null || t < best)){ best = t; bestTri = [a, b, c]; bestI = i; }
|
|
245
|
+
}
|
|
246
|
+
if(best === null) return null;
|
|
247
|
+
return {orig, dir, t: best, tri: bestTri, index: bestI};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// -- selection: click a body, see what it is ---------------------------------
|
|
251
|
+
const selHud = document.getElementById("sel");
|
|
252
|
+
function applySelection(idx){
|
|
253
|
+
selected = idx;
|
|
254
|
+
const cols = new Float32Array(baseCols);
|
|
255
|
+
if(selected >= 0){
|
|
256
|
+
const g = groups[selected];
|
|
257
|
+
for(let v = g.v0; v <= g.v1; v++){
|
|
258
|
+
cols[3*v] = Math.min(1, cols[3*v] * 0.5 + 0.55);
|
|
259
|
+
cols[3*v+1] = Math.min(1, cols[3*v+1] * 0.5 + 0.45);
|
|
260
|
+
cols[3*v+2] = Math.min(1, cols[3*v+2] * 0.3 + 0.1);
|
|
261
|
+
}
|
|
262
|
+
selHud.textContent = `selected: ${g.name} (${g.part})\n` +
|
|
263
|
+
`centroid (${g.centroid.map(v=>v.toFixed(1)).join(", ")}) · ` +
|
|
264
|
+
`${(g.i1 - g.i0) / 3} tris · click again to deselect`;
|
|
265
|
+
} else {
|
|
266
|
+
selHud.textContent = "";
|
|
267
|
+
}
|
|
268
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, cb);
|
|
269
|
+
gl.bufferData(gl.ARRAY_BUFFER, cols, gl.STATIC_DRAW);
|
|
270
|
+
syncSheetHighlight(selected >= 0 ? groups[selected].name : null);
|
|
271
|
+
}
|
|
272
|
+
function selectAt(px, py){
|
|
273
|
+
const hit = raycast(px, py);
|
|
274
|
+
let idx = -1;
|
|
275
|
+
if(hit) idx = groups.findIndex(g => hit.index >= g.i0 && hit.index < g.i1);
|
|
276
|
+
if(idx === selected && hit) idx = -1; // click again to deselect
|
|
277
|
+
applySelection(idx);
|
|
278
|
+
}
|
|
279
|
+
// cross-probe: 3D selection highlights the matching ref on the sheets…
|
|
280
|
+
const probed = [];
|
|
281
|
+
function syncSheetHighlight(name){
|
|
282
|
+
while(probed.length){ const [el, f, w] = probed.pop();
|
|
283
|
+
el.setAttribute("fill", f); el.setAttribute("font-weight", w || ""); }
|
|
284
|
+
if(!name) return;
|
|
285
|
+
for(const t of document.querySelectorAll("#sheets svg text")){
|
|
286
|
+
if(t.textContent.trim() === name){
|
|
287
|
+
probed.push([t, t.getAttribute("fill"), t.getAttribute("font-weight")]);
|
|
288
|
+
t.setAttribute("fill", "#f85149");
|
|
289
|
+
t.setAttribute("font-weight", "bold");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// …and clicking a ref on a sheet selects its body in 3D
|
|
294
|
+
document.getElementById("sheets").addEventListener("click", e => {
|
|
295
|
+
const t = e.target.closest("text");
|
|
296
|
+
if(!t) return;
|
|
297
|
+
const idx = groups.findIndex(g => g.name === t.textContent.trim());
|
|
298
|
+
if(idx < 0) return;
|
|
299
|
+
activeTab = "3d"; showTab(); renderTabs();
|
|
300
|
+
applySelection(idx);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// -- exploded view: a display projection, never a model edit (ADR-0014) ------
|
|
304
|
+
function applyExplode(){
|
|
305
|
+
const out = new Float32Array(basePos);
|
|
306
|
+
if(explode > 0 && groups.length > 1){
|
|
307
|
+
for(const g of groups){
|
|
308
|
+
let d = sub3(g.centroid, center);
|
|
309
|
+
const l = Math.hypot(...d);
|
|
310
|
+
d = l < 1e-6 ? [0, 0, 1] : scl3(d, 1 / l);
|
|
311
|
+
const off = scl3(d, explode * radius * 0.9);
|
|
312
|
+
for(let v = g.v0; v <= g.v1; v++){
|
|
313
|
+
out[3*v] += off[0]; out[3*v+1] += off[1]; out[3*v+2] += off[2];
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
meshPos = out;
|
|
318
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, vb);
|
|
319
|
+
gl.bufferData(gl.ARRAY_BUFFER, out, gl.STATIC_DRAW);
|
|
320
|
+
picks = []; updateMeasure();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function pick(px, py){
|
|
324
|
+
const hit = raycast(px, py);
|
|
325
|
+
if(!hit) return;
|
|
326
|
+
const bestTri = hit.tri;
|
|
327
|
+
let hitp = add3(hit.orig, scl3(hit.dir, hit.t));
|
|
328
|
+
// vertex snap: nearest corner of the hit triangle within 4% of model radius
|
|
329
|
+
let snap = null, snapD = radius * 0.04;
|
|
330
|
+
for(const v of bestTri){
|
|
331
|
+
const dd = Math.hypot(...sub3(v, hitp));
|
|
332
|
+
if(dd < snapD){ snapD = dd; snap = v; }
|
|
333
|
+
}
|
|
334
|
+
if(snap) hitp = snap;
|
|
335
|
+
picks.push(hitp);
|
|
336
|
+
if(picks.length > 2) picks = [hitp];
|
|
337
|
+
updateMeasure();
|
|
338
|
+
}
|
|
339
|
+
function updateMeasure(){
|
|
340
|
+
const pts = [], cols = [];
|
|
341
|
+
for(const p of picks){ pts.push(...p); cols.push(0.25, 0.95, 0.4); }
|
|
342
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, measVb);
|
|
343
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(pts), gl.DYNAMIC_DRAW);
|
|
344
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, measCb);
|
|
345
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(cols), gl.DYNAMIC_DRAW);
|
|
346
|
+
if(picks.length === 2){
|
|
347
|
+
const d = sub3(picks[1], picks[0]);
|
|
348
|
+
measureHud.textContent =
|
|
349
|
+
`dist ${Math.hypot(...d).toFixed(3)} mm\n` +
|
|
350
|
+
`dx ${d[0].toFixed(3)} dy ${d[1].toFixed(3)} dz ${d[2].toFixed(3)}\n` +
|
|
351
|
+
`A (${picks[0].map(v=>v.toFixed(2)).join(", ")})\n` +
|
|
352
|
+
`B (${picks[1].map(v=>v.toFixed(2)).join(", ")})`;
|
|
353
|
+
} else if(picks.length === 1){
|
|
354
|
+
measureHud.textContent = `A (${picks[0].map(v=>v.toFixed(2)).join(", ")})\npick a second point`;
|
|
355
|
+
} else {
|
|
356
|
+
measureHud.textContent = measureMode ? "click a point to measure" : "";
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function draw(){
|
|
361
|
+
const w = canvas.clientWidth, h = canvas.clientHeight;
|
|
362
|
+
if(canvas.width !== w || canvas.height !== h){ canvas.width = w; canvas.height = h; }
|
|
363
|
+
gl.viewport(0, 0, w, h);
|
|
364
|
+
gl.clearColor(0.051, 0.067, 0.09, 1);
|
|
365
|
+
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
366
|
+
if(nIndices){
|
|
367
|
+
const mvp = mul(perspective(0.8, w/h, radius*0.01, radius*40),
|
|
368
|
+
lookAt(eyePos(), center, [0,0,1]));
|
|
369
|
+
gl.uniformMatrix4fv(uMvp, false, new Float32Array(mvp));
|
|
370
|
+
gl.uniform1i(uFlat, 0);
|
|
371
|
+
gl.bindVertexArray(vao);
|
|
372
|
+
gl.drawElements(gl.TRIANGLES, nIndices, gl.UNSIGNED_INT, 0);
|
|
373
|
+
if(picks.length){
|
|
374
|
+
gl.disable(gl.DEPTH_TEST);
|
|
375
|
+
gl.uniform1i(uFlat, 1);
|
|
376
|
+
gl.bindVertexArray(measVao);
|
|
377
|
+
gl.drawArrays(gl.POINTS, 0, picks.length);
|
|
378
|
+
if(picks.length === 2) gl.drawArrays(gl.LINES, 0, 2);
|
|
379
|
+
gl.enable(gl.DEPTH_TEST);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
requestAnimationFrame(draw);
|
|
383
|
+
}
|
|
384
|
+
requestAnimationFrame(draw);
|
|
385
|
+
|
|
386
|
+
let dragging = false, moved = false, px = 0, py = 0;
|
|
387
|
+
canvas.addEventListener("pointerdown", e => {
|
|
388
|
+
dragging = true; moved = false; px = e.clientX; py = e.clientY; });
|
|
389
|
+
addEventListener("pointerup", e => {
|
|
390
|
+
if(dragging && !moved && canvas.style.display !== "none" && e.target === canvas){
|
|
391
|
+
if(measureMode) pick(e.clientX, e.clientY);
|
|
392
|
+
else if(groups.length) selectAt(e.clientX, e.clientY);
|
|
393
|
+
}
|
|
394
|
+
dragging = false;
|
|
395
|
+
});
|
|
396
|
+
addEventListener("pointermove", e => {
|
|
397
|
+
if(!dragging) return;
|
|
398
|
+
if(Math.abs(e.clientX - px) + Math.abs(e.clientY - py) > 2) moved = true;
|
|
399
|
+
yaw -= (e.clientX - px) * 0.008;
|
|
400
|
+
pitch = Math.max(-1.5, Math.min(1.5, pitch + (e.clientY - py) * 0.008));
|
|
401
|
+
px = e.clientX; py = e.clientY;
|
|
402
|
+
});
|
|
403
|
+
addEventListener("wheel", e => { dist = Math.max(1.2, Math.min(12, dist * (e.deltaY > 0 ? 1.1 : 0.9))); });
|
|
404
|
+
addEventListener("keydown", e => {
|
|
405
|
+
if(e.key === "Escape"){ picks = []; updateMeasure(); }
|
|
406
|
+
if(e.key === "z"){ zebraOn = !zebraOn; gl.uniform1i(uZebra, zebraOn ? 1 : 0); draw(); }
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// -- tabs ---------------------------------------------------------------------
|
|
410
|
+
const tabsEl = document.getElementById("tabs");
|
|
411
|
+
let activeTab = location.hash === "#sheets" ? "sheets"
|
|
412
|
+
: location.hash === "#checks" ? "checks" : "3d", sheetCount = 0;
|
|
413
|
+
function renderTabs(){
|
|
414
|
+
tabsEl.innerHTML = "";
|
|
415
|
+
const mk = (id, label, cls) => {
|
|
416
|
+
const t = document.createElement("div");
|
|
417
|
+
t.className = "tab " + (cls || "") + (id === activeTab || (id === "measure" && measureMode) ? " on" : "");
|
|
418
|
+
t.textContent = label;
|
|
419
|
+
t.onclick = () => {
|
|
420
|
+
if(id === "measure"){ measureMode = !measureMode; if(!measureMode){ picks = []; } updateMeasure(); }
|
|
421
|
+
else { activeTab = id; showTab(); }
|
|
422
|
+
renderTabs();
|
|
423
|
+
};
|
|
424
|
+
tabsEl.appendChild(t);
|
|
425
|
+
};
|
|
426
|
+
mk("3d", "3d");
|
|
427
|
+
if(sheetCount) mk("sheets", `schematics (${sheetCount})`);
|
|
428
|
+
if(checksState) mk("checks",
|
|
429
|
+
checksState.ok ? "checks ✓" : `checks (${checksState.total_violations})`);
|
|
430
|
+
if(reviewState) mk("review",
|
|
431
|
+
reviewState.gate_ok ? "review ✓" : `review (${reviewState.summary.introduced})`);
|
|
432
|
+
if(activeTab === "3d") mk("measure", "measure", "tool");
|
|
433
|
+
}
|
|
434
|
+
function showTab(){
|
|
435
|
+
document.getElementById("sheets").style.display = activeTab === "sheets" ? "block" : "none";
|
|
436
|
+
document.getElementById("checks").style.display = activeTab === "checks" ? "block" : "none";
|
|
437
|
+
document.getElementById("review").style.display = activeTab === "review" ? "block" : "none";
|
|
438
|
+
const three = activeTab === "3d";
|
|
439
|
+
canvas.style.display = three ? "block" : "none";
|
|
440
|
+
hud.style.display = three ? "block" : "none";
|
|
441
|
+
measureHud.style.display = three ? "block" : "none";
|
|
442
|
+
}
|
|
443
|
+
let checksState = null, reviewState = null;
|
|
444
|
+
async function loadReview(baseRef){
|
|
445
|
+
if(!baseRef){ reviewState = null; return; }
|
|
446
|
+
try {
|
|
447
|
+
reviewState = await (await fetch("/api/review")).json();
|
|
448
|
+
if(reviewState.error) throw new Error(reviewState.error);
|
|
449
|
+
const el = document.getElementById("review");
|
|
450
|
+
el.innerHTML = "";
|
|
451
|
+
const head = document.createElement("div");
|
|
452
|
+
head.className = "rev";
|
|
453
|
+
head.innerHTML = `<h3>review vs <code>${baseRef}</code> — ` +
|
|
454
|
+
`<span class="${reviewState.gate_ok ? "good" : "bad"}">` +
|
|
455
|
+
`${reviewState.gate_ok ? "gate PASS" : "gate FAIL"}</span> · ` +
|
|
456
|
+
`${reviewState.summary.changed} file(s) · ` +
|
|
457
|
+
`${reviewState.summary.introduced} introduced · ` +
|
|
458
|
+
`${reviewState.summary.fixed} fixed</h3>`;
|
|
459
|
+
el.appendChild(head);
|
|
460
|
+
for(const f of reviewState.files){
|
|
461
|
+
const card = document.createElement("div");
|
|
462
|
+
card.className = "rev";
|
|
463
|
+
let html = `<h3>${f.file} <small>(${f.kind}, ${f.status})</small></h3>`;
|
|
464
|
+
for(const v of f.violations_introduced)
|
|
465
|
+
html += `<div class="bad">introduced: ${v}</div>`;
|
|
466
|
+
for(const v of f.violations_fixed)
|
|
467
|
+
html += `<div class="good">fixed: ${v}</div>`;
|
|
468
|
+
if(f.render_old || f.render_new){
|
|
469
|
+
html += '<div class="sxs">' +
|
|
470
|
+
`<div class="pane"><h4>base</h4>${f.render_old || ""}</div>` +
|
|
471
|
+
`<div class="pane"><h4>head</h4>${f.render_new || ""}</div></div>`;
|
|
472
|
+
}
|
|
473
|
+
card.innerHTML = html;
|
|
474
|
+
el.appendChild(card);
|
|
475
|
+
}
|
|
476
|
+
renderTabs();
|
|
477
|
+
} catch(e){ reviewState = null; }
|
|
478
|
+
}
|
|
479
|
+
async function loadChecks(){
|
|
480
|
+
try {
|
|
481
|
+
checksState = await (await fetch("/api/checks")).json();
|
|
482
|
+
if(checksState.error) throw new Error(checksState.error);
|
|
483
|
+
const el = document.getElementById("checks");
|
|
484
|
+
el.innerHTML = "";
|
|
485
|
+
for(const r of checksState.results){
|
|
486
|
+
const card = document.createElement("div");
|
|
487
|
+
card.className = "chk";
|
|
488
|
+
const h = document.createElement("h3");
|
|
489
|
+
h.innerHTML = `${r.check} <span class="${r.ok ? "ok" : "bad"}">` +
|
|
490
|
+
`${r.ok ? "✓ pass" : r.violations.length + " violation(s)"}</span>`;
|
|
491
|
+
card.appendChild(h);
|
|
492
|
+
if(!r.ok){
|
|
493
|
+
const ul = document.createElement("ul");
|
|
494
|
+
for(const v of r.violations.slice(0, 200)){
|
|
495
|
+
const li = document.createElement("li");
|
|
496
|
+
li.textContent = v;
|
|
497
|
+
ul.appendChild(li);
|
|
498
|
+
}
|
|
499
|
+
card.appendChild(ul);
|
|
500
|
+
}
|
|
501
|
+
el.appendChild(card);
|
|
502
|
+
}
|
|
503
|
+
renderTabs();
|
|
504
|
+
} catch(e){ checksState = null; }
|
|
505
|
+
}
|
|
506
|
+
async function loadSheets(){
|
|
507
|
+
try {
|
|
508
|
+
const data = await (await fetch("/api/schematics")).json();
|
|
509
|
+
const sheets = data.sheets || [];
|
|
510
|
+
sheetCount = sheets.length;
|
|
511
|
+
const el = document.getElementById("sheets");
|
|
512
|
+
el.innerHTML = "";
|
|
513
|
+
for(const s of sheets){
|
|
514
|
+
const name = document.createElement("div");
|
|
515
|
+
name.className = "sheet-name"; name.textContent = s.name + " (" + s.file + ")";
|
|
516
|
+
el.appendChild(name);
|
|
517
|
+
if(s.error){
|
|
518
|
+
const e2 = document.createElement("div");
|
|
519
|
+
e2.className = "sheet-err"; e2.textContent = s.error;
|
|
520
|
+
el.appendChild(e2);
|
|
521
|
+
} else {
|
|
522
|
+
const card = document.createElement("div");
|
|
523
|
+
card.className = "sheet-card"; card.innerHTML = s.svg;
|
|
524
|
+
el.appendChild(card);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
renderTabs();
|
|
528
|
+
} catch(e){ /* schematics are optional — the 3D view stands alone */ }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// -- live reload --------------------------------------------------------------
|
|
532
|
+
let version = null;
|
|
533
|
+
async function poll(){
|
|
534
|
+
try {
|
|
535
|
+
const v = await (await fetch("/api/version")).json();
|
|
536
|
+
if(v.error) throw new Error(v.error);
|
|
537
|
+
document.title = v.name + " — gitcad viewer";
|
|
538
|
+
if(v.version !== version){
|
|
539
|
+
version = v.version;
|
|
540
|
+
err.textContent = "";
|
|
541
|
+
if(v.kind === "board" || v.kind === "schematic"){
|
|
542
|
+
const svg = await (await fetch("/api/board.svg")).text();
|
|
543
|
+
document.getElementById("board").innerHTML = svg;
|
|
544
|
+
document.getElementById("board").style.display = "flex";
|
|
545
|
+
canvas.style.display = "none";
|
|
546
|
+
hud.textContent = v.name;
|
|
547
|
+
} else {
|
|
548
|
+
const mesh = await (await fetch("/api/mesh")).json();
|
|
549
|
+
if(mesh.error) throw new Error(mesh.error);
|
|
550
|
+
upload(mesh);
|
|
551
|
+
picks = []; updateMeasure();
|
|
552
|
+
// explode slider only makes sense for multi-instance assemblies;
|
|
553
|
+
// #x=0.6 deep-links an exploded state (a display projection —
|
|
554
|
+
// the model text never changes, ADR-0014)
|
|
555
|
+
const box = document.getElementById("explodebox");
|
|
556
|
+
box.style.display = groups.length > 1 ? "flex" : "none";
|
|
557
|
+
const m = location.hash.match(/x=([0-9.]+)/);
|
|
558
|
+
explode = m ? Math.min(1, parseFloat(m[1])) : explode;
|
|
559
|
+
document.getElementById("explodeslider").value = String(explode * 100);
|
|
560
|
+
applyExplode();
|
|
561
|
+
showTab();
|
|
562
|
+
}
|
|
563
|
+
loadSheets();
|
|
564
|
+
loadChecks();
|
|
565
|
+
loadReview(v.review_base);
|
|
566
|
+
}
|
|
567
|
+
} catch(e){ err.textContent = String(e); }
|
|
568
|
+
setTimeout(poll, 1000);
|
|
569
|
+
}
|
|
570
|
+
document.getElementById("explodeslider").addEventListener("input", e => {
|
|
571
|
+
explode = Number(e.target.value) / 100;
|
|
572
|
+
applyExplode();
|
|
573
|
+
});
|
|
574
|
+
renderTabs();
|
|
575
|
+
poll();
|
|
576
|
+
</script></body></html>
|
|
577
|
+
"""
|