Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions javascript/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Doyle Spiral Studio (JavaScript)

This folder contains the browser-based Doyle spiral viewer. The main entry point is `index.html`, which provides both the SVG preview and the interactive 3D view.

## Running the page locally

1. From the repository root, change into the `javascript` directory:
```bash
cd javascript
```
2. Start a simple local server (needed because the page loads ES modules):
```bash
python -m http.server 8000
```
3. Open http://localhost:8000 in your browser and select `index.html`.

You can also use any other static file server; the important part is that the files are served over HTTP so the module imports are allowed.

## Using the interface

- Adjust parameters in the **Spiral parameters** and **Fill pattern** panels and the preview updates automatically.
- Toggle between the **2D** and **3D** views with the buttons above the preview area.
- Use **Download SVG** to export the current spiral; the exported file renders each fill line individually, while the on-page preview uses a lighter pattern fill for faster updates.
- The **Advanced settings** panel lets you tune render timeouts and stroke widths. Use the **3D settings** toggle inside the 3D view to expose material and animation controls.

## Performance tips

- Leave the lightweight pattern preview enabled for quicker on-page renders; exporting automatically regenerates a full-detail SVG.
- Reduce `Canvas size` or `Number of gaps` if renders take too long, or increase the timeout under **Advanced settings**.
12 changes: 11 additions & 1 deletion javascript/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,21 @@ function downloadCurrentSvg() {
return;
}

let svgContent = lastRender.svgString || '';
const exportParams = { ...collectParams(), pattern_render_mode: 'segments' };
let svgContent = '';
try {
const exportResult = renderSpiral(exportParams);
svgContent = exportResult?.svgString || '';
} catch (error) {
console.error('Export render failed', error);
}

if (!svgContent) {
const svgElement = svgPreview.querySelector('svg');
if (svgElement) {
svgContent = new XMLSerializer().serializeToString(svgElement);
} else if (lastRender.svgString) {
svgContent = lastRender.svgString;
}
}

Expand Down
122 changes: 122 additions & 0 deletions javascript/js/doyle_spiral_engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,7 @@ class ArcGroup {
patternOffsetOverride = null,
outlineStrokeWidth = 0.6,
patternStrokeWidth = 0.5,
patternRenderMode = 'background',
} = {}) {
const outline = this.getClosedOutline();
if (!outline.length) {
Expand Down Expand Up @@ -1532,6 +1533,9 @@ class ArcGroup {
patternType,
rectWidth,
patternStrokeWidth,
patternRenderMode,
patternSpacingOverride: patternSpacingOverride,
patternOffsetOverride: patternOffsetOverride,
});
return;
}
Expand Down Expand Up @@ -1669,6 +1673,7 @@ class DrawingContext {
this._virtualDefs = [];
this._virtualMain = [];
}
this._patternDefinitionCache = new Map();
}

