geometor.etc.render

Render geometor.model.Model instances into interactive Explorer-styled SVGs.

Attributes

EXPLORER_SVG_CSS

INTERACTIVE_SVG_JS

Functions

model_to_elements_list(model) → list[dict[str, Any]]

Convert a geometor.model.Model into a chronological list of browser-ready element dicts.

render_model_svg(model[, svg_width, svg_height, ...]) → str

Render a geometor.model.Model to an interactive Explorer-compatible SVG string.

write_center_svg(model, output_path[, subtitle]) → pathlib.Path

Render and write a center's Model SVG to output_path.

Module Contents

geometor.etc.render.EXPLORER_SVG_CSS = Multiline-String
Show Value
"""
        :root {
            --background-color: #121212;
            --text-color: #e0e0e0;
            --accent-color: #161616;
            --border-color: #333;
            --select-color: #0ff;
        }

        svg.geometor-etc-drawing {
            width: 100%;
            height: auto;
            max-height: 540px;
            background-color: var(--accent-color);
            border: 1px solid var(--border-color);
            border-radius: 4px;
            display: block;
            user-select: none;
            touch-action: none;
            cursor: grab;
        }

        svg.geometor-etc-drawing.is-panning {
            cursor: grabbing;
        }

        svg.geometor-etc-drawing.hide-labels #labels .point-label,
        svg.geometor-etc-drawing.hide-labels #labels .guide-label,
        svg.geometor-etc-drawing.hide-labels #labels .target-label,
        svg.geometor-etc-drawing.hide-labels #labels .given-label {
            display: none;
        }

        svg.geometor-etc-drawing.hide-guides .guide {
            display: none !important;
        }

        svg.geometor-etc-drawing:not(.show-lines) .incident {
            display: none !important;
        }

        #points circle {
            fill: var(--text-color);
            stroke: var(--background-color);
            stroke-width: 2.5;
            vector-effect: non-scaling-stroke;
            cursor: pointer;
            transition: fill 0.12s ease, stroke 0.12s ease;
        }

        #points circle.given {
            fill: #6F6;
        }

        #points circle.guide {
            fill: orange;
            stroke-dasharray: none;
        }

        #points circle.selected,
        #points circle.target {
            stroke: var(--select-color);
            fill: gold;
            stroke-width: 3;
        }

        #points circle:hover,
        #points circle.hover {
            fill: var(--select-color) !important;
            stroke: #fff !important;
            stroke-width: 3.5 !important;
        }

        #elements line,
        #elements path.line {
            fill: none;
            stroke: #b0bec5;
            stroke-width: 2.2;
            stroke-dasharray: 2, 4.5;
            stroke-linecap: round;
            vector-effect: non-scaling-stroke;
            cursor: pointer;
        }

        #elements line.sideline,
        #elements path.line.sideline {
            stroke: #cbd5e1;
            stroke-width: 2.3;
        }

        #elements line.central_line,
        #elements path.line.central_line {
            stroke: #0bd;
            stroke-width: 1.85;
            stroke-opacity: 0.85;
        }

        #elements line.central_line[data-color-token="orange"] { stroke: #fb923c; stroke-dasharray: 6, 3; }
        #elements line.central_line[data-color-token="teal"] { stroke: #2dd4bf; stroke-dasharray: 6, 3; }
        #elements line.central_line[data-color-token="blue"] { stroke: #38bdf8; stroke-dasharray: 6, 3; }
        #elements line.central_line[data-color-token="orchid"] { stroke: #e879f9; stroke-dasharray: 6, 3; }
        #elements line.central_line[data-color-token="pink"] { stroke: #f472b6; stroke-dasharray: 6, 3; }
        #elements line.central_line[data-color-token="lime"] { stroke: #a3e635; stroke-dasharray: 6, 3; }
        #elements line.central_line[data-color-token="green"] { stroke: #5ec27e; stroke-dasharray: 6, 3; }
        #elements line.central_line[data-color-token="purple"] { stroke: #a855f7; stroke-dasharray: 6, 3; }

        #elements line.golden,
        #elements path.line.golden,
        #elements line.euler,
        #elements path.line.euler {
            stroke: gold;
            stroke-width: 2.1;
        }

        #elements line.strut {
            stroke: gold;
            stroke-width: 2.8;
            stroke-dasharray: none;
            stroke-opacity: 0.95;
        }

        #elements circle {
            fill: none;
            stroke: #C09;
            stroke-width: 2;
            vector-effect: non-scaling-stroke;
            cursor: pointer;
        }

        #elements circle.incircle {
            stroke: #e040fb;
        }

        #elements circle.nine_point_circle {
            stroke: #00e5ff;
            stroke-dasharray: 6, 3;
        }

        #elements circle.secondary_circle {
            stroke: #ff9100;
            stroke-dasharray: 5, 3;
            stroke-opacity: 0.85;
        }

        #elements .bisector {
            stroke-dasharray: 2, 4;
        }

        #elements .guide {
            stroke: orange;
            stroke-dasharray: 2, 4;
            stroke-opacity: 0.65;
        }

        #elements line:hover,
        #elements line.hover,
        #elements path.line:hover,
        #elements path.line.hover,
        #elements circle:hover,
        #elements circle.hover {
            stroke: var(--select-color) !important;
            stroke-width: 3.2 !important;
            stroke-dasharray: none !important;
            stroke-opacity: 1 !important;
        }

        #highlights .highlight-segment {
            stroke: var(--select-color);
            fill: none;
            stroke-width: 6;
            stroke-opacity: 0.45;
            vector-effect: non-scaling-stroke;
            marker-start: url(#marker);
            marker-end: url(#marker);
            pointer-events: none;
        }

        #graphics polyline {
            stroke: #ffd54f;
            fill: none;
            stroke-width: 3.2;
            vector-effect: non-scaling-stroke;
            cursor: pointer;
        }

        #graphics polygon {
            stroke: #36c9;
            fill: #36c2;
            stroke-width: 6;
            vector-effect: non-scaling-stroke;
            stroke-linejoin: round;
            marker-start: url(#marker);
            marker-mid: url(#marker);
            marker-end: url(#marker);
            cursor: pointer;
        }

        #graphics polygon.secondary {
            stroke: rgba(144, 238, 144, 0.55);
            fill: rgba(144, 238, 144, 0.08);
            stroke-width: 3;
        }

        #graphics polygon.subarea-a {
            stroke: rgba(255, 112, 67, 0.72);
            fill: rgba(255, 112, 67, 0.20);
            stroke-width: 2.4;
            marker-start: none;
            marker-mid: none;
            marker-end: none;
        }

        #graphics polygon.subarea-b {
            stroke: rgba(102, 187, 106, 0.72);
            fill: rgba(102, 187, 106, 0.20);
            stroke-width: 2.4;
            marker-start: none;
            marker-mid: none;
            marker-end: none;
        }

        #graphics polygon.subarea-c {
            stroke: rgba(66, 165, 245, 0.72);
            fill: rgba(66, 165, 245, 0.20);
            stroke-width: 2.4;
            marker-start: none;
            marker-mid: none;
            marker-end: none;
        }

        #graphics polygon:hover,
        #graphics polygon.hover,
        #graphics polyline:hover,
        #graphics polyline.hover {
            stroke: var(--select-color) !important;
            stroke-opacity: 1 !important;
        }

        #graphics polygon.subarea-a:hover,
        #graphics polygon.subarea-a.hover {
            fill: rgba(255, 112, 67, 0.38) !important;
        }

        #graphics polygon.subarea-b:hover,
        #graphics polygon.subarea-b.hover {
            fill: rgba(102, 187, 106, 0.38) !important;
        }

        #graphics polygon.subarea-c:hover,
        #graphics polygon.subarea-c.hover {
            fill: rgba(66, 165, 245, 0.38) !important;
        }

        #labels text {
            fill: var(--text-color);
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
            font-weight: 600;
            paint-order: stroke;
            stroke: var(--accent-color);
            stroke-width: 0.15em;
            stroke-linecap: round;
            stroke-linejoin: round;
            user-select: none;
            pointer-events: none;
        }

        #labels tspan {
            paint-order: stroke;
            stroke: var(--accent-color);
            stroke-width: 0.15em;
            stroke-linecap: round;
            stroke-linejoin: round;
        }

        #labels text.target-label {
            fill: gold;
            font-weight: 700;
        }

        #labels text.given-label {
            fill: #8f8;
        }

        #labels text.guide-label {
            fill: #ffb74d;
            font-weight: 600;
        }

        #labels text.hover-label {
            fill: var(--select-color) !important;
            font-weight: 700;
        }

        #labels text.dense-label {
            display: none;
        }

        #labels text.dense-label.hover-label {
            display: block !important;
        }

        .etc-svg-stage {
            position: relative;
            width: 100%;
        }

        .etc-svg-toolbar {
            position: absolute;
            top: 8px;
            right: 8px;
            display: flex;
            gap: 4px;
            z-index: 20;
            opacity: 0.35;
            transition: opacity 0.18s ease;
        }

        .etc-svg-stage:hover .etc-svg-toolbar,
        figure:hover .etc-svg-toolbar {
            opacity: 0.95;
        }

        .etc-svg-btn {
            background: rgba(18, 18, 18, 0.88);
            color: #e0e0e0;
            border: 1px solid #444;
            border-radius: 4px;
            padding: 2px 8px;
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
            font-size: 12px;
            line-height: 1.4;
            cursor: pointer;
            user-select: none;
        }

        .etc-svg-btn:hover,
        .etc-svg-btn.active {
            background: rgba(0, 255, 255, 0.14);
            color: #0ff;
            border-color: #0ff;
        }

        .etc-symbolic-hud {
            position: absolute;
            left: 10px;
            bottom: 10px;
            max-width: min(460px, calc(100% - 20px));
            background: rgba(14, 16, 22, 0.93);
            border: 1px solid rgba(0, 255, 255, 0.35);
            border-radius: 6px;
            padding: 8px 12px;
            color: #eaeaea;
            font-family: "Fira Sans Condensed", system-ui, -apple-system, sans-serif;
            font-size: 13px;
            line-height: 1.38;
            z-index: 24;
            pointer-events: none;
            box-shadow: 0 6px 20px rgba(0, 0, 0, 0.58);
            opacity: 0;
            transform: translateY(4px);
            transition: opacity 0.12s ease, transform 0.12s ease;
        }

        .etc-symbolic-hud.is-visible {
            opacity: 1;
            transform: translateY(0);
        }

        .etc-hud-head {
            display: flex;
            align-items: baseline;
            gap: 7px;
            border-bottom: 1px solid rgba(255, 255, 255, 0.1);
            padding-bottom: 4px;
            margin-bottom: 5px;
        }

        .etc-hud-dot {
            width: 8px;
            height: 8px;
            border-radius: 50%;
            flex-shrink: 0;
            align-self: center;
        }

        .etc-hud-id {
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
            font-weight: 700;
            color: #0ff;
            font-size: 13px;
        }

        .etc-hud-title {
            font-weight: 600;
            color: #f5f5f5;
            flex: 1;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
        }

        .etc-hud-kind {
            font-size: 10.5px;
            text-transform: uppercase;
            letter-spacing: 0.06em;
            color: #94a3b8;
        }

        .etc-hud-rows {
            display: grid;
            grid-template-columns: auto 1fr;
            gap: 2px 10px;
            align-items: baseline;
        }

        .etc-hud-key {
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
            font-size: 11.5px;
            color: #94a3b8;
            text-align: right;
            white-space: nowrap;
        }

        .etc-hud-val {
            color: #f8fafc;
            font-size: 13.5px;
            overflow-wrap: anywhere;
        }

        .etc-hud-val .katex {
            font-size: 1.02em;
        }

        .etc-hud-approx {
            margin-top: 4px;
            padding-top: 3px;
            border-top: 1px dashed rgba(255, 255, 255, 0.09);
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
            font-size: 11px;
            color: #8b9bb4;
        }

        .etc-math-fallback {
            font-family: "EB Garamond", Georgia, serif;
            font-size: 1.06em;
        }

        .etc-math-frac {
            display: inline-flex;
            flex-direction: column;
            vertical-align: middle;
            text-align: center;
            padding: 0 2px;
            font-size: 0.88em;
            line-height: 1.05;
        }

        .etc-math-num {
            border-bottom: 1px solid rgba(248, 250, 252, 0.65);
            padding: 0 2px 1px;
        }

        .etc-math-den {
            padding: 1px 2px 0;
        }

        .etc-math-rad {
            border-top: 1px solid rgba(248, 250, 252, 0.6);
            padding: 0 2px;
        }

        .etc-graphics-panel,
        .etc-act-plate {
            width: 100%;
            background: #141414;
            border: 1px solid #333;
            border-radius: 6px;
            overflow: hidden;
        }

        .etc-graphics-panel svg.geometor-etc-drawing,
        .etc-act-plate svg.geometor-etc-drawing {
            border: none;
            border-radius: 0;
            display: block;
        }

        svg.geometor-etc-drawing .etc-plate-poly {
            cursor: pointer;
            transition: fill-opacity 0.14s ease, stroke 0.14s ease;
        }

        svg.geometor-etc-drawing .etc-plate-poly.hover {
            stroke: var(--select-color) !important;
            stroke-width: 2.8px !important;
            fill-opacity: 0.44 !important;
        }

        svg.geometor-etc-drawing .etc-plate-strut,
        svg.geometor-etc-drawing .etc-plate-line,
        svg.geometor-etc-drawing .etc-plate-circle,
        svg.geometor-etc-drawing .etc-plate-pt,
        svg.geometor-etc-drawing .etc-plate-panel {
            cursor: pointer;
        }

        svg.geometor-etc-drawing .etc-plate-strut.hover,
        svg.geometor-etc-drawing .etc-plate-line.hover,
        svg.geometor-etc-drawing .etc-plate-circle.hover {
            stroke: var(--select-color) !important;
            filter: drop-shadow(0 0 5px rgba(0, 255, 255, 0.72));
        }

        svg.geometor-etc-drawing .etc-plate-pt.hover {
            stroke: var(--select-color) !important;
            stroke-width: 3.6px !important;
            filter: drop-shadow(0 0 6px rgba(0, 255, 255, 0.85));
        }

        svg.geometor-etc-drawing .etc-plate-panel.hover > rect.etc-panel-border {
            stroke: var(--select-color) !important;
            stroke-width: 2.8px !important;
        }

        .etc-triangle-tabs {
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            gap: 2px;
            background: #1a1a1a;
            border-bottom: 1px solid #2e2e2e;
            padding: 6px 10px 0 10px;
        }

        .etc-tab-btn {
            background: transparent;
            color: #999;
            border: 1px solid transparent;
            border-bottom: none;
            border-radius: 5px 5px 0 0;
            padding: 5px 14px;
            font-family: "Fira Sans Condensed", system-ui, -apple-system, sans-serif;
            font-size: 13.5px;
            font-weight: 600;
            letter-spacing: 0.02em;
            cursor: pointer;
            user-select: none;
            transition: color 0.15s ease, background 0.15s ease;
            margin-bottom: -1px;
        }

        .etc-tab-btn:hover {
            color: #fff;
            background: rgba(255, 255, 255, 0.04);
        }

        .etc-tab-btn.active {
            background: #161616;
            color: #0ff;
            border-color: #2e2e2e;
            border-bottom: 1px solid #161616;
        }
        """
        
