line-ends.mjs
325 lines · scripts/line-ends.mjs at 1482e84
1function lineEnds({ select = null, tolerance = 1, root: rootSlack = 2, mark = 7, least = 0.3, cover = 0.5, show = false } = {}) {
2 const plate = select == null ? document.querySelector(".iso-plate") || document.body : document.querySelector(select);
3 if (!plate) throw new Error(`Line target not found: ${select}`);
4 const box = plate.getBoundingClientRect();
5 const style = document.createElement("style");
6 style.textContent = `${select || ".iso-plate"} *{pointer-events:none!important}${select || ".iso-plate"} svg,${select || ".iso-plate"} svg *{pointer-events:visiblePainted!important}`;
7 document.head.append(style);
8 const skipClass = ["iso-edge", "iso-bevel", "iso-crease", "tb-edge"];
9 const svgs = [...(plate.matches("svg") ? [plate] : []), ...plate.querySelectorAll("svg")];
10 const shapes = svgs.flatMap((svg) => [...svg.querySelectorAll("path, line, polyline, polygon, circle, ellipse, rect")]).filter((el) => !el.closest("defs, clipPath, mask, pattern, marker, symbol"));
11 const opacityOf = (el) => {
12 let alpha = 1;
13 for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
14 const cs = getComputedStyle(node);
15 if (cs.display === "none") return 0;
16 alpha *= Number(cs.opacity);
17 if (node === plate) break;
18 }
19 return getComputedStyle(el).visibility === "hidden" ? 0 : alpha;
20 };
21 const numbers = (text) => (text.match(/-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) || []).map(Number);
22 const subpathsOf = (d, curveTolerance) => {
23 const out = [];
24 let current = null;
25 let x = 0;
26 let y = 0;
27 let sx = 0;
28 let sy = 0;
29 let control = null;
30 const push = (px, py) => {
31 current.points.push([px, py]);
32 x = px;
33 y = py;
34 };
35 const start = (px, py) => {
36 current = { points: [[px, py]], closed: false };
37 out.push(current);
38 x = sx = px;
39 y = sy = py;
40 };
41 let curveBudget = 16384;
42 const chordDistance = (point, first, last) => {
43 const dx = last[0] - first[0];
44 const dy = last[1] - first[1];
45 const span = dx * dx + dy * dy;
46 const along = span > 0 ? Math.max(0, Math.min(1, ((point[0] - first[0]) * dx + (point[1] - first[1]) * dy) / span)) : 0;
47 return Math.hypot(point[0] - first[0] - along * dx, point[1] - first[1] - along * dy);
48 };
49 const curve = (points) => {
50 const pending = [points];
51 while (pending.length) {
52 if (--curveBudget < 0) throw new Error("Curve audit could not establish its screen-space sampling bound");
53 const controlPoints = pending.pop();
54 const first = controlPoints[0];
55 const last = controlPoints[controlPoints.length - 1];
56 if (controlPoints.slice(1, -1).every((point) => chordDistance(point, first, last) <= curveTolerance)) {
57 push(...last);
58 continue;
59 }
60 const left = [first];
61 const right = [last];
62 let level = controlPoints;
63 while (level.length > 1) {
64 level = level.slice(1).map((point, index) => [(level[index][0] + point[0]) / 2, (level[index][1] + point[1]) / 2]);
65 left.push(level[0]);
66 right.push(level[level.length - 1]);
67 }
68 pending.push(right.reverse(), left);
69 }
70 };
71 const cubic = (x1, y1, x2, y2, px, py) => {
72 curve([[x, y], [x1, y1], [x2, y2], [px, py]]);
73 control = { kind: "cubic", point: [x2, y2] };
74 };
75 const quad = (x1, y1, px, py) => {
76 curve([[x, y], [x1, y1], [px, py]]);
77 control = { kind: "quadratic", point: [x1, y1] };
78 };
79 for (const [, command, args] of d.matchAll(/([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g)) {
80 const v = numbers(args);
81 const rel = command === command.toLowerCase();
82 const C = command.toUpperCase();
83 const ox = () => (rel ? x : 0);
84 const oy = () => (rel ? y : 0);
85 if (C === "Z") {
86 if (current) {
87 current.closed = true;
88 push(sx, sy);
89 }
90 current = null;
91 continue;
92 }
93 if (C === "M") {
94 for (let i = 0; i + 1 < v.length; i += 2) {
95 if (i === 0) start(v[0] + ox(), v[1] + oy());
96 else push(v[i] + ox(), v[i + 1] + oy());
97 }
98 control = null;
99 continue;
100 }
101 if (!current) start(x, y);
102 if (C === "L") for (let i = 0; i + 1 < v.length; i += 2) push(v[i] + ox(), v[i + 1] + oy());
103 else if (C === "H") for (const value of v) push(value + ox(), y);
104 else if (C === "V") for (const value of v) push(x, value + oy());
105 else if (C === "C") for (let i = 0; i + 5 < v.length; i += 6) cubic(v[i] + ox(), v[i + 1] + oy(), v[i + 2] + ox(), v[i + 3] + oy(), v[i + 4] + ox(), v[i + 5] + oy());
106 else if (C === "S")
107 for (let i = 0; i + 3 < v.length; i += 4) {
108 const reflected = control?.kind === "cubic" ? [2 * x - control.point[0], 2 * y - control.point[1]] : [x, y];
109 cubic(reflected[0], reflected[1], v[i] + ox(), v[i + 1] + oy(), v[i + 2] + ox(), v[i + 3] + oy());
110 }
111 else if (C === "Q") for (let i = 0; i + 3 < v.length; i += 4) quad(v[i] + ox(), v[i + 1] + oy(), v[i + 2] + ox(), v[i + 3] + oy());
112 else if (C === "T")
113 for (let i = 0; i + 1 < v.length; i += 2) {
114 const reflected = control?.kind === "quadratic" ? [2 * x - control.point[0], 2 * y - control.point[1]] : [x, y];
115 quad(reflected[0], reflected[1], v[i] + ox(), v[i + 1] + oy());
116 }
117 else if (C === "A") {
118 const arc = document.createElementNS("http://www.w3.org/2000/svg", "path");
119 arc.setAttribute("d", `M${x} ${y}${command}${args}`);
120 const length = arc.getTotalLength();
121 const count = Math.max(8, Math.ceil(length / 2));
122 for (let index = 1; index <= count; index++) {
123 const point = arc.getPointAtLength(length * index / count);
124 push(point.x, point.y);
125 }
126 }
127 if (C !== "C" && C !== "S" && C !== "Q" && C !== "T") control = null;
128 }
129 return out;
130 };
131 const geometryOf = (el, curveTolerance) => {
132 const tag = el.tagName.toLowerCase();
133 const num = (name) => Number(el.getAttribute(name) || 0);
134 if (tag === "path") return subpathsOf(el.getAttribute("d") || "", curveTolerance);
135 if (tag === "line") return [{ points: [[num("x1"), num("y1")], [num("x2"), num("y2")]], closed: false }];
136 if (tag === "polyline" || tag === "polygon") {
137 const v = numbers(el.getAttribute("points") || "");
138 const points = [];
139 for (let i = 0; i + 1 < v.length; i += 2) points.push([v[i], v[i + 1]]);
140 if (tag === "polygon" && points.length) points.push(points[0]);
141 return [{ points, closed: tag === "polygon" }];
142 }
143 if (tag === "rect") {
144 const [x, y, w, h] = [num("x"), num("y"), num("width"), num("height")];
145 const rx = Math.min(w / 2, Math.max(0, el.hasAttribute("rx") ? num("rx") : num("ry")));
146 const ry = Math.min(h / 2, Math.max(0, el.hasAttribute("ry") ? num("ry") : num("rx")));
147 if (!rx || !ry) return [{ points: [[x, y], [x + w, y], [x + w, y + h], [x, y + h], [x, y]], closed: true }];
148 const points = [];
149 for (const [cx, cy, start] of [[x + w - rx, y + ry, -Math.PI / 2], [x + w - rx, y + h - ry, 0], [x + rx, y + h - ry, Math.PI / 2], [x + rx, y + ry, Math.PI]]) {
150 for (let index = 0; index <= 32; index++) {
151 const angle = start + index * Math.PI / 64;
152 points.push([cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)]);
153 }
154 }
155 points.push(points[0]);
156 return [{ points, closed: true }];
157 }
158 return [];
159 };
160 const cell = 6;
161 const grid = new Map();
162 const add = (gx, gy, entry) => {
163 const id = `${gx},${gy}`;
164 if (!grid.has(id)) grid.set(id, []);
165 grid.get(id).push(entry);
166 };
167 const lines = [];
168 let scale = 1;
169 for (const el of shapes) {
170 const cs = getComputedStyle(el);
171 const alpha = opacityOf(el);
172 if (alpha < 0.1) continue;
173 const ctm = el.getScreenCTM();
174 if (!ctm) continue;
175 const map = ([px, py]) => [ctm.a * px + ctm.c * py + ctm.e, ctm.b * px + ctm.d * py + ctm.f];
176 const tag = el.tagName.toLowerCase();
177 const stroked = cs.stroke !== "none" && parseFloat(cs.strokeWidth) > 0 && Number(cs.strokeOpacity) > 0.05;
178 const filled = cs.fill !== "none" && Number(cs.fillOpacity) > 0.05;
179 if (tag === "circle" || tag === "ellipse") {
180 if (!stroked && !filled) continue;
181 const cx = Number(el.getAttribute("cx") || 0);
182 const cy = Number(el.getAttribute("cy") || 0);
183 const rx = Number(el.getAttribute(tag === "circle" ? "r" : "rx") || 0);
184 const ry = Number(el.getAttribute(tag === "circle" ? "r" : "ry") || 0);
185 if (rx <= 0 || ry <= 0) continue;
186 const points = Array.from({ length: 128 }, (_, index) => {
187 const angle = index * Math.PI * 2 / 128;
188 return map([cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)]);
189 });
190 const entry = { el, kind: "round", points, cx, cy, rx, ry, filled };
191 const xs = points.map(([x]) => x);
192 const ys = points.map(([, y]) => y);
193 for (let gx = Math.floor(Math.min(...xs) / cell); gx <= Math.floor(Math.max(...xs) / cell); gx++) for (let gy = Math.floor(Math.min(...ys) / cell); gy <= Math.floor(Math.max(...ys) / cell); gy++) add(gx, gy, entry);
194 continue;
195 }
196 if (!stroked) continue;
197 const owner = el.closest("svg");
198 const viewCtm = owner && owner.getScreenCTM();
199 if (viewCtm) scale = Math.hypot(viewCtm.a, viewCtm.b) || scale;
200 const curveTolerance = 0.15 / Math.max(1, Math.hypot(ctm.a, ctm.b, ctm.c, ctm.d));
201 const subpaths = geometryOf(el, curveTolerance).map((sub, index) => ({ ...sub, index, flat: sub.points.map(map) }));
202 subpaths.forEach((sub) => {
203 for (let i = 0; i < sub.flat.length - 1; i++) {
204 const a = sub.flat[i];
205 const b = sub.flat[i + 1];
206 const entry = { el, kind: "segment", a, b, sub: sub.index, i };
207 for (let gx = Math.floor(Math.min(a[0], b[0]) / cell); gx <= Math.floor(Math.max(a[0], b[0]) / cell); gx++) for (let gy = Math.floor(Math.min(a[1], b[1]) / cell); gy <= Math.floor(Math.max(a[1], b[1]) / cell); gy++) add(gx, gy, entry);
208 }
209 });
210 const checked = el.classList.contains("iso-line") && !skipClass.some((name) => el.classList.contains(name)) && alpha >= least;
211 if (checked) lines.push({ el, subpaths, alpha, free: el.getAttribute("data-free") });
212 }
213 const near = (p, reach) => {
214 const out = new Set();
215 for (let gx = Math.floor((p[0] - reach) / cell); gx <= Math.floor((p[0] + reach) / cell); gx++) for (let gy = Math.floor((p[1] - reach) / cell); gy <= Math.floor((p[1] + reach) / cell); gy++) for (const entry of grid.get(`${gx},${gy}`) || []) out.add(entry);
216 return out;
217 };
218 const segmentDistance = (p, a, b) => {
219 const dx = b[0] - a[0];
220 const dy = b[1] - a[1];
221 const span = dx * dx + dy * dy;
222 const t = span > 0 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / span)) : 0;
223 return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
224 };
225 const lengthOf = (flat) => flat.reduce((sum, p, i) => (i ? sum + Math.hypot(p[0] - flat[i - 1][0], p[1] - flat[i - 1][1]) : 0), 0);
226 const hidden = (el, p) => {
227 const stack = document.elementsFromPoint(p[0], p[1]);
228 const at = stack.indexOf(el);
229 const above = at < 0 ? stack : stack.slice(0, at);
230 return above.some((node) => {
231 if (!(node instanceof SVGGeometryElement) || node.contains(el)) return false;
232 const cs = getComputedStyle(node);
233 return cs.fill !== "none" && Number(cs.fillOpacity) * opacityOf(node) >= cover && node.isPointInFill(new DOMPoint(...inverse(node, p)));
234 });
235 };
236 const inverse = (node, p) => {
237 const m = node.getScreenCTM().inverse();
238 return [m.a * p[0] + m.c * p[1] + m.e, m.b * p[0] + m.d * p[1] + m.f];
239 };
240 const clearance = (line, sub, end, p, reach) => {
241 let best = Infinity;
242 const own = sub.flat;
243 const skipNear = 2 * reach;
244 const along = [0];
245 for (let i = 1; i < own.length; i++) along.push(along[i - 1] + Math.hypot(own[i][0] - own[i - 1][0], own[i][1] - own[i - 1][1]));
246 const total = along[along.length - 1];
247 for (const entry of near(p, reach * 4)) {
248 if (entry.kind === "round") {
249 const local = inverse(entry.el, p);
250 const inside = ((local[0] - entry.cx) / entry.rx) ** 2 + ((local[1] - entry.cy) / entry.ry) ** 2 <= 1;
251 if (entry.filled && inside) best = 0;
252 else for (let index = 0; index < entry.points.length; index++) best = Math.min(best, segmentDistance(p, entry.points[index], entry.points[(index + 1) % entry.points.length]));
253 continue;
254 }
255 if (entry.el === line.el && entry.sub === sub.index) {
256 const from = along[entry.i];
257 const to = along[entry.i + 1];
258 const gapFrom = end === "start" ? from : total - to;
259 if (gapFrom < skipNear) continue;
260 }
261 best = Math.min(best, segmentDistance(p, entry.a, entry.b));
262 }
263 return best;
264 };
265 const describe = (el) => {
266 const item = el.closest("[data-part], [data-name], [data-item], .it");
267 const tag = item && item !== el ? (item.getAttribute("data-part") || item.getAttribute("data-name") || item.getAttribute("data-item") || `it k ${item.getAttribute("data-k") || ""}`) : "";
268 return `${el.getAttribute("class")}${el.getAttribute("data-tone") ? ` ${el.getAttribute("data-tone")}` : ""}${tag ? ` in ${tag}` : ""}`;
269 };
270 const problems = [];
271 let ends = 0;
272 const slack = tolerance * scale;
273 const rooted = rootSlack * scale;
274 for (const line of lines) {
275 for (const sub of line.subpaths) {
276 if (sub.closed || sub.flat.length < 2) continue;
277 const first = sub.flat[0];
278 const last = sub.flat[sub.flat.length - 1];
279 if (Math.hypot(first[0] - last[0], first[1] - last[1]) < 0.05 * scale) continue;
280 const length = lengthOf(sub.flat);
281 if (length < 0.2 * scale) continue;
282 const result = {};
283 for (const [end, p] of [["start", first], ["end", last]]) {
284 if (line.free === "" || line.free === "both" || line.free === end) {
285 result[end] = 0;
286 continue;
287 }
288 ends++;
289 result[end] = hidden(line.el, p) ? 0 : clearance(line, sub, end, p, Math.max(slack, rooted));
290 }
291 const straight = sub.flat.length === 2 || sub.flat.every((q) => segmentDistance(q, first, last) < 0.15 * scale);
292 const isMark = straight && length <= mark * scale;
293 const fail = (end) => result[end] !== undefined && result[end] > slack;
294 let bad = [];
295 if (isMark) {
296 const best = Math.min(result.start ?? Infinity, result.end ?? Infinity);
297 if (!(best <= rooted)) bad = ["start", "end"].filter((end) => result[end] !== undefined && result[end] > 0);
298 } else bad = ["start", "end"].filter(fail);
299 for (const end of bad) {
300 const p = end === "start" ? first : last;
301 problems.push({ what: describe(line.el), d: (line.el.getAttribute("d") || "").slice(0, 36), end, mark: isMark, gap: result[end] / scale, at: [Math.round((p[0] - box.left) * 10) / 10, Math.round((p[1] - box.top) * 10) / 10], client: p, opacity: Math.round(line.alpha * 100) / 100, length: Math.round((length / scale) * 10) / 10 });
302 }
303 }
304 }
305 style.remove();
306 problems.sort((a, b) => b.gap - a.gap);
307 if (show) {
308 const ns = "http://www.w3.org/2000/svg";
309 const overlay = document.createElementNS(ns, "svg");
310 overlay.setAttribute("data-line-ends", "");
311 overlay.setAttribute("style", `position:fixed;left:0;top:0;width:${innerWidth}px;height:${innerHeight}px;pointer-events:none;z-index:2147483647;overflow:visible`);
312 for (const problem of problems) {
313 const ring = document.createElementNS(ns, "circle");
314 ring.setAttribute("cx", problem.client[0]);
315 ring.setAttribute("cy", problem.client[1]);
316 ring.setAttribute("r", 5);
317 ring.setAttribute("style", "fill:none;stroke:#ff0050;stroke-width:1.5");
318 overlay.append(ring);
319 }
320 document.body.append(overlay);
321 }
322 return { ends, lines: lines.length, problems: problems.map(({ client, ...rest }) => rest) };
323}
325export const LINE_ENDS = lineEnds.toString();