diff --git a/prototypes/issue-110/RESEARCH.md b/prototypes/issue-110/RESEARCH.md
new file mode 100644
index 0000000..2e8c40a
--- /dev/null
+++ b/prototypes/issue-110/RESEARCH.md
@@ -0,0 +1,123 @@
+# CraftDAG #110: ellipse and tangent path-repeat research
+
+## Decision
+
+The gap is in the engine representation, with Agent-authored coordinate work making it more brittle. Recommend a narrow implementation follow-up: **`EllipseRing` plus `PathRepeat` over an explicitly bounded ellipse**. Do not add a general `CurvePath`, spline language, or landmark component. Keep this branch as a research prototype; the prototype is not wired into the stable ComponentPlan schema.
+
+## Reproduced evidence
+
+MinePilot #73's latest persisted issue text reports the same result across a shell and multi-level arcade: four partial `CircleRing`s with separate centers/radii plus tangent walls for the shell; `ArcadeRun` only handles straight sides; oval bays cannot follow the path with local tangent orientation. The issue also reports a circular `CircleRing + RadialRepeat` control working for ordinary circular structure. Its full `docs/ISSUE_73_CURVED_RADIAL_STRESS_TEST.md` file is referenced as residing in the old MinePilot worktree, but is not present on current `main` and the GitHub Contents API returns 404, so exact historical LOC and repair-round counts could not be recovered.
+
+This checkout independently compiled two small current-vocabulary baselines:
+
+| Fixture | Current plan | Authored components | JSON bytes | Expanded blocks | Result |
+| --- | --- | ---: | ---: | ---: | --- |
+| Stadium-like shell control | 2 partial `CircleRing`s + 2 tangent `Beam`s | 4 | 938 | 2,240 | Valid and compact, but a capsule/stadium, not an ellipse; ellipse curvature cannot be matched by two fixed-radius caps. |
+| Oval two-level arcade | 24 manually positioned `Instance`s per level; fixed-X assembly | 48 instances (+ 4 assembly components) | 6,065 | 768 | Valid; no tangent rotation. Bays remain parallel around the oval and the 24 positions are hand-authored coordinates. |
+
+Both are generated/compiled by `current-workaround.mjs` against CraftDAG 0.2.6. These are bounded reproduction fixtures, not polished MinePilot content. They needed zero repair rounds because the issue is expressiveness/orientation, not schema validity. The persisted #73 summary likewise identifies visual faceting/brittleness and tangent-orientation failure, but does not preserve six-view screenshots or per-round measurements in the accessible issue text.
+
+## Capability audit
+
+| Need | Existing primitive | Adequate? |
+| --- | --- | --- |
+| Ellipse perimeter | `CircleRing` uses one radius; multiple partial rings and straight connectors can approximate a stadium | No for a true bounded ellipse; faceted or wrong curvature. |
+| Partial ellipse | `CircleRing` has `startAngle`/`endAngle`, but only for circles | No. |
+| Repeat along path | `Repeat` is linear on X/Y/Z; `RadialRepeat` places a fixed count around a full circle | No for ellipse paths and bounded open arcs. |
+| Local tangent orientation | `RadialRepeat.rotate=true` rotates `Instance` assemblies around a circle; arbitrary angles voxelize. `Instance` itself has no rotation | Partial: circle only. The useful voxel rotation machinery already exists, but path sampling does not. |
+| Arc endpoint handling | `CircleRing` filters sampled disk pixels by angle; `RadialRepeat` samples `count` around 360° | No path endpoints. Partial-repeat needs explicit endpoint inclusion and seam behavior. |
+| Bounded deterministic cost | Plan budgets, integer radii/count, deterministic expansion; radial repeat scales cost by count | Mostly. Ellipse extents, rotated module bounds, and conservative per-repeat block cost need explicit validation. |
+| Agent authorability | Circle / line concepts are clear; oval workaround uses multiple centers, radii, and manually placed instances | No for the two target families: author has to do path math and coordinate/rotation bookkeeping. |
+
+`ArcadeRun` creates a straight repeated arch rhythm inside one axis-aligned box. `Assembly`/`Instance` makes a multi-part bay reusable, but Instance has no general rotation. `Repeat` only translates an object along one axis. These are orthogonal and useful for the circular and straight controls; they do not compose into an ellipse-path repeat without coordinate-level authoring.
+
+## Candidate comparison
+
+### Ellipse geometry
+
+| Candidate | Benefits | Cost / risk | Finding |
+| --- | --- | --- | --- |
+| A: `EllipseRing` | One bounded Minecraft-style raster algorithm; clear dimensions `radiusX/radiusZ`; natural full/partial shell API; easy extents and area estimate | Duplicates some `CircleRing` logic; must define inner offset and angular endpoints | Best minimum geometry primitive. Can be a generic ellipse ring/cylinder, not an arbitrary curve language. |
+| B: bounded `CurvePath` | Could eventually serve more path families | Requires path kinds, sampling, arc-length/parameter semantics, tangent rules, endpoint policy, bounds, budget, diagnostics; invites spline/CAD growth | Do not adopt now. The current evidence only needs ellipses, and generic path authoring raises prompt and validation cost. |
+
+### Repeat geometry
+
+| Candidate | Benefits | Cost / risk | Finding |
+| --- | --- | --- | --- |
+| Extend `RadialRepeat` | Reuses current circular API and tested arbitrary-angle `Instance` voxelization | `radius`/"radial" semantics become misleading with two radii and partial open intervals; accumulating axes, endpoints, spacing modes, and path kinds creates a complex union schema | Keep existing circular behavior stable; do not turn it into a generic path component. |
+| `PathRepeat` | Explicitly means placing an assembly along a bounded path; can share the existing node rotation/voxelization helper | One schema component and more diagnostics; must stay ellipse-only initially | Preferred. First version accepts an ellipse path only and a source `Instance`/assembly. |
+
+### Minimum semantics to carry into implementation
+
+- Parameterize an ellipse by `x=cx+rx*cos(t)`, `z=cz+rz*sin(t)` in degrees. The tangent is `(-rx*sin(t), rz*cos(t))`.
+- `EllipseRing` uses integer center/radii/y/height/thickness, with optional partial `startAngle`/`endAngle` matching `CircleRing` conventions. Bounds use the full declared ellipse extents, even for a partial arc, unless later evidence justifies tighter angular bounds.
+- `PathRepeat` starts with an explicit ellipse `{center, radiusX, radiusZ}` plus `startAngle`, `endAngle`, `count`, and `closed`. Closed paths divide by `count` and omit a duplicate seam endpoint. Open paths include both endpoints and divide by `count - 1`. Reject invalid count and spans beyond one turn.
+- Start with `count`, not spacing. Spacing requires ellipse arc-length approximation and count rounding, which is avoidable API complexity for this use case.
+- `orientToTangent` defaults false or is required explicitly; when enabled, local assembly +X follows the analytic tangent. Keep integer block positions and deterministic nearest-voxel rotation; do not imply continuous Minecraft block-state rotation.
+- Estimate shell blocks from ellipse area/thickness/height conservatively. Estimate repeat cost as source assembly estimate times count (safe overestimate despite overlap). Validate center, ellipse extents, module envelope, and emitted voxels against plan bounds before returning geometry.
+- Namespace repeated IDs with stable path ID + source ID + index. Run the ordinary support analysis on expanded nodes; surface disconnected first/last supports and out-of-bounds modules with the path ID and repair hints.
+
+## Prototype evidence
+
+`geometry-prototype.mjs` is an isolated, deterministic geometry spike, deliberately outside core exports/schema. It implements the two proposed concepts and an isometric SVG renderer:
+
+| Prototype fixture | Emitted unique voxels | API proof |
+| --- | ---: | --- |
+| Elliptical arena shell | 2,412 | `EllipseRing`, radii 28 x 16, thickness 2, height 9. |
+| Two-level elliptical colonnade | 816 | Same shell plus two 24-bay `PathRepeat`s, tangent-oriented local post/lintel module. |
+| Open elliptical harbor wall probe | 25 | Open 0°–180° `PathRepeat`, count 9, both endpoints included. |
+
+The helper rejects out-of-bounds voxel positions, invalid radii/count/angles, and outputs over the explicit block budget. Closed paths omit a duplicate endpoint by dividing the angle span by `count`; open paths include both ends. Quantized rotation can merge neighboring voxels by design. Re-running the same fixtures produces identical SVG hashes.
+
+Artifacts: [`ellipse-shell.svg`](artifacts/ellipse-shell.svg), [`curved-arcade.svg`](artifacts/curved-arcade.svg), [`open-arc-endpoints.svg`](artifacts/open-arc-endpoints.svg).
+
+These SVGs are geometry artifacts, not MinePilot's six-view Three.js review captures. CraftDAG has no visual review renderer, and these images do not show block states or prove build quality in Minecraft. Treat them as preview artifacts for reviewer inspection; no independent visual rating was recorded. An implementation needs MinePilot preview review for close-up faceting and overlap.
+
+## Proposed API sketch
+
+```json
+{
+ "id": "outer_shell",
+ "type": "EllipseRing",
+ "placement": {
+ "center": { "x": 36, "z": 20 }, "y": 0,
+ "radiusX": 28, "radiusZ": 16
+ },
+ "options": { "height": 9, "thickness": 2, "fill": "hollow" }
+}
+```
+
+```json
+{
+ "id": "arcade_bays",
+ "type": "PathRepeat",
+ "placement": {
+ "path": { "type": "ellipse", "center": { "x": 36, "z": 20 }, "radiusX": 25, "radiusZ": 13 },
+ "source": "bay_module", "count": 24,
+ "startAngle": 0, "endAngle": 360, "closed": true,
+ "orientToTangent": true
+ }
+}
+```
+
+Exact field placement and whether `source` references an assembly or existing `Instance` remain prototype questions. Avoid both an awkward `Instance` wrapper and nested instances unless compiler contracts require them.
+
+## Risks and recommendation
+
+- **Compatibility:** additive component type avoids changing `CircleRing` and existing `RadialRepeat` plans. Reuse node IDs/rotation helpers where possible; component schema and documentation additions remain.
+- **DSL growth:** two types add prompt surface. Bound the path to ellipse-only and defer spacing mode, splines, arbitrary polylines, mirroring, and general rotation.
+- **Visual quantization:** local tangent direction is continuous but output is voxelized. Closely spaced modules can collide or leave gaps; expose deterministic diagnostics if measured sampling cannot keep bay spacing coherent.
+- **Support analysis:** repeated clones must retain provenance, and endpoint supports on an open path must not be silently omitted.
+- **Budget:** radial/ring cost estimates currently operate on formulas separate from exact expansion. Require conservative tests at the configured limits before shipping.
+
+**Recommendation:** #110 has enough cross-family evidence to proceed to a focused implementation PR after this research review. Implement `EllipseRing` and ellipse-only `PathRepeat` together, and require the shell + multi-level arcade fixtures plus a circular regression before merge. Do not merge this prototype as production code. No changes to core schema, compiler, or exported API were made here.
+
+## Reproduction commands
+
+```bash
+pnpm --filter @i365dev/craftdag-core build
+node prototypes/issue-110/current-workaround.mjs
+node prototypes/issue-110/geometry-prototype.mjs prototypes/issue-110/ellipse-shell.json
+node prototypes/issue-110/geometry-prototype.mjs prototypes/issue-110/curved-arcade.json
+node prototypes/issue-110/geometry-prototype.mjs prototypes/issue-110/open-arc-endpoints.json
+```
diff --git a/prototypes/issue-110/artifacts/curved-arcade.svg b/prototypes/issue-110/artifacts/curved-arcade.svg
new file mode 100644
index 0000000..1ed7ad5
--- /dev/null
+++ b/prototypes/issue-110/artifacts/curved-arcade.svg
@@ -0,0 +1,816 @@
+
diff --git a/prototypes/issue-110/artifacts/ellipse-shell.svg b/prototypes/issue-110/artifacts/ellipse-shell.svg
new file mode 100644
index 0000000..4d9b31d
--- /dev/null
+++ b/prototypes/issue-110/artifacts/ellipse-shell.svg
@@ -0,0 +1,2412 @@
+
diff --git a/prototypes/issue-110/artifacts/open-arc-endpoints.svg b/prototypes/issue-110/artifacts/open-arc-endpoints.svg
new file mode 100644
index 0000000..c416ca5
--- /dev/null
+++ b/prototypes/issue-110/artifacts/open-arc-endpoints.svg
@@ -0,0 +1,25 @@
+
diff --git a/prototypes/issue-110/current-workaround.mjs b/prototypes/issue-110/current-workaround.mjs
new file mode 100644
index 0000000..a9b5043
--- /dev/null
+++ b/prototypes/issue-110/current-workaround.mjs
@@ -0,0 +1,43 @@
+#!/usr/bin/env node
+// Generate and compile two current-vocabulary baseline plans for the report.
+import { compileComponentPlan } from "../../packages/core/dist/index.mjs";
+
+const palette = { wall: "minecraft:stone_bricks", trim: "minecraft:stone_bricks", foundation: "minecraft:stone" };
+const shell = {
+ version: "0.1", name: "Current workaround: stadium-like shell",
+ policy: { sizeTier: "large" },
+ bounds: { width: 55, height: 10, length: 35 }, palette,
+ components: [
+ { id: "west_cap", type: "CircleRing", placement: { center: { x: 16, z: 17 }, y: 0, radius: 16 }, materials: { main: "minecraft:stone_bricks" }, options: { thickness: 2, height: 8, startAngle: 90, endAngle: 270 } },
+ { id: "east_cap", type: "CircleRing", placement: { center: { x: 38, z: 17 }, y: 0, radius: 16 }, materials: { main: "minecraft:stone_bricks" }, options: { thickness: 2, height: 8, startAngle: 270, endAngle: 90 } },
+ { id: "north_tangent_wall", type: "Beam", placement: { anchor: { x: 16, y: 0, z: 0 }, size: { width: 22, height: 8, length: 2 } } },
+ { id: "south_tangent_wall", type: "Beam", placement: { anchor: { x: 16, y: 0, z: 33 }, size: { width: 22, height: 8, length: 2 } } }
+ ]
+};
+
+const center = { x: 36, z: 20 }, rx = 25, rz = 13, count = 24;
+const instances = [];
+for (const [level, y] of [[0, 1], [1, 9]]) {
+ for (let i = 0; i < count; i++) {
+ const angle = 2 * Math.PI * i / count;
+ const x = Math.round(center.x + rx * Math.cos(angle));
+ const z = Math.round(center.z + rz * Math.sin(angle));
+ instances.push({ id: `level_${level}_bay_${i}`, type: "Instance", placement: { assembly: "fixed_x_bay", anchor: { x: x - 3, y, z } } });
+ }
+}
+const arcade = {
+ version: "0.1", name: "Current workaround: manual oval arcade bays",
+ policy: { sizeTier: "large" },
+ bounds: { width: 72, height: 18, length: 40 }, palette,
+ assemblies: [{ id: "fixed_x_bay", bounds: { width: 7, height: 6, length: 1 }, components: [
+ { id: "pier_left", type: "SupportPost", placement: { anchor: { x: 0, y: 0, z: 0 }, size: { width: 1, height: 5, length: 1 } } },
+ { id: "pier_right", type: "SupportPost", placement: { anchor: { x: 6, y: 0, z: 0 }, size: { width: 1, height: 5, length: 1 } } },
+ { id: "lintel", type: "Beam", placement: { anchor: { x: 0, y: 5, z: 0 }, size: { width: 7, height: 1, length: 1 } } }
+ ] }],
+ components: instances
+};
+
+for (const [id, plan] of [["shell", shell], ["arcade", arcade]]) {
+ const compiled = compileComponentPlan(plan);
+ console.log(JSON.stringify({ id, authoredComponents: plan.components.length, planBytes: Buffer.byteLength(JSON.stringify(plan)), expandedBlocks: compiled.blocks.length, valid: true }));
+}
diff --git a/prototypes/issue-110/curved-arcade.json b/prototypes/issue-110/curved-arcade.json
new file mode 100644
index 0000000..b4cb85e
--- /dev/null
+++ b/prototypes/issue-110/curved-arcade.json
@@ -0,0 +1,82 @@
+{
+ "name": "Two-level elliptical colonnade",
+ "bounds": { "width": 72, "height": 18, "length": 40 },
+ "maxBlocks": 100000,
+ "output": "artifacts/curved-arcade.svg",
+ "geometry": [
+ {
+ "type": "EllipseRing",
+ "center": { "x": 36, "z": 20 },
+ "radiusX": 28,
+ "radiusZ": 16,
+ "y": 0,
+ "height": 1,
+ "thickness": 1,
+ "block": "stone"
+ },
+ {
+ "type": "PathRepeat",
+ "center": { "x": 36, "z": 20 },
+ "radiusX": 25,
+ "radiusZ": 13,
+ "y": 1,
+ "count": 24,
+ "startAngle": 0,
+ "endAngle": 360,
+ "closed": true,
+ "module": {
+ "voxels": [
+ { "x": -3, "y": 0, "z": 0, "block": "pillar" },
+ { "x": -3, "y": 1, "z": 0, "block": "pillar" },
+ { "x": -3, "y": 2, "z": 0, "block": "pillar" },
+ { "x": -3, "y": 3, "z": 0, "block": "pillar" },
+ { "x": -3, "y": 4, "z": 0, "block": "pillar" },
+ { "x": 3, "y": 0, "z": 0, "block": "pillar" },
+ { "x": 3, "y": 1, "z": 0, "block": "pillar" },
+ { "x": 3, "y": 2, "z": 0, "block": "pillar" },
+ { "x": 3, "y": 3, "z": 0, "block": "pillar" },
+ { "x": 3, "y": 4, "z": 0, "block": "pillar" },
+ { "x": -3, "y": 5, "z": 0, "block": "trim" },
+ { "x": -2, "y": 5, "z": 0, "block": "trim" },
+ { "x": -1, "y": 5, "z": 0, "block": "trim" },
+ { "x": 0, "y": 5, "z": 0, "block": "trim" },
+ { "x": 1, "y": 5, "z": 0, "block": "trim" },
+ { "x": 2, "y": 5, "z": 0, "block": "trim" },
+ { "x": 3, "y": 5, "z": 0, "block": "trim" }
+ ]
+ }
+ },
+ {
+ "type": "PathRepeat",
+ "center": { "x": 36, "z": 20 },
+ "radiusX": 25,
+ "radiusZ": 13,
+ "y": 9,
+ "count": 24,
+ "startAngle": 0,
+ "endAngle": 360,
+ "closed": true,
+ "module": {
+ "voxels": [
+ { "x": -3, "y": 0, "z": 0, "block": "pillar" },
+ { "x": -3, "y": 1, "z": 0, "block": "pillar" },
+ { "x": -3, "y": 2, "z": 0, "block": "pillar" },
+ { "x": -3, "y": 3, "z": 0, "block": "pillar" },
+ { "x": -3, "y": 4, "z": 0, "block": "pillar" },
+ { "x": 3, "y": 0, "z": 0, "block": "pillar" },
+ { "x": 3, "y": 1, "z": 0, "block": "pillar" },
+ { "x": 3, "y": 2, "z": 0, "block": "pillar" },
+ { "x": 3, "y": 3, "z": 0, "block": "pillar" },
+ { "x": 3, "y": 4, "z": 0, "block": "pillar" },
+ { "x": -3, "y": 5, "z": 0, "block": "trim" },
+ { "x": -2, "y": 5, "z": 0, "block": "trim" },
+ { "x": -1, "y": 5, "z": 0, "block": "trim" },
+ { "x": 0, "y": 5, "z": 0, "block": "trim" },
+ { "x": 1, "y": 5, "z": 0, "block": "trim" },
+ { "x": 2, "y": 5, "z": 0, "block": "trim" },
+ { "x": 3, "y": 5, "z": 0, "block": "trim" }
+ ]
+ }
+ }
+ ]
+}
diff --git a/prototypes/issue-110/ellipse-shell.json b/prototypes/issue-110/ellipse-shell.json
new file mode 100644
index 0000000..308a0c7
--- /dev/null
+++ b/prototypes/issue-110/ellipse-shell.json
@@ -0,0 +1,18 @@
+{
+ "name": "Elliptical arena shell",
+ "bounds": { "width": 72, "height": 12, "length": 40 },
+ "maxBlocks": 100000,
+ "output": "artifacts/ellipse-shell.svg",
+ "geometry": [
+ {
+ "type": "EllipseRing",
+ "center": { "x": 36, "z": 20 },
+ "radiusX": 28,
+ "radiusZ": 16,
+ "y": 0,
+ "height": 9,
+ "thickness": 2,
+ "block": "stone"
+ }
+ ]
+}
diff --git a/prototypes/issue-110/geometry-prototype.mjs b/prototypes/issue-110/geometry-prototype.mjs
new file mode 100644
index 0000000..a185b0f
--- /dev/null
+++ b/prototypes/issue-110/geometry-prototype.mjs
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+// Deliberately isolated research prototype. It is not part of @i365dev/craftdag-core.
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+
+function need(condition, message) {
+ if (!condition) throw new Error(message);
+}
+
+function ellipseRing(spec, voxels) {
+ const { center, radiusX, radiusZ, y, height, thickness } = spec;
+ need(Number.isInteger(radiusX) && radiusX > 0 && Number.isInteger(radiusZ) && radiusZ > 0, "EllipseRing radii must be positive integers.");
+ need(Number.isInteger(thickness) && thickness > 0 && thickness < Math.min(radiusX, radiusZ), "EllipseRing thickness must leave an interior.");
+ need(Number.isInteger(height) && height > 0, "EllipseRing height must be a positive integer.");
+ for (let x = Math.floor(center.x - radiusX); x <= Math.ceil(center.x + radiusX); x++) {
+ for (let z = Math.floor(center.z - radiusZ); z <= Math.ceil(center.z + radiusZ); z++) {
+ const outer = ((x - center.x) / radiusX) ** 2 + ((z - center.z) / radiusZ) ** 2;
+ const inner = ((x - center.x) / (radiusX - thickness)) ** 2 + ((z - center.z) / (radiusZ - thickness)) ** 2;
+ if (outer <= 1 && inner >= 1) {
+ for (let dy = 0; dy < height; dy++) add(voxels, x, y + dy, z, spec.block ?? "stone");
+ }
+ }
+ }
+}
+
+function pathRepeat(spec, voxels) {
+ const { center, radiusX, radiusZ, y, count, startAngle = 0, endAngle = 360, closed = false, module } = spec;
+ need(Number.isInteger(count) && count > 0 && count <= 256, "PathRepeat count must be 1..256.");
+ need(Number.isInteger(radiusX) && radiusX > 0 && Number.isInteger(radiusZ) && radiusZ > 0, "PathRepeat ellipse radii must be positive integers.");
+ need(closed ? count >= 3 : count >= 2, "A closed path needs at least 3 placements; an open path needs at least 2.");
+ need(Number.isFinite(startAngle) && Number.isFinite(endAngle) && Math.abs(endAngle - startAngle) <= 360, "PathRepeat angles must span at most one turn.");
+ need(!closed || Math.abs(Math.abs(endAngle - startAngle) - 360) < 1e-9, "A closed ellipse path must span exactly one turn.");
+ need(module && Array.isArray(module.voxels), "PathRepeat requires a bounded voxel module.");
+ const steps = closed ? count : count - 1;
+ for (let i = 0; i < count; i++) {
+ const angle = (startAngle + (endAngle - startAngle) * i / steps) * Math.PI / 180;
+ const x = center.x + radiusX * Math.cos(angle);
+ const z = center.z + radiusZ * Math.sin(angle);
+ const yaw = Math.atan2(radiusZ * Math.cos(angle), -radiusX * Math.sin(angle));
+ for (const p of module.voxels) {
+ const rx = Math.round(p.x * Math.cos(yaw) - p.z * Math.sin(yaw));
+ const rz = Math.round(p.x * Math.sin(yaw) + p.z * Math.cos(yaw));
+ add(voxels, Math.round(x) + rx, y + p.y, Math.round(z) + rz, p.block ?? "stone");
+ }
+ }
+}
+
+function add(voxels, x, y, z, block) { voxels.set(`${x},${y},${z}`, { x, y, z, block }); }
+
+function renderIsometric(voxels, bounds, output) {
+ const points = [...voxels.values()];
+ const project = ({ x, y, z }) => ({ x: (x - z) * 0.866, y: (x + z) * 0.5 - y });
+ const projected = points.map((p) => ({ p, q: project(p) }));
+ const minX = Math.min(...projected.map(({ q }) => q.x)), maxX = Math.max(...projected.map(({ q }) => q.x));
+ const minY = Math.min(...projected.map(({ q }) => q.y)), maxY = Math.max(...projected.map(({ q }) => q.y));
+ const scale = Math.min(900 / Math.max(1, maxX - minX), 650 / Math.max(1, maxY - minY));
+ const color = (block) => block.includes("trim") ? "#bd8b48" : block.includes("pillar") ? "#766c60" : "#a9a79f";
+ const rects = projected.sort((a, b) => (a.p.x + a.p.z + a.p.y) - (b.p.x + b.p.z + b.p.y)).map(({ p, q }) => {
+ const x = 32 + (q.x - minX) * scale, y = 32 + (q.y - minY) * scale;
+ return ``;
+ }).join("\n");
+ const width = Math.ceil((maxX - minX) * scale + 64), height = Math.ceil((maxY - minY) * scale + 64);
+ fs.writeFileSync(output, `\n`);
+}
+
+const input = process.argv[2];
+need(input, "Usage: node geometry-prototype.mjs ");
+const fixture = JSON.parse(fs.readFileSync(input, "utf8"));
+const voxels = new Map();
+for (const item of fixture.geometry) {
+ if (item.type === "EllipseRing") ellipseRing(item, voxels);
+ else if (item.type === "PathRepeat") pathRepeat(item, voxels);
+ else throw new Error(`Unsupported prototype type ${item.type}`);
+}
+for (const { x, y, z } of voxels.values()) {
+ need(x >= 0 && x < fixture.bounds.width && y >= 0 && y < fixture.bounds.height && z >= 0 && z < fixture.bounds.length,
+ `OUT_OF_BOUNDS at ${x},${y},${z}; move the center inward or reduce the radii/module.`);
+}
+need(voxels.size <= (fixture.maxBlocks ?? 100000), `BLOCK_BUDGET_EXCEEDED: ${voxels.size}; lower count/height or shrink the ellipse.`);
+const output = path.resolve(here, fixture.output);
+fs.mkdirSync(path.dirname(output), { recursive: true });
+renderIsometric(voxels, fixture.bounds, output);
+console.log(JSON.stringify({ fixture: fixture.name, voxelCount: voxels.size, output, deterministic: true }, null, 2));
diff --git a/prototypes/issue-110/open-arc-endpoints.json b/prototypes/issue-110/open-arc-endpoints.json
new file mode 100644
index 0000000..2e1a69a
--- /dev/null
+++ b/prototypes/issue-110/open-arc-endpoints.json
@@ -0,0 +1,26 @@
+{
+ "name": "Open elliptical harbor wall probe",
+ "bounds": { "width": 24, "height": 3, "length": 20 },
+ "maxBlocks": 1000,
+ "output": "artifacts/open-arc-endpoints.svg",
+ "geometry": [
+ {
+ "type": "PathRepeat",
+ "center": { "x": 12, "z": 10 },
+ "radiusX": 10,
+ "radiusZ": 7,
+ "y": 1,
+ "count": 9,
+ "startAngle": 0,
+ "endAngle": 180,
+ "closed": false,
+ "module": {
+ "voxels": [
+ { "x": -1, "y": 0, "z": 0, "block": "trim" },
+ { "x": 0, "y": 0, "z": 0, "block": "trim" },
+ { "x": 1, "y": 0, "z": 0, "block": "trim" }
+ ]
+ }
+ }
+ ]
+}