geometor.etc.render.INTERACTIVE_SVG_JS = Multiline-String
Show Value
"""
        (function () {
            const scriptEl = document.currentScript;
            const svg = scriptEl ? scriptEl.closest('svg') : document.querySelector('svg.geometor-etc-drawing');
            if (!svg || svg.dataset.etcInteractiveInit === '1') return;
            if (svg.closest('.excerpt, .collection, a') || svg.classList.contains('euclid-thumbnail')) return;
            svg.dataset.etcInteractiveInit = '1';

            const initVbAttr = svg.getAttribute('data-initial-viewbox') || svg.getAttribute('viewBox');
            const initialViewBox = initVbAttr.split(/\s+/).map(Number);

            let elementsCatalog = {};
            const metaEl = svg.querySelector('script.etc-elements-meta');
            if (metaEl) {
                try {
                    elementsCatalog = JSON.parse(metaEl.textContent);
                } catch (e) {}
            }

            const pointsGroup = svg.querySelector('#points');
            const elementsGroup = svg.querySelector('#elements');
            const graphicsGroup = svg.querySelector('#graphics');
            const highlightsGroup = svg.querySelector('#highlights');
            const labelsGroup = svg.querySelector('#labels');

            const pointEls = pointsGroup ? Array.from(pointsGroup.querySelectorAll('circle')) : [];
            const labelEls = labelsGroup ? Array.from(labelsGroup.querySelectorAll('text[data-point-id]')) : [];
            const subtitleEl = labelsGroup ? labelsGroup.querySelector('text.subtitle-label') : null;

            function escapeHtml(str) {
                return String(str || '')
                    .replace(/&/g, '&')
                    .replace(/</g, '&lt;')
                    .replace(/>/g, '&gt;')
                    .replace(/"/g, '&quot;');
            }

            function formatIdHtml(idStr) {
                const s = escapeHtml(idStr);
                return s.replace(/_\{([^}]+)\}/g, '<sub>$1</sub>').replace(/_([A-Za-z0-9']+)/g, '<sub>$1</sub>');
            }

            function extractBraced(src, startIdx) {
                if (src[startIdx] !== '{') return null;
                let depth = 0;
                for (let i = startIdx; i < src.length; i++) {
                    if (src[i] === '{') depth++;
                    else if (src[i] === '}') {
                        depth--;
                        if (depth === 0) {
                            return { content: src.slice(startIdx + 1, i), next: i + 1 };
                        }
                    }
                }
                return null;
            }

            function convertLatexFallback(tex) {
                let s = String(tex || '');
                s = s.replace(/\\left\s*([(\[{|])/g, '$1').replace(/\\right\s*([)\]}|])/g, '$1');
                s = s.replace(/\\;/g, ' ').replace(/\\,/g, '\u2009').replace(/\\quad/g, '  ');
                s = s.replace(/\\varphi|\\phi/g, 'φ')
                     .replace(/\\Delta/g, 'Δ')
                     .replace(/\\alpha/g, 'α')
                     .replace(/\\beta/g, 'β')
                     .replace(/\\gamma/g, 'γ')
                     .replace(/\\cdot/g, '·')
                     .replace(/\\approx/g, '≈')
                     .replace(/\\infty/g, '∞');

                let out = '';
                let i = 0;
                while (i < s.length) {
                    if (s.startsWith('\\frac', i)) {
                        let k = i + 5;
                        while (k < s.length && s[k] === ' ') k++;
                        const numB = extractBraced(s, k);
                        if (numB) {
                            let k2 = numB.next;
                            while (k2 < s.length && s[k2] === ' ') k2++;
                            const denB = extractBraced(s, k2);
                            if (denB) {
                                out += '<span class="etc-math-frac"><span class="etc-math-num">' +
                                    convertLatexFallback(numB.content) +
                                    '</span><span class="etc-math-den">' +
                                    convertLatexFallback(denB.content) +
                                    '</span></span>';
                                i = denB.next;
                                continue;
                            }
                        }
                    }
                    if (s.startsWith('\\sqrt', i)) {
                        let k = i + 5;
                        while (k < s.length && s[k] === ' ') k++;
                        const radB = extractBraced(s, k);
                        if (radB) {
                            out += '√<span class="etc-math-rad">' + convertLatexFallback(radB.content) + '</span>';
                            i = radB.next;
                            continue;
                        }
                    }
                    if ((s[i] === '_' || s[i] === '^') && i + 1 < s.length) {
                        const tag = s[i] === '_' ? 'sub' : 'sup';
                        if (s[i + 1] === '{') {
                            const subB = extractBraced(s, i + 1);
                            if (subB) {
                                out += `<${tag}>` + convertLatexFallback(subB.content) + `</${tag}>`;
                                i = subB.next;
                                continue;
                            }
                        } else {
                            out += `<${tag}>` + escapeHtml(s[i + 1]) + `</${tag}>`;
                            i += 2;
                            continue;
                        }
                    }
                    out += escapeHtml(s[i]);
                    i++;
                }
                return out;
            }

            function renderMathHtml(latexStr, prettyStr) {
                if (!latexStr) return escapeHtml(prettyStr || '');
                if (typeof window !== 'undefined' && window.katex && typeof window.katex.renderToString === 'function') {
                    try {
                        return window.katex.renderToString(latexStr, { throwOnError: false, displayMode: false });
                    } catch (e) {}
                }
                return '<span class="etc-math-fallback">' + convertLatexFallback(latexStr) + '</span>';
            }

            function getViewBox() {
                return svg.getAttribute('viewBox').split(/\s+/).map(Number);
            }

            function scaleViewElements() {
                const rect = svg.getBoundingClientRect();
                const widthPx = rect.width || 800;
                const vb = getViewBox();
                const unitsPerPx = vb[2] / widthPx;

                const targetRPx = parseFloat(svg.getAttribute('data-target-r-px') || '10.5');
                pointEls.forEach(c => {
                    const isTarget = c.classList.contains('target') || c.classList.contains('selected');
                    const isGuide = c.classList.contains('guide');
                    const rPx = isTarget ? targetRPx : (isGuide ? 4.8 : 7.5);
                    c.setAttribute('r', (rPx * unitsPerPx).toFixed(6));
                });

                const fontSize = 20.25 * unitsPerPx;
                const guideFontSize = 17.5 * unitsPerPx;
                labelEls.forEach(lbl => {
                    const cx = parseFloat(lbl.getAttribute('data-anchor-x') || '0');
                    const cy = parseFloat(lbl.getAttribute('data-anchor-y') || '0');
                    const oxPx = parseFloat(lbl.getAttribute('data-offset-x') || '12');
                    const oyPx = parseFloat(lbl.getAttribute('data-offset-y') || '-10.5');
                    const isGuideLbl = lbl.classList.contains('guide-label');
                    lbl.setAttribute('x', (cx + oxPx * unitsPerPx).toFixed(6));
                    lbl.setAttribute('y', (cy + oyPx * unitsPerPx).toFixed(6));
                    lbl.setAttribute('font-size', (isGuideLbl ? guideFontSize : fontSize).toFixed(6));
                });

                if (subtitleEl) {
                    const subX = vb[0] + 20.0 * unitsPerPx;
                    const subY = vb[1] + vb[3] - 18.0 * unitsPerPx;
                    subtitleEl.setAttribute('x', subX.toFixed(6));
                    subtitleEl.setAttribute('y', subY.toFixed(6));
                    subtitleEl.setAttribute('font-size', (17.25 * unitsPerPx).toFixed(6));
                }
            }

            function setViewBox(vb) {
                svg.setAttribute('viewBox', `${vb[0].toFixed(6)} ${vb[1].toFixed(6)} ${vb[2].toFixed(6)} ${vb[3].toFixed(6)}`);
                scaleViewElements();
            }

            function zoomBy(factor, clientX, clientY) {
                const vb = getViewBox();
                let [x, y, w, h] = vb;
                const rect = svg.getBoundingClientRect();
                const relX = (clientX !== undefined && rect.width > 0) ? (clientX - rect.left) / rect.width : 0.5;
                const relY = (clientY !== undefined && rect.height > 0) ? (clientY - rect.top) / rect.height : 0.5;
                const focusX = x + relX * w;
                const focusY = y + relY * h;
                const baseW = Math.max(initialViewBox[2] || 1.0, 0.2);
                const baseH = Math.max(initialViewBox[3] || 1.0, 0.2);
                const newW = Math.min(Math.max(w * factor, baseW * 0.002), baseW * 30.0);
                const newH = Math.min(Math.max(h * factor, baseH * 0.002), baseH * 30.0);
                const newX = focusX - relX * newW;
                const newY = focusY - relY * newH;
                setViewBox([newX, newY, newW, newH]);
            }

            function resetView() {
                setViewBox([...initialViewBox]);
            }

            let hudEl = null;

            svg._etcController = {
                zoomIn: () => zoomBy(1 / 1.25),
                zoomOut: () => zoomBy(1.25),
                resetView: resetView,
                toggleLabels: () => svg.classList.toggle('hide-labels'),
                toggleGuides: () => svg.classList.toggle('hide-guides'),
                scaleViewElements: scaleViewElements,
            };

            if (document.body && document.documentElement.tagName.toLowerCase() === 'html') {
                let stage = svg.parentElement;
                if (stage && !stage.classList.contains('etc-svg-stage')) {
                    const newStage = document.createElement('div');
                    newStage.className = 'etc-svg-stage';
                    stage.insertBefore(newStage, svg);
                    newStage.appendChild(svg);
                    stage = newStage;

                    const toolbar = document.createElement('div');
                    toolbar.className = 'etc-svg-toolbar';
                    const btns = [
                        { label: '+', title: 'Zoom In (or scroll wheel)', action: () => zoomBy(1 / 1.25) },
                        { label: '−', title: 'Zoom Out (or scroll wheel)', action: () => zoomBy(1.25) },
                        { label: '⟲ Fit', title: 'Reset View (or double-click canvas)', action: () => resetView() },
                    ];
                    if (svg.querySelector('.incident')) {
                        btns.push({
                            label: 'Lines',
                            toggleKey: 'lines',
                            title: 'Toggle Incident Central Lines',
                            isActive: () => svg.classList.contains('show-lines'),
                            action: () => svg.classList.toggle('show-lines'),
                        });
                    }
                    if (labelEls.length > 0) {
                        btns.push({
                            label: 'Labels',
                            toggleKey: 'labels',
                            title: 'Toggle Point Labels',
                            isActive: () => !svg.classList.contains('hide-labels'),
                            action: () => svg.classList.toggle('hide-labels'),
                        });
                    }
                    if (svg.querySelector('.guide')) {
                        btns.push({
                            label: 'Guides',
                            toggleKey: 'guides',
                            title: 'Toggle Construction Guides',
                            isActive: () => !svg.classList.contains('hide-guides'),
                            action: () => svg.classList.toggle('hide-guides'),
                        });
                    }
                    btns.forEach(b => {
                        const btn = document.createElement('button');
                        btn.type = 'button';
                        btn.className = 'etc-svg-btn';
                        if (b.toggleKey) {
                            btn.dataset.toggleKey = b.toggleKey;
                            btn.classList.toggle('active', b.isActive());
                        }
                        btn.textContent = b.label;
                        btn.title = b.title;
                        btn.addEventListener('click', (e) => {
                            e.preventDefault();
                            e.stopPropagation();
                            b.action();
                            if (b.isActive) {
                                btn.classList.toggle('active', b.isActive());
                            }
                        });
                        toolbar.appendChild(btn);
                    });
                    stage.appendChild(toolbar);
                }

                if (stage) {
                    hudEl = stage.querySelector('.etc-symbolic-hud');
                    if (!hudEl) {
                        hudEl = document.createElement('div');
                        hudEl.className = 'etc-symbolic-hud';
                        hudEl.setAttribute('aria-live', 'polite');
                        stage.appendChild(hudEl);
                    }
                    svg.querySelectorAll('title').forEach(t => t.remove());
                }

                const panel = svg.closest('.etc-graphics-panel');
                if (panel && panel.dataset.etcTabsInit !== '1') {
                    panel.dataset.etcTabsInit = '1';
                    const tabBtns = Array.from(panel.querySelectorAll('.etc-tab-btn'));

                    tabBtns.forEach(btn => {
                        btn.addEventListener('click', (e) => {
                            e.preventDefault();
                            const targetTab = btn.getAttribute('data-tab');
                            const tabPanes = Array.from(panel.querySelectorAll('.etc-tab-pane'));
                            const prevPane = panel.querySelector('.etc-tab-pane.active');
                            const prevSvg = prevPane ? prevPane.querySelector('svg.geometor-etc-drawing') : null;
                            const hideLabels = prevSvg ? prevSvg.classList.contains('hide-labels') : false;
                            const hideGuides = prevSvg ? prevSvg.classList.contains('hide-guides') : false;
                            const showLines = prevSvg ? prevSvg.classList.contains('show-lines') : false;

                            tabBtns.forEach(b => {
                                const isMatch = b.getAttribute('data-tab') === targetTab;
                                b.classList.toggle('active', isMatch);
                                b.setAttribute('aria-selected', isMatch ? 'true' : 'false');
                            });

                            tabPanes.forEach(pane => {
                                const isMatch = pane.getAttribute('data-tab-pane') === targetTab;
                                pane.classList.toggle('active', isMatch);
                                pane.style.display = isMatch ? 'block' : 'none';
                                if (isMatch) {
                                    const paneSvg = pane.querySelector('svg.geometor-etc-drawing');
                                    if (paneSvg) {
                                        paneSvg.classList.toggle('hide-labels', hideLabels);
                                        paneSvg.classList.toggle('hide-guides', hideGuides);
                                        paneSvg.classList.toggle('show-lines', showLines);
                                        const paneToolbar = pane.querySelector('.etc-svg-toolbar');
                                        if (paneToolbar) {
                                            const lBtn = paneToolbar.querySelector('[data-toggle-key="lines"]');
                                            if (lBtn) lBtn.classList.toggle('active', showLines);
                                            const lblBtn = paneToolbar.querySelector('[data-toggle-key="labels"]');
                                            if (lblBtn) lblBtn.classList.toggle('active', !hideLabels);
                                            const gBtn = paneToolbar.querySelector('[data-toggle-key="guides"]');
                                            if (gBtn) gBtn.classList.toggle('active', !hideGuides);
                                        }
                                        if (paneSvg._etcController) {
                                            paneSvg._etcController.scaleViewElements();
                                            requestAnimationFrame(() => paneSvg._etcController.scaleViewElements());
                                        }
                                    }
                                }
                            });

                            const figureEl = panel.closest('figure');
                            const captionP = figureEl ? (figureEl.querySelector('figcaption .caption-text') || figureEl.querySelector('figcaption p')) : null;
                            if (captionP) {
                                if (!captionP.dataset.origHtml) {
                                    captionP.dataset.origHtml = captionP.innerHTML;
                                }
                                const capHtml = btn.getAttribute('data-caption-html');
                                if (targetTab === 'acute' && captionP.dataset.origHtml) {
                                    captionP.innerHTML = captionP.dataset.origHtml;
                                } else if (capHtml) {
                                    captionP.innerHTML = capHtml;
                                }
                            }
                        });
                    });
                }
            }

            function updateHud(info, fallbackId) {
                if (!hudEl) return;
                if (!info) {
                    hudEl.classList.remove('is-visible');
                    return;
                }
                const dotColor = info.color || '#0ff';
                const idHtml = formatIdHtml(info.ID || fallbackId || '');
                const titleHtml = escapeHtml(info.name || fallbackId || '');
                const kindHtml = escapeHtml(info.type || '');
                const symRows = Array.isArray(info.symbolic_rows) ? info.symbolic_rows : [];
                let rowsHtml = '';
                if (symRows.length > 0) {
                    symRows.forEach(r => {
                        rowsHtml += '<div class="etc-hud-key">' + escapeHtml(r.label) + '</div>' +
                            '<div class="etc-hud-val">' + renderMathHtml(r.latex, r.pretty) + '</div>';
                    });
                } else if (Array.isArray(info.rows)) {
                    info.rows.forEach(pair => {
                        rowsHtml += '<div class="etc-hud-key">' + escapeHtml(pair[0]) + '</div>' +
                            '<div class="etc-hud-val">' + escapeHtml(pair[1]) + '</div>';
                    });
                }
                const approxHtml = info.approx
                    ? '<div class="etc-hud-approx">' + escapeHtml(info.approx) + '</div>'
                    : '';
                hudEl.innerHTML =
                    '<div class="etc-hud-head">' +
                        '<span class="etc-hud-dot" style="background:' + escapeHtml(dotColor) + '"></span>' +
                        '<span class="etc-hud-id">' + idHtml + '</span>' +
                        '<span class="etc-hud-title">' + titleHtml + '</span>' +
                        '<span class="etc-hud-kind">' + kindHtml + '</span>' +
                    '</div>' +
                    '<div class="etc-hud-rows">' + rowsHtml + '</div>' +
                    approxHtml;
                hudEl.classList.add('is-visible');
            }

            let activeHoveredKey = null;

            function clearHover(clearTextRefs) {
                if (!activeHoveredKey) return;
                activeHoveredKey = null;
                svg.querySelectorAll('.hover').forEach(el => el.classList.remove('hover'));
                svg.querySelectorAll('.hover-label').forEach(el => el.classList.remove('hover-label'));
                if (highlightsGroup) {
                    highlightsGroup.querySelectorAll('.highlight-segment').forEach(el => {
                        el.style.display = 'none';
                    });
                }
                if (hudEl) {
                    hudEl.classList.remove('is-visible');
                }
                if (clearTextRefs !== false && typeof document !== 'undefined') {
                    document.querySelectorAll('.geom-highlight-text').forEach(el => {
                        el.classList.remove('geom-highlight-text');
                    });
                }
            }

            function findSvgElement(candidateId) {
                if (!candidateId) return null;
                return svg.querySelector(`[data-id="${CSS.escape(candidateId)}"], [data-ref="${CSS.escape(candidateId)}"]`);
            }

            function resolveSingleSvgId(rawId) {
                if (!rawId) return null;
                const trimmed = rawId.trim();
                if (findSvgElement(trimmed)) return trimmed;
                const clean = trimmed.includes(':') ? trimmed.split(':').pop().trim() : trimmed;
                if (findSvgElement(clean)) return clean;

                const lineM = clean.match(/^\[\s*(\S+)\s+(\S+)\s*\]$/);
                if (lineM) {
                    const cands = [
                        `[ ${lineM[1]} ${lineM[2]} ]`,
                        `[ ${lineM[2]} ${lineM[1]} ]`,
                        `/ ${lineM[1]} ${lineM[2]} /`,
                        `/ ${lineM[2]} ${lineM[1]} /`,
                    ];
                    for (const c of cands) {
                        if (findSvgElement(c)) return c;
                    }
                }

                const segM = clean.match(/^\/\s*(\S+)\s+(\S+)\s*\/$/);
                if (segM) {
                    const cands = [
                        `/ ${segM[1]} ${segM[2]} /`,
                        `/ ${segM[2]} ${segM[1]} /`,
                        `[ ${segM[1]} ${segM[2]} ]`,
                        `[ ${segM[2]} ${segM[1]} ]`,
                    ];
                    for (const c of cands) {
                        if (findSvgElement(c)) return c;
                    }
                }

                const braceAllM = clean.match(/^\{\s*([\d,\s]+)\s*\}$/);
                if (braceAllM) {
                    const wantNums = braceAllM[1].split(',').map(s => s.trim()).filter(Boolean);
                    if (wantNums.length === 2) {
                        const cands = [
                            `{ ${wantNums[0]}, ${wantNums[1]} }`,
                            `{ ${wantNums[1]}, ${wantNums[0]} }`,
                        ];
                        for (const c of cands) {
                            if (findSvgElement(c)) return c;
                        }
                    }
                    if (wantNums.length >= 2) {
                        const wantSet = new Set(wantNums);
                        const lineEls = svg.querySelectorAll('line[data-id], line[data-ref]');
                        for (const ln of lineEls) {
                            const lid = ln.getAttribute('data-id') || ln.getAttribute('data-ref') || '';
                            const lm = lid.match(/^\{\s*([\d,\s]+)\s*\}$/);
                            if (lm) {
                                const lnums = lm[1].split(',').map(s => s.trim()).filter(Boolean);
                                const shared = lnums.filter(n => wantSet.has(n));
                                if (shared.length >= 2) return lid;
                            }
                        }
                    }
                }

                const triM = clean.match(/^<\s*(\S+)\s+(\S+)\s+(\S+)\s*>$/);
                if (triM) {
                    const want = [triM[1], triM[2], triM[3]].sort().join('|');
                    const polys = svg.querySelectorAll('polygon[data-id], polygon[data-ref]');
                    for (const p of polys) {
                        const pid = p.getAttribute('data-id') || p.getAttribute('data-ref') || '';
                        const pm = pid.match(/^<\s*(\S+)\s+(\S+)\s+(\S+)\s*>$/);
                        if (pm && [pm[1], pm[2], pm[3]].sort().join('|') === want) {
                            return pid;
                        }
                    }
                }

                return null;
            }

            function hasElement(rawRef) {
                if (!rawRef) return false;
                const parts = String(rawRef).split(';').map(s => s.trim()).filter(Boolean);
                return parts.some(p => resolveSingleSvgId(p) !== null);
            }

            function activateHover(rawRef, highlightTextRefs) {
                if (!rawRef) return false;
                const parts = String(rawRef).split(';').map(s => s.trim()).filter(Boolean);
                const resolvedIds = parts.map(p => resolveSingleSvgId(p)).filter(Boolean);
                if (resolvedIds.length === 0) return false;

                const key = resolvedIds.join(';');
                if (activeHoveredKey === key) return true;
                clearHover(highlightTextRefs !== false);
                activeHoveredKey = key;

                let primaryInfo = null;
                let primaryId = resolvedIds[0];

                resolvedIds.forEach(id => {
                    const info = elementsCatalog[id];
                    if (!primaryInfo && info) {
                        primaryInfo = info;
                        primaryId = id;
                    }
                    svg.querySelectorAll(`[data-id="${CSS.escape(id)}"], [data-ref="${CSS.escape(id)}"]`).forEach(el => {
                        el.classList.add('hover');
                    });
                    if (highlightsGroup) {
                        const hl = highlightsGroup.querySelector(`[data-highlight-for="${CSS.escape(id)}"]`);
                        if (hl) hl.style.display = 'inline';
                    }
                    let relatedPts = (info && info.related_points) ? info.related_points : [id];
                    if (!info) {
                        const sm = id.match(/^[\[/]\s*(\S+)\s+(\S+)\s*[\]/]$/);
                        const tm = id.match(/^<\s*(\S+)\s+(\S+)\s+(\S+)\s*>$/);
                        if (sm) relatedPts = [sm[1], sm[2]];
                        else if (tm) relatedPts = [tm[1], tm[2], tm[3]];
                    }
                    relatedPts.forEach(pid => {
                        const ptEl = pointsGroup ? pointsGroup.querySelector(`[data-id="${CSS.escape(pid)}"]`) : null;
                        if (ptEl) ptEl.classList.add('hover');
                        const lblEl = labelsGroup ? labelsGroup.querySelector(`text[data-point-id="${CSS.escape(pid)}"]`) : null;
                        if (lblEl) lblEl.classList.add('hover-label');
                    });
                });

                updateHud(primaryInfo, primaryId);

                if (highlightTextRefs !== false && typeof document !== 'undefined') {
                    const selectors = new Set([`.geom-ref[data-ref="${CSS.escape(rawRef)}"]`]);
                    resolvedIds.forEach(id => selectors.add(`.geom-ref[data-ref="${CSS.escape(id)}"]`));
                    document.querySelectorAll(Array.from(selectors).join(', ')).forEach(el => {
                        el.classList.add('geom-highlight-text');
                    });
                }
                return true;
            }

            svg._etcController.activateHover = activateHover;
            svg._etcController.clearHover = clearHover;
            svg._etcController.hasElement = hasElement;

            if (typeof window !== 'undefined') {
                window.__etcSvgRegistry = window.__etcSvgRegistry || [];
                if (!window.__etcSvgRegistry.includes(svg)) {
                    window.__etcSvgRegistry.push(svg);
                }
            }

            if (document.body && document.documentElement.tagName.toLowerCase() === 'html' && document.body.dataset.etcGeomRefSyncInit !== '1') {
                document.body.dataset.etcGeomRefSyncInit = '1';

                function isSvgVisible(s) {
                    if (!s || !s.isConnected || !s._etcController) return false;
                    const pane = s.closest('.etc-tab-pane');
                    if (pane && !pane.classList.contains('active')) return false;
                    return s.getClientRects().length > 0;
                }

                function dispatchRefHover(sourceEl, refStr) {
                    if (!refStr) return;
                    const reg = (window.__etcSvgRegistry || []).filter(isSvgVisible);
                    const sec = sourceEl ? sourceEl.closest('section') : null;
                    let targets = [];
                    if (sec) {
                        targets = reg.filter(s => sec.contains(s) && s._etcController.hasElement(refStr));
                    }
                    if (targets.length === 0) {
                        targets = reg.filter(s => s._etcController.hasElement(refStr));
                    }
                    reg.forEach(s => {
                        if (!targets.includes(s)) s._etcController.clearHover(false);
                    });
                    targets.forEach(s => s._etcController.activateHover(refStr, false));
                    if (sourceEl && targets.length > 0) {
                        sourceEl.classList.add('geom-highlight-text');
                    }
                }

                function clearAllSvgHovers() {
                    (window.__etcSvgRegistry || []).forEach(s => {
                        if (s && s._etcController) s._etcController.clearHover(true);
                    });
                }

                document.body.addEventListener('mouseover', (e) => {
                    const geomEl = e.target.closest('.geom-ref[data-ref]');
                    if (geomEl) {
                        dispatchRefHover(geomEl, geomEl.getAttribute('data-ref'));
                        return;
                    }
                    const refLink = e.target.closest('a.reference.internal');
                    if (refLink) {
                        const txt = (refLink.textContent || '').trim();
                        const xm = txt.match(/^X(?:_|\()?(\d+)\)?$/);
                        if (xm) {
                            dispatchRefHover(refLink, `X_${xm[1]}`);
                        }
                    }
                });

                document.body.addEventListener('mouseout', (e) => {
                    const geomEl = e.target.closest('.geom-ref[data-ref], a.reference.internal');
                    if (geomEl) {
                        clearAllSvgHovers();
                    }
                });
            }

            svg.addEventListener('mouseover', (event) => {
                if (isPanning) return;
                const target = event.target.closest('[data-id], [data-ref]');
                if (target && svg.contains(target)) {
                    activateHover(target.getAttribute('data-id') || target.getAttribute('data-ref'));
                } else {
                    clearHover();
                }
            });

            svg.addEventListener('mouseout', (event) => {
                if (isPanning) return;
                const related = event.relatedTarget ? event.relatedTarget.closest?.('[data-id], [data-ref]') : null;
                if (!related || !svg.contains(related)) {
                    clearHover();
                }
            });

            svg.addEventListener('wheel', (event) => {
                event.preventDefault();
                const factor = event.deltaY > 0 ? 1.12 : (1 / 1.12);
                zoomBy(factor, event.clientX, event.clientY);
            }, { passive: false });

            let isPanning = false;
            let didDrag = false;
            let startClient = { x: 0, y: 0 };

            svg.addEventListener('mousedown', (event) => {
                if (event.button !== 0) return;
                isPanning = true;
                didDrag = false;
                startClient = { x: event.clientX, y: event.clientY };
                svg.classList.add('is-panning');
            });

            svg.addEventListener('mousemove', (event) => {
                if (!isPanning) return;
                const dxPx = event.clientX - startClient.x;
                const dyPx = event.clientY - startClient.y;
                if (Math.hypot(dxPx, dyPx) > 2) didDrag = true;
                const rect = svg.getBoundingClientRect();
                if (rect.width > 0 && rect.height > 0) {
                    const vb = getViewBox();
                    const dx = dxPx * (vb[2] / rect.width);
                    const dy = dyPx * (vb[3] / rect.height);
                    setViewBox([vb[0] - dx, vb[1] - dy, vb[2], vb[3]]);
                }
                startClient = { x: event.clientX, y: event.clientY };
            });

            window.addEventListener('mouseup', () => {
                if (isPanning) {
                    isPanning = false;
                    svg.classList.remove('is-panning');
                }
            });

            svg.addEventListener('mouseleave', () => {
                isPanning = false;
                svg.classList.remove('is-panning');
                clearHover();
            });

            svg.addEventListener('click', (event) => {
                if (didDrag) return;
                const pt = event.target.closest('circle[data-id]');
                if (!pt) return;
                const pid = pt.getAttribute('data-id') || '';
                const m = pid.match(/^X_(\d+)$/);
                if (m && typeof window !== 'undefined' && window.location && window.location.pathname.includes('/c-') && !window.location.pathname.includes('/x-')) {
                    const slug = 'x-' + m[1].padStart(5, '0');
                    window.location.href = `${slug}/index.html`;
                }
            });

            svg.addEventListener('dblclick', (event) => {
                event.preventDefault();
                resetView();
            });

            if (typeof ResizeObserver !== 'undefined') {
                new ResizeObserver(() => scaleViewElements()).observe(svg);
            }
        })();
        """
geometor.etc.render.model_to_elements_list(model: geometor.model.Model) → list[dict[str, Any]]

Convert a geometor.model.Model into a chronological list of browser-ready element dicts.

geometor.etc.render.render_model_svg(model: geometor.model.Model, svg_width: int = 800, svg_height: int = 600, show_labels: bool = True, subtitle: str = '') → str

Render a geometor.model.Model to an interactive Explorer-compatible SVG string.

geometor.etc.render.write_center_svg(model: geometor.model.Model, output_path: str | pathlib.Path, subtitle: str = '') → pathlib.Path

Render and write a center’s Model SVG to output_path.