setNormalizationScale(elements) {
Expand Down Expand Up @@ -1727,6 +1732,63 @@ class DrawingContext {
target.push(`<${tag}${attrString ? ` ${attrString}` : ''} />`);
}

_ensureLinePattern(spacing, angleDeg, offset, stroke, strokeWidthStr) {
const safeSpacing = Math.max(1e-6, Math.abs(Number(spacing) || 0));
const normalizedAngle = Number.isFinite(angleDeg) ? angleDeg : 0;
const normalizedOffset = Number.isFinite(offset) ? offset : 0;
const strokeColor = stroke || '#000000';
const key = [
safeSpacing.toFixed(6),
normalizedAngle.toFixed(6),
normalizedOffset.toFixed(6),
strokeColor,
strokeWidthStr,
].join('|');
const cached = this._patternDefinitionCache.get(key);
if (cached) {
return cached;
}
const id = `linefill_${this._patternDefinitionCache.size + 1}`;
const transformParts = [];
if (Math.abs(normalizedOffset) > 1e-6) {
transformParts.push(`translate(0 ${normalizedOffset.toFixed(4)})`);
}
if (Math.abs(normalizedAngle % 360) > 1e-9) {
transformParts.push(`rotate(${normalizedAngle.toFixed(4)})`);
}
const transformAttr = transformParts.length ? transformParts.join(' ') : '';

if (!this.hasDOM) {
const transformSnippet = transformAttr ? ` patternTransform="${transformAttr}"` : '';
const pattern = `<pattern id="${id}" patternUnits="userSpaceOnUse" width="${safeSpacing.toFixed(4)}" height="${safeSpacing.toFixed(4)}"${transformSnippet}>`
+ `<line x1="0" y1="0" x2="${safeSpacing.toFixed(4)}" y2="0" stroke="${strokeColor}" stroke-width="${strokeWidthStr}" stroke-linecap="round" />`
+ `</pattern>`;
this._virtualDefs.push(pattern);
} else {
const patternEl = document.createElementNS(SVG_NS, 'pattern');
patternEl.setAttribute('id', id);
patternEl.setAttribute('patternUnits', 'userSpaceOnUse');
patternEl.setAttribute('width', safeSpacing.toFixed(4));
patternEl.setAttribute('height', safeSpacing.toFixed(4));
if (transformAttr) {
patternEl.setAttribute('patternTransform', transformAttr);
}
const line = document.createElementNS(SVG_NS, 'line');
line.setAttribute('x1', '0');
line.setAttribute('y1', '0');
line.setAttribute('x2', safeSpacing.toFixed(4));
line.setAttribute('y2', '0');
line.setAttribute('stroke', strokeColor);
line.setAttribute('stroke-width', strokeWidthStr);
line.setAttribute('stroke-linecap', 'round');
patternEl.appendChild(line);
this.defs.appendChild(patternEl);
}

this._patternDefinitionCache.set(key, id);
return id;
}

drawScaledCircle(circle, { color = '#4CB39B', opacity = 0.8 } = {}) {
if (!circle.visible) {
return;
Expand Down Expand Up @@ -1869,6 +1931,9 @@ class DrawingContext {
patternType = 'lines',
rectWidth = 2,
patternStrokeWidth = 0.5,
patternRenderMode = 'background',
patternSpacingOverride = null,
patternOffsetOverride = null,
} = {}) {
if (!points || !points.length) {
return;
Expand Down Expand Up @@ -1963,6 +2028,53 @@ class DrawingContext {
? createOutlineSegments(scaled)
: null;

const patternStyle = patternType === 'rectangles' ? 'rectangles' : 'lines';
const useBackgroundPattern =
fill === 'pattern'
&& patternRenderMode === 'background'
&& patternStyle === 'lines';

if (useBackgroundPattern) {
const spacingRaw = Array.isArray(linePatternSettings) && linePatternSettings.length
? Number(linePatternSettings[0])
: null;
const angleDeg = Array.isArray(linePatternSettings) && linePatternSettings.length > 1
? Number(linePatternSettings[1])
: 0;
const spacing = Number.isFinite(patternSpacingOverride)
? Math.max(0, patternSpacingOverride) * this.scaleFactor
: Number.isFinite(spacingRaw)
? Math.max(0, spacingRaw)
: 0;
const offset = Number.isFinite(patternOffsetOverride)
? patternOffsetOverride * this.scaleFactor
: Number.isFinite(lineOffset)
? lineOffset
: 0;
if (spacing > 0 && patternStroke > 0) {
const patternId = this._ensureLinePattern(spacing, angleDeg, offset, stroke, patternStrokeStr);
const pathData = buildPathData(scaled);
const pathAttributes = {
d: pathData,
fill: `url(#${patternId})`,
stroke: 'none',
};
if (!this.hasDOM) {
this._pushVirtual('path', pathAttributes);
} else {
const path = document.createElementNS(SVG_NS, 'path');
for (const [key, value] of Object.entries(pathAttributes)) {
path.setAttribute(key, value);
}
this.mainGroup.appendChild(path);
}
}
if (outlineSegments) {
emitOutlineSegments(outlineSegments, stroke);
}
return;
}

if (fill === 'pattern') {
let segmentsToDraw = null;
if (patternSegments !== null && patternSegments !== undefined) {
Expand Down Expand Up @@ -2867,6 +2979,7 @@ class DoyleSpiralEngine {
highlightRimWidth = 1.2,
groupOutlineWidth = 0.6,
patternStrokeWidth = 0.5,
patternRenderMode = 'background',
} = {}) {
this.generateOuterCircles();
this.computeAllIntersections();
Expand Down Expand Up @@ -2977,6 +3090,7 @@ class DoyleSpiralEngine {
patternOffsetOverride: offsetForGroups,
outlineStrokeWidth: outlineStrokeWidth,
patternStrokeWidth: patternStroke,
patternRenderMode,
});
}
}
Expand Down Expand Up @@ -3028,6 +3142,7 @@ class DoyleSpiralEngine {
boundingBoxWidth = null,
boundingBoxHeight = null,
lengthUnits = '',
patternRenderMode = 'background',
} = {}) {
if (!this._generated) {
this.generateCircles();
Expand Down Expand Up @@ -3061,6 +3176,7 @@ class DoyleSpiralEngine {
highlightRimWidth,
groupOutlineWidth,
patternStrokeWidth,
patternRenderMode,
});
return {
svg: context.toElement(),
Expand Down Expand Up @@ -3124,6 +3240,10 @@ function normaliseParams(params = {}) {
? params.fill_pattern_type.toLowerCase()
: 'lines';
const fillPatternType = patternTypeRaw === 'rectangles' ? 'rectangles' : 'lines';
const patternRenderModeRaw = typeof params.pattern_render_mode === 'string'
? params.pattern_render_mode.toLowerCase()
: 'background';
const patternRenderMode = patternRenderModeRaw === 'segments' ? 'segments' : 'background';
const spacingRaw = Number(params.fill_pattern_spacing ?? 8);
const offsetRaw = Number(params.fill_pattern_offset ?? 0);
const rectWidthValue = Number(params.fill_pattern_rect_width ?? 2);
Expand Down Expand Up @@ -3168,6 +3288,7 @@ function normaliseParams(params = {}) {
max_d: Number(params.max_d ?? 2000),
bounding_box_width_mm: boundingWidthMm,
bounding_box_height_mm: boundingHeightMm,
pattern_render_mode: patternRenderMode,
};
}

Expand Down Expand Up @@ -3197,6 +3318,7 @@ function renderSpiral(params = {}, overrideMode = null) {
boundingBoxWidth: opts.bounding_box_width_mm,
boundingBoxHeight: opts.bounding_box_height_mm,
lengthUnits: 'mm',
patternRenderMode: opts.pattern_render_mode,
});
return {
engine,
Expand Down