From 969aca92558636100ae381f3a7b8b3d13a97499c Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 27 Sep 2026 15:27:01 +0800 Subject: [PATCH 1/2] feat(core): add ellipse ring and path repeat --- docs/COMPONENT_DESIGN_GUIDE.md | 4 + docs/COMPONENT_PLAN_SPEC.md | 24 + docs/LLM_AUTHORING_CONTRACT.md | 4 + docs/llm.txt | 4 +- packages/core/src/componentPlan.ts | 458 ++++++++++++++++++- packages/core/src/supportAnalysis.ts | 27 ++ packages/core/src/types.ts | 36 ++ packages/core/test/ellipsePathRepeat.test.ts | 313 +++++++++++++ 8 files changed, 855 insertions(+), 15 deletions(-) create mode 100644 packages/core/test/ellipsePathRepeat.test.ts diff --git a/docs/COMPONENT_DESIGN_GUIDE.md b/docs/COMPONENT_DESIGN_GUIDE.md index 617194a..9996512 100644 --- a/docs/COMPONENT_DESIGN_GUIDE.md +++ b/docs/COMPONENT_DESIGN_GUIDE.md @@ -69,6 +69,8 @@ Examples: - `SteppedTier` - `VerticalSetbackVolume` - `SteppedDome` +- `EllipseRing` +- `PathRepeat` - `ArcadeRun` - `TreeCanopy` - `OrganicPatch` @@ -79,6 +81,8 @@ Examples: Add a high-level component only when it captures a reusable pattern that agents otherwise express verbosely or incorrectly. +`EllipseRing` captures true bounded oval shells that cannot be represented by stadium caps without changing curvature. `PathRepeat` is deliberately limited to count-based ellipse sampling of a reusable assembly, reusing the existing arbitrary-angle instance rotation machinery. Keep their semantics Minecraft-oriented; do not expand them into a general curve or CAD language. + ## Component admission checklist Before adding a new component, answer all questions below. diff --git a/docs/COMPONENT_PLAN_SPEC.md b/docs/COMPONENT_PLAN_SPEC.md index 06941bd..11ecd11 100644 --- a/docs/COMPONENT_PLAN_SPEC.md +++ b/docs/COMPONENT_PLAN_SPEC.md @@ -197,6 +197,8 @@ Start with a small component vocabulary: - `VerticalSetbackVolume` - `SteppedDome` - `RectRing` +- `EllipseRing` +- `PathRepeat` - `RailingRun` - `ArcadeRun` - `SupportBracket` @@ -243,6 +245,28 @@ Use `SteppedDome` for bounded Minecraft-style dome approximations, such as Taj-s Use `RectRing` for rectangular ring bands around an open interior: eave bands on tiered towers and pagoda-style roofs, cornices, parapets, plinth bands, balcony fascias, and trim frames around courtyards or roof plates. It is the rectangular counterpart to `CircleRing` and expands into at most four axis-aligned band boxes (`ring_front`, `ring_back`, `ring_left`, `ring_right`) inside one anchored bounding box. Use `options.bandWidth` for the inward band thickness and `options.height` for the band height. The band must leave at least a one-block open interior; use `Platform` instead when the surface should be a full slab. Pair it with `SteppedDome` or `CircleRing` for curved caps. +Use `EllipseRing` for bounded oval shells, arenas, courtyards, and curved walls that need true ellipse curvature rather than a stadium approximation. Its integer `placement.center`, `radiusX`, and `radiusZ` define the ellipse on XZ; `placement.y` is the base layer. `options.height` and `options.thickness` default to 1, and `options.fill` defaults to `hollow`. Hollow rings subtract an inner ellipse inset by the thickness on both axes. Optional `startAngle`/`endAngle` select a partial ellipse using the same 0° (+X), increasing-toward-+Z convention as `CircleRing`. Bounds validation uses the full declared ellipse, including for a partial arc. Expansion emits deterministic single-block `SolidBox` nodes and keeps ordinary material, provenance, and support analysis behavior. + +Use `PathRepeat` to place one reusable assembly at count-based samples along an ellipse, such as arcade bays or colonnade modules. The source is an assembly ID. The path point aligns with the XZ center of the assembly bounds, rounded to the nearest voxel when the pivot falls between blocks; its local Y coordinates are preserved. `orientToTangent: true` rotates local +X onto the analytic ellipse tangent and uses the existing deterministic integer voxel rotation. It defaults to `false`. The schema is intentionally ellipse-only: + +```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 + } +} +``` + +Closed paths require one full turn and emit `count` samples without a duplicate seam endpoint. Open paths require at least two samples over a non-zero sweep and include both endpoints. If `endAngle` is less than `startAngle`, sampling continues forward through 360°. Angles use the ellipse parameterization `x=cx+radiusX*cos(t)`, `z=cz+radiusZ*sin(t)`. The declared ellipse must fit the plan bounds, and every rotated emitted voxel is checked against those bounds. Block budgeting conservatively sums each rotated source-assembly bounds envelope per sample without discounting overlap. `PathRepeat` supports assemblies only; use `Instance` for one-off placements. It does not support arc-length spacing or general curves. + For stepped eave curvature, set `options.cornerRise` to a small non-negative integer: each corner column gains up to that many extra blocks above `options.height`, stepping down over `options.riseSpan` columns (defaults to `cornerRise`, one block per step) toward the middle of each edge. This approximates curved eave edges and corner uplift with deterministic integer segments named `ring__seg`; keep the ring's inner hole aligned with the tower cross-section at that height so the band bears on the walls. The placement must declare vertical room (`options.height + options.cornerRise <= placement.size.height`) or validation rejects the plan. Flat rings without `cornerRise` expand exactly as before. Components that consume a cornerRise ring through `inputs` are connected to every emitted segment, preserving whole-ring bearing in the expanded graph. Use `RailingRun` for repeated posts plus horizontal rails along bridges, decks, walls, balconies, and platforms. It is a bounded run, not a fence/pathfinding simulator. Use `postSpacing`, `includeTopRail`, and `includeMidRail` for controlled detail. diff --git a/docs/LLM_AUTHORING_CONTRACT.md b/docs/LLM_AUTHORING_CONTRACT.md index 5c34fa2..aaa2ca1 100644 --- a/docs/LLM_AUTHORING_CONTRACT.md +++ b/docs/LLM_AUTHORING_CONTRACT.md @@ -122,6 +122,10 @@ Use `RailingRun` for bounded posts and rails along decks, bridges, walls, and ba Use `RectRing` for rectangular eave bands, cornices, parapets, plinth bands, and trim frames with an open interior. Set `options.bandWidth` for band thickness; keep at least a one-block open hole. Use `Platform` for full slabs and `CircleRing` for circular rings. For pagoda/temple eaves, set `options.cornerRise` (1-3) with `options.riseSpan` (defaults to `cornerRise`) so the band steps upward toward each corner, approximating curved eave edges and corner uplift; size the ring so its inner hole matches the wall cross-section at that height, and give the placement enough height for base + rise. Do not invent pagoda-eave or landmark-specific ring types. Components that declare `inputs` on a cornerRise ring are automatically attached to every emitted segment, so no per-segment refs are needed. +Use `EllipseRing` for true oval shells and curved walls. Set `placement.center`, `radiusX`, and `radiusZ`; use `options.height` and `options.thickness` for a hollow wall. It uses a deterministic voxel ellipse, not two circular caps and tangent walls. Partial `startAngle`/`endAngle` use the `CircleRing` angle convention, and bounds must contain the full declared ellipse. + +Use `PathRepeat` for a reusable assembly repeated along an ellipse, such as a curved arcade. Set `placement.path` to `{ "type": "ellipse", "center": { "x": ..., "z": ... }, "radiusX": ..., "radiusZ": ... }`, then set `source`, `count`, `startAngle`, `endAngle`, and `closed`. Closed paths use `count` samples without a duplicate seam endpoint; open paths include both endpoints and need at least two samples. Set `orientToTangent: true` to align assembly local +X to the ellipse tangent. Path points follow the center of the assembly bounds, and every rotated voxel must fit plan bounds. This is count-based ellipse sampling, not arc-length spacing or a generic curve language. + Use `ArcadeRun` for repeated stepped arch-like facade rhythms: colosseum bays, gothic arcades, palace cloisters, bridge arches, and monumental walls. Do not try to describe exact curves; use Minecraft-like blocky arches. Use `SupportBracket` for visible support under overhangs, balconies, lifeboat shelves, awnings, and light bridge edges. Pair it with `structural.maxCantilever` when the bracket is intended to satisfy support diagnostics. Do not use it to silence unrelated floating structures. diff --git a/docs/llm.txt b/docs/llm.txt index 5ca1789..86cce07 100644 --- a/docs/llm.txt +++ b/docs/llm.txt @@ -23,7 +23,7 @@ Authoring rules 10. If validation fails, repair the plan instead of loosening schema or validation. Allowed ComponentPlan components -Foundation, Platform, Beam, RoomShell, Compartment, Corridor, StairRun, FloorStack, TaperedVolume, SteppedTier, VerticalSetbackVolume, SteppedDome, RailingRun, ArcadeRun, SupportBracket, TreeCanopy, OrganicPatch, PathRun, RockCluster, Light, Door, Window, Opening, Portal, GableRoof, FlatRoof, SupportPost, Repeat, Instance. +Foundation, Platform, Beam, RoomShell, Compartment, Corridor, StairRun, FloorStack, TaperedVolume, SteppedTier, VerticalSetbackVolume, SteppedDome, RectRing, EllipseRing, PathRepeat, RailingRun, ArcadeRun, SupportBracket, TreeCanopy, OrganicPatch, PathRun, RockCluster, Light, Door, Window, Opening, Portal, GableRoof, FlatRoof, SupportPost, Repeat, Instance. When to use key components - RoomShell: one exterior or interior hollow volume. @@ -36,6 +36,8 @@ When to use key components - SteppedTier: pyramids, amphitheater seating, podiums, terraces. - VerticalSetbackVolume: tall towers, pagodas, skyscrapers, spires. - SteppedDome: Minecraft-style dome roofs and caps. +- EllipseRing: true bounded oval shell using integer center and radii. +- PathRepeat: count-based ellipse path for a reusable assembly; closed paths omit the seam sample, open paths include both endpoints, and orientToTangent aligns local +X. - ArcadeRun: repeated blocky arches in facades or bridges. - SupportBracket: visible supports under overhangs, balconies, lifeboat shelves. - TreeCanopy, OrganicPatch, PathRun, RockCluster: landscape and garden detail. diff --git a/packages/core/src/componentPlan.ts b/packages/core/src/componentPlan.ts index 5af13a9..f6a06ab 100644 --- a/packages/core/src/componentPlan.ts +++ b/packages/core/src/componentPlan.ts @@ -503,6 +503,32 @@ const CircleRingComponentSchema = z.object({ structural: StructuralIntentSchema.optional(), }).strict(); +const EllipseRingPlacementSchema = z.object({ + center: z.object({ x: NonNegativeIntSchema, z: NonNegativeIntSchema }).strict(), + y: NonNegativeIntSchema, + radiusX: PositiveIntSchema, + radiusZ: PositiveIntSchema, +}).strict(); + +const EllipseRingOptionsSchema = z.object({ + thickness: PositiveIntSchema.optional(), + height: PositiveIntSchema.optional(), + fill: z.enum(["hollow", "solid"]).optional(), + startAngle: z.number().min(0).max(360).optional(), + endAngle: z.number().min(0).max(360).optional(), +}).strict().optional(); + +const EllipseRingComponentSchema = z.object({ + id: z.string().min(1), + type: z.literal("EllipseRing"), + role: z.string().min(1).optional(), + inputs: z.array(ComponentInputSchema).optional(), + placement: EllipseRingPlacementSchema, + materials: MaterialsSchema, + options: EllipseRingOptionsSchema, + structural: StructuralIntentSchema.optional(), +}).strict(); + const DiagonalBeamPlacementSchema = z.object({ from: z.object({ x: NonNegativeIntSchema, y: NonNegativeIntSchema, z: NonNegativeIntSchema }), to: z.object({ x: NonNegativeIntSchema, y: NonNegativeIntSchema, z: NonNegativeIntSchema }), @@ -541,6 +567,32 @@ const RadialRepeatComponentSchema = z.object({ structural: StructuralIntentSchema.optional(), }).strict(); +const EllipsePathSchema = z.object({ + type: z.literal("ellipse"), + center: z.object({ x: NonNegativeIntSchema, z: NonNegativeIntSchema }).strict(), + radiusX: PositiveIntSchema, + radiusZ: PositiveIntSchema, +}).strict(); + +const PathRepeatPlacementSchema = z.object({ + path: EllipsePathSchema, + source: z.string().min(1), + count: PositiveIntSchema, + startAngle: z.number().min(0).max(360), + endAngle: z.number().min(0).max(360), + closed: z.boolean(), + orientToTangent: z.boolean().optional(), +}).strict(); + +const PathRepeatComponentSchema = z.object({ + id: z.string().min(1), + type: z.literal("PathRepeat"), + role: z.string().min(1).optional(), + inputs: z.array(ComponentInputSchema).optional(), + placement: PathRepeatPlacementSchema, + structural: StructuralIntentSchema.optional(), +}).strict(); + const AssetInstancePlacementSchema = z.object({ assetId: z.string().min(1), anchor: ComponentAnchorSchema, @@ -577,6 +629,7 @@ const AssemblyComponentNodeSchema = z.discriminatedUnion("type", [ StairRunComponentSchema, LightComponentSchema, CircleRingComponentSchema, + EllipseRingComponentSchema, RectRingComponentSchema, DiagonalBeamComponentSchema, RadialRepeatComponentSchema, @@ -612,6 +665,7 @@ const ComponentNodeSchema = z.discriminatedUnion("type", [ StairRunComponentSchema, LightComponentSchema, CircleRingComponentSchema, + EllipseRingComponentSchema, RectRingComponentSchema, DiagonalBeamComponentSchema, RadialRepeatComponentSchema, @@ -624,6 +678,7 @@ const ComponentNodeSchema = z.discriminatedUnion("type", [ SupportPostComponentSchema, RepeatComponentSchema, InstanceComponentSchema, + PathRepeatComponentSchema, AssetInstanceComponentSchema, ]); @@ -811,6 +866,16 @@ export function validateComponentPlan(doc: unknown): ComponentPlanDocument { validateBudgetPolicy(parsed, componentMap, assemblyMap); + validatePathRepeatBounds(parsed.components ?? [], parsed.bounds, assemblyMap, parsed.grid?.unitBlocks ?? 1); + for (const section of parsed.sections ?? []) { + validatePathRepeatBounds( + section.components, + section.bounds, + buildSectionAssemblyMap(assemblyMap, section), + parsed.grid?.unitBlocks ?? 1 + ); + } + return parsed; } @@ -926,12 +991,12 @@ function validateComponentSet( if (!inputTarget) { throw unknownRefError(component, `inputs ref "${input.ref}"`, input.ref, componentMap); } - if (inputTarget.type === "Repeat" || inputTarget.type === "Instance" || inputTarget.type === "RadialRepeat") { + if (inputTarget.type === "Repeat" || inputTarget.type === "Instance" || inputTarget.type === "RadialRepeat" || inputTarget.type === "PathRepeat") { throw componentValidationError({ code: "INVALID_NON_PHYSICAL_REFERENCE", componentId: component.id, message: `Component "${component.id}" cannot reference non-physical component "${input.ref}".`, - repairHint: "Reference a concrete component instead of Repeat or Instance.", + repairHint: "Reference a concrete component instead of Repeat, Instance, RadialRepeat, or PathRepeat.", }); } } @@ -950,6 +1015,28 @@ function validateComponentSet( throw unknownRefError(component, `source "${component.placement.source}"`, component.placement.source, componentMap); } + if (component.type === "PathRepeat") { + validatePathRepeatSemantics(component); + if (!assemblyMap.has(component.placement.source)) { + throw componentValidationError({ + code: "UNKNOWN_ASSEMBLY_REF", + componentId: component.id, + message: `PathRepeat "${component.id}" references unknown source assembly "${component.placement.source}".`, + availableRefs: [...assemblyMap.keys()], + repairHint: "Set placement.source to an existing reusable assembly ID.", + }); + } + if (assemblyMap.get(component.placement.source)?.components.length === 0) { + throw componentValidationError({ + code: "EMPTY_PATH_REPEAT_SOURCE", + componentId: component.id, + message: `PathRepeat "${component.id}" source assembly "${component.placement.source}" has no components to repeat.`, + assemblyId: component.placement.source, + repairHint: "Add physical bay components to the source assembly before repeating it.", + }); + } + } + if (component.type === "Instance") { const assembly = assemblyMap.get(component.placement.assembly); if (!assembly) { @@ -970,6 +1057,10 @@ function validateComponentSet( validateAnchoredBounds(bounds, component); } + if (component.type === "EllipseRing") { + validateEllipseRingBounds(component, bounds); + } + validateInteriorLayoutComponent(component); validateShapeComponent(component); validateComponentMaterials(plan, component); @@ -1149,12 +1240,16 @@ function expandComponentToNodes( }]; case "CircleRing": return expandCircleRing(component, unit, expandInputs(component, componentMap)); + case "EllipseRing": + return expandEllipseRing(component, unit, expandInputs(component, componentMap)); case "RectRing": return expandRectRing(component, componentMap, unit); case "DiagonalBeam": return expandDiagonalBeam(component, unit, expandInputs(component, componentMap)); case "RadialRepeat": return expandRadialRepeat(component, componentMap, unit, assemblyMap); + case "PathRepeat": + return expandPathRepeat(component, componentMap, unit, assemblyMap); case "AssetInstance": { const voxel = assets?.[component.placement.assetId]; if (!voxel) return []; @@ -2147,6 +2242,67 @@ function expandCircleRing( return nodes; } +function expandEllipseRing( + component: Extract, + unit: 1 | 2, + inputs: { ref: string }[] +): CraftDagNode[] { + const { center, y, radiusX, radiusZ } = component.placement; + const thickness = (component.options?.thickness ?? 1) * unit; + const height = (component.options?.height ?? 1) * unit; + const fill = component.options?.fill ?? "hollow"; + const outerX = radiusX * unit; + const outerZ = radiusZ * unit; + const innerX = Math.max(0, outerX - thickness); + const innerZ = Math.max(0, outerZ - thickness); + const start = component.options?.startAngle ?? 0; + const end = component.options?.endAngle ?? 360; + const block = material(component, "main", "wall"); + const nodes: CraftDagNode[] = []; + let partIndex = 0; + + for (let dy = 0; dy < height; dy += 1) { + const blockY = y * unit + dy; + for (let dx = -outerX; dx <= outerX; dx += 1) { + for (let dz = -outerZ; dz <= outerZ; dz += 1) { + if (!insideEllipse(dx, dz, outerX, outerZ)) continue; + if (fill === "hollow" && innerX > 0 && innerZ > 0 && insideEllipse(dx, dz, innerX, innerZ)) continue; + + const parameterAngle = normalizeDegrees(Math.atan2(dz / outerZ, dx / outerX) * (180 / Math.PI)); + if (!angleInArc(parameterAngle, start, end)) continue; + + const bx = center.x * unit + dx; + const bz = center.z * unit + dz; + nodes.push({ + id: `${component.id}__ellipse_${partIndex}`, + type: "SolidBox", + inputs, + params: { from: [bx, blockY, bz], to: [bx, blockY, bz], block }, + }); + partIndex += 1; + } + } + } + + return nodes; +} + +function insideEllipse(dx: number, dz: number, radiusX: number, radiusZ: number): boolean { + const rx2 = radiusX * radiusX; + const rz2 = radiusZ * radiusZ; + return dx * dx * rz2 + dz * dz * rx2 <= rx2 * rz2; +} + +function normalizeDegrees(angle: number): number { + return ((angle % 360) + 360) % 360; +} + +function angleInArc(angle: number, start: number, end: number): boolean { + return start <= end + ? angle >= start && angle <= end + : angle >= start || angle <= end; +} + function expandDiagonalBeam( component: Extract, unit: 1 | 2, @@ -2253,6 +2409,82 @@ function expandRadialRepeat( return nodes; } +function pathRepeatSamples(component: Extract): { + x: number; + z: number; + angle: number; + index: number; +}[] { + const { path, count, startAngle, endAngle, closed, orientToTangent } = component.placement; + const sweep = ellipsePathSweep(startAngle, endAngle); + const samples: { x: number; z: number; angle: number; index: number }[] = []; + for (let index = 0; index < count; index += 1) { + const fraction = closed ? index / count : index / (count - 1); + const radians = (startAngle + sweep * fraction) * (Math.PI / 180); + const x = Math.round(path.center.x + path.radiusX * Math.cos(radians)); + const z = Math.round(path.center.z + path.radiusZ * Math.sin(radians)); + const tangentAngle = Math.atan2(path.radiusZ * Math.cos(radians), -path.radiusX * Math.sin(radians)); + samples.push({ x, z, angle: orientToTangent ? tangentAngle : 0, index }); + } + return samples; +} + +function expandPathRepeat( + component: Extract, + componentMap: Map, + unit: 1 | 2, + assemblyMap: Map +): CraftDagNode[] { + return expandPathRepeatNodes(component, componentMap, unit, assemblyMap); +} + +function expandPathRepeatNodes( + component: Extract, + componentMap: Map, + unit: 1 | 2, + assemblyMap: Map +): CraftDagNode[] { + const assembly = assemblyMap.get(component.placement.source); + if (!assembly) { + throw componentValidationError({ + code: "UNKNOWN_ASSEMBLY_REF", + componentId: component.id, + message: `PathRepeat "${component.id}" references unknown source assembly "${component.placement.source}".`, + repairHint: "Set placement.source to an existing reusable assembly ID.", + }); + } + + const localComponentMap = buildComponentMap(assembly.components, `Assembly "${assembly.id}"`); + const localNodes = assembly.components.flatMap((localComponent) => + expandComponentToNodes(localComponent, localComponentMap, assembly.bounds, unit, assemblyMap) + ); + const localWidth = assembly.bounds.width * unit; + const localLength = assembly.bounds.length * unit; + const pivotX = (localWidth - 1) / 2; + const pivotZ = (localLength - 1) / 2; + const externalInputs = expandInputs(component, componentMap); + const expanded: CraftDagNode[] = []; + + for (const sample of pathRepeatSamples(component)) { + const cloneId = `${component.id}__${assembly.id}_${sample.index}`; + const rotatedNodes = localNodes.flatMap((node) => + sample.angle === 0 ? [node] : rotateNodeLocal(node, sample.angle, localWidth, localLength) + ); + const sampleCenterX = sample.x * unit + (unit - 1) / 2; + const sampleCenterZ = sample.z * unit + (unit - 1) / 2; + const shift: Vec3 = [ + Math.round(sampleCenterX - pivotX), + 0, + Math.round(sampleCenterZ - pivotZ), + ]; + for (const node of rotatedNodes) { + expanded.push(namespaceAndShiftNode(node, cloneId, shift, externalInputs)); + } + } + + return expanded; +} + function unknownRefError( component: ComponentNode, field: string, @@ -2285,6 +2517,147 @@ function validateAnchoredBounds(bounds: ComponentSize, component: AnchoredCompon } } +function validateEllipseRingBounds( + component: Extract, + bounds: ComponentSize +): void { + const { center, y, radiusX, radiusZ } = component.placement; + const height = component.options?.height ?? 1; + const minX = center.x - radiusX; + const maxX = center.x + radiusX; + const minZ = center.z - radiusZ; + const maxZ = center.z + radiusZ; + if (minX < 0 || maxX >= bounds.width || minZ < 0 || maxZ >= bounds.length || y + height > bounds.height) { + throw componentValidationError({ + code: "ELLIPSE_RING_OUT_OF_BOUNDS", + componentId: component.id, + message: `EllipseRing "${component.id}" declared ellipse extent x=${minX}..${maxX}, y=${y}..${y + height - 1}, z=${minZ}..${maxZ} exceeds ComponentPlan bounds.`, + repairHint: "Move the ellipse center, reduce either radius or height, or increase the plan bounds to contain the full declared ellipse.", + }); + } +} + +function validatePathRepeatSemantics(component: Extract): void { + const { startAngle, endAngle, closed, count } = component.placement; + const sweep = ellipsePathSweep(startAngle, endAngle); + if (closed && sweep !== 360) { + throw componentValidationError({ + code: "INVALID_PATH_REPEAT_ARC", + componentId: component.id, + message: `PathRepeat "${component.id}" marks an ellipse arc as closed but its angle span is ${sweep} degrees.`, + repairHint: "Use startAngle/endAngle that cover exactly one full turn (0° to 360°), or set closed to false.", + }); + } + if (!closed && (count < 2 || sweep === 0)) { + throw componentValidationError({ + code: "INVALID_PATH_REPEAT_ARC", + componentId: component.id, + message: `Open PathRepeat "${component.id}" needs at least two samples over a non-zero ellipse arc.`, + repairHint: "Set count to at least 2 and choose distinct startAngle and endAngle values.", + }); + } +} + +function ellipsePathSweep(startAngle: number, endAngle: number): number { + if (startAngle === endAngle) return 0; + return endAngle > startAngle ? endAngle - startAngle : endAngle + 360 - startAngle; +} + +function validatePathRepeatBounds( + components: readonly ComponentNode[], + bounds: ComponentSize, + assemblyMap: Map, + unit: 1 | 2 +): void { + const componentMap = buildComponentMap(components, "ComponentPlan"); + const blockBounds: Vec3 = [bounds.width * unit, bounds.height * unit, bounds.length * unit]; + + for (const component of components) { + if (component.type !== "PathRepeat") continue; + const { center, radiusX, radiusZ } = component.placement.path; + const minX = center.x - radiusX; + const maxX = center.x + radiusX; + const minZ = center.z - radiusZ; + const maxZ = center.z + radiusZ; + if (minX < 0 || maxX >= bounds.width || minZ < 0 || maxZ >= bounds.length) { + throw componentValidationError({ + code: "PATH_REPEAT_OUT_OF_BOUNDS", + componentId: component.id, + message: `PathRepeat "${component.id}" ellipse path extent x=${minX}..${maxX}, z=${minZ}..${maxZ} exceeds ComponentPlan bounds.`, + repairHint: "Move the ellipse center, reduce its radius, or increase the plan bounds to contain the full declared path.", + }); + } + + const assembly = assemblyMap.get(component.placement.source)!; + const nodes = expandPathRepeatNodes(component, componentMap, unit, assemblyMap); + for (const node of nodes) { + for (const position of primitiveVoxelPositions(node)) { + if (position.some((value, axis) => value < 0 || value >= blockBounds[axis])) { + const clonePrefix = `${component.id}__${assembly.id}_`; + const indexEnd = node.id.indexOf("__", clonePrefix.length); + const index = node.id.startsWith(clonePrefix) && indexEnd >= 0 + ? node.id.slice(clonePrefix.length, indexEnd) + : "unknown"; + throw componentValidationError({ + code: "PATH_REPEAT_OUT_OF_BOUNDS", + componentId: component.id, + assemblyId: assembly.id, + instanceId: `${component.id}__${assembly.id}_${index}`, + sourceNodeId: node.id, + message: `PathRepeat "${component.id}" source "${assembly.id}" sample ${index} emits voxel [${position.join(", ")}] outside ComponentPlan bounds.`, + repairHint: "Move the ellipse path inward, reduce the source assembly envelope, or enlarge the plan bounds for the rotated bay.", + }); + } + } + } + validatePathRepeatEnvelope(component, assembly, bounds, unit); + } +} + +function validatePathRepeatEnvelope( + component: Extract, + assembly: ComponentAssemblyDefinition, + bounds: ComponentSize, + unit: 1 | 2 +): void { + const localWidth = assembly.bounds.width * unit; + const localHeight = assembly.bounds.height * unit; + const localLength = assembly.bounds.length * unit; + const pivotX = (localWidth - 1) / 2; + const pivotZ = (localLength - 1) / 2; + const blockBounds: Vec3 = [bounds.width * unit, bounds.height * unit, bounds.length * unit]; + + for (const sample of pathRepeatSamples(component)) { + const centerX = sample.x * unit + (unit - 1) / 2; + const centerZ = sample.z * unit + (unit - 1) / 2; + const shiftX = Math.round(centerX - pivotX); + const shiftZ = Math.round(centerZ - pivotZ); + const corners: Vec3[] = [ + [0, 0, 0], + [localWidth - 1, 0, 0], + [0, 0, localLength - 1], + [localWidth - 1, 0, localLength - 1], + ].map(([x, y, z]) => [ + ...rotateLocalPosition([x, y, z], sample.angle, localWidth, localLength), + ] as Vec3).map(([x, y, z]) => [x + shiftX, y, z + shiftZ]); + const minX = Math.min(...corners.map(([x]) => x)); + const maxX = Math.max(...corners.map(([x]) => x)); + const minZ = Math.min(...corners.map(([, , z]) => z)); + const maxZ = Math.max(...corners.map(([, , z]) => z)); + if (minX < 0 || maxX >= blockBounds[0] || localHeight > blockBounds[1] || minZ < 0 || maxZ >= blockBounds[2]) { + throw componentValidationError({ + code: "PATH_REPEAT_OUT_OF_BOUNDS", + componentId: component.id, + assemblyId: assembly.id, + instanceId: `${component.id}__${assembly.id}_${sample.index}`, + message: `PathRepeat "${component.id}" source "${assembly.id}" sample ${sample.index} rotated assembly envelope x=${minX}..${maxX}, y=0..${localHeight - 1}, z=${minZ}..${maxZ} exceeds ComponentPlan bounds.`, + repairHint: "Move the ellipse path inward, reduce the source assembly envelope, or enlarge the plan bounds for the rotated bay.", + }); + } + } +} + + function validateInstanceBounds( bounds: ComponentSize, component: Extract, @@ -2854,6 +3227,14 @@ function estimateExpandedComponentCount( total += component.placement.count - 1; continue; } + if (component.type === "PathRepeat") { + const assembly = assemblyMap.get(component.placement.source); + if (assembly) { + const localComponentMap = buildComponentMap(assembly.components, `Assembly "${assembly.id}"`); + total += component.placement.count * estimateExpandedComponentCount(assembly.components, localComponentMap, assemblyMap); + } + continue; + } total += 1; } @@ -3015,6 +3396,12 @@ function estimateComponentBlocks( const area = fill === "solid" ? Math.PI * r * r : Math.PI * (r * r - Math.max(0, r - t) * Math.max(0, r - t)); return Math.ceil(area) * h; } + case "EllipseRing": { + const width = component.placement.radiusX * 2 * unit + 1; + const length = component.placement.radiusZ * 2 * unit + 1; + const height = (component.options?.height ?? 1) * unit; + return width * length * height; + } case "RectRing": return rectRingPlacements(component) .reduce((total, placement) => total + componentVolume(placement.size) * unit * unit * unit, 0); @@ -3030,6 +3417,8 @@ function estimateComponentBlocks( if (!source) return 0; return estimateComponentBlocks(source, componentMap, assemblyMap, bounds, unit) * (component.placement.count - 1); } + case "PathRepeat": + return estimatePathRepeatBlocks(component, assemblyMap, unit); case "AssetInstance": return 0; default: { @@ -3039,6 +3428,36 @@ function estimateComponentBlocks( } } +function estimatePathRepeatBlocks( + component: Extract, + assemblyMap: Map, + unit: 1 | 2 +): number { + const assembly = assemblyMap.get(component.placement.source); + if (!assembly) return 0; + + const localWidth = assembly.bounds.width * unit; + const localHeight = assembly.bounds.height * unit; + const localLength = assembly.bounds.length * unit; + let estimate = 0; + + for (const sample of pathRepeatSamples(component)) { + const corners: Vec3[] = ([ + [0, 0, 0], + [localWidth - 1, 0, 0], + [0, 0, localLength - 1], + [localWidth - 1, 0, localLength - 1], + ] as Vec3[]).map((point) => rotateLocalPosition(point, sample.angle, localWidth, localLength)); + const minX = Math.min(...corners.map((point) => point[0])); + const maxX = Math.max(...corners.map((point) => point[0])); + const minZ = Math.min(...corners.map((point) => point[2])); + const maxZ = Math.max(...corners.map((point) => point[2])); + estimate += (maxX - minX + 1) * localHeight * (maxZ - minZ + 1); + } + + return estimate; +} + function componentVolume(size: ComponentSize): number { return size.width * size.height * size.length; } @@ -3957,6 +4376,10 @@ function requiredFallbackMaterials(component: ComponentNode): { role: string; va case "DiagonalBeam": case "RadialRepeat": return [{ role: "main", value: "wall" }]; + case "EllipseRing": + return [{ role: "main", value: "wall" }]; + case "PathRepeat": + return []; case "RectRing": return [{ role: "main", value: "roof" }]; default: { @@ -4061,12 +4484,16 @@ function outputPart(component: ComponentNode): string { return "repeat"; case "CircleRing": return "ring_0"; + case "EllipseRing": + return "ellipse_0"; case "RectRing": return (component.options?.cornerRise ?? 0) > 0 ? "ring_front_seg0" : "ring_front"; case "DiagonalBeam": return "beam_0_0_0_0"; case "RadialRepeat": return "repeat"; + case "PathRepeat": + return "path_repeat"; case "Instance": case "AssetInstance": return "instance"; @@ -4469,18 +4896,7 @@ function rotateNodeLocal( localW: number, localL: number ): CraftDagNode[] { - const cos = Math.cos(angle), sin = Math.sin(angle); - const cx = (localW - 1) / 2; - const cz = (localL - 1) / 2; - const rotatePt = (v: [number, number, number]): [number, number, number] => { - const dx = v[0] - cx; - const dz = v[2] - cz; - return [ - Math.round(cx + dx * cos - dz * sin), - v[1], - Math.round(cz + dx * sin + dz * cos), - ]; - }; + const rotatePt = (v: Vec3): Vec3 => rotateLocalPosition(v, angle, localW, localL); // Quarter turns preserve axis-aligned primitives, so keep the compact node. const quarterTurns = Math.round(angle / (Math.PI / 2)); @@ -4545,6 +4961,20 @@ function rotateNodeLocal( }); } +function rotateLocalPosition(position: Vec3, angle: number, localW: number, localL: number): Vec3 { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const centerX = (localW - 1) / 2; + const centerZ = (localL - 1) / 2; + const dx = position[0] - centerX; + const dz = position[2] - centerZ; + return [ + Math.round(centerX + dx * cos - dz * sin), + position[1], + Math.round(centerZ + dx * sin + dz * cos), + ]; +} + function primitiveVoxelPositions(node: CraftDagNode): Vec3[] { const minX = Math.min(node.params.from[0], node.params.to[0]); const maxX = Math.max(node.params.from[0], node.params.to[0]); diff --git a/packages/core/src/supportAnalysis.ts b/packages/core/src/supportAnalysis.ts index d9ea7e4..62e814a 100644 --- a/packages/core/src/supportAnalysis.ts +++ b/packages/core/src/supportAnalysis.ts @@ -407,6 +407,33 @@ function collectStructuralEntryTree( return; } + if (component.type === "PathRepeat") { + const assembly = assemblyMap.get(component.placement.source); + if (!assembly) { + return; + } + const assemblyComponentMap = new Map(assembly.components.map((member) => [member.id, member])); + const nestedInherited: InheritedStructuralIntent = { + ...inherited, + repeatIntent: mergeStructuralIntent(inherited.repeatIntent, component.structural), + }; + for (let index = 0; index < component.placement.count; index += 1) { + for (const assemblyComponent of assembly.components) { + collectStructuralEntryTree( + entries, + registeredPrefixes, + assemblyComponent, + assemblyComponentMap, + NO_ASSEMBLIES, + `${prefix}__${assembly.id}_${index}__${assemblyComponent.id}`, + nestedInherited, + depth + 1 + ); + } + } + return; + } + if (component.type === "Repeat" || component.type === "RadialRepeat") { const source = componentMap.get(component.placement.source); if (!source) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 676047c..e41c7c9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -165,6 +165,38 @@ export interface CircleRingOptions { endAngle?: number; } +export interface EllipseRingPlacement { + center: { x: number; z: number }; + y: number; + radiusX: number; + radiusZ: number; +} + +export interface EllipseRingOptions { + thickness?: number; + height?: number; + fill?: "hollow" | "solid"; + startAngle?: number; + endAngle?: number; +} + +export interface EllipsePath { + type: "ellipse"; + center: { x: number; z: number }; + radiusX: number; + radiusZ: number; +} + +export interface PathRepeatPlacement { + path: EllipsePath; + source: string; + count: number; + startAngle: number; + endAngle: number; + closed: boolean; + orientToTangent?: boolean; +} + export interface DiagonalBeamPlacement { from: { x: number; y: number; z: number }; to: { x: number; y: number; z: number }; @@ -345,6 +377,7 @@ export interface StairRunOptions { export type StairRunComponent = BaseComponentNode<"StairRun", AnchoredComponentPlacement, StairRunOptions>; export type LightComponent = BaseComponentNode<"Light", AnchoredComponentPlacement>; export type CircleRingComponent = BaseComponentNode<"CircleRing", CircleRingPlacement, CircleRingOptions>; +export type EllipseRingComponent = BaseComponentNode<"EllipseRing", EllipseRingPlacement, EllipseRingOptions>; export interface RectRingOptions { bandWidth?: number; height?: number; @@ -356,6 +389,7 @@ export interface RectRingOptions { export type RectRingComponent = BaseComponentNode<"RectRing", AnchoredComponentPlacement, RectRingOptions>; export type DiagonalBeamComponent = BaseComponentNode<"DiagonalBeam", DiagonalBeamPlacement, DiagonalBeamOptions>; export type RadialRepeatComponent = BaseComponentNode<"RadialRepeat", RadialRepeatPlacement>; +export type PathRepeatComponent = BaseComponentNode<"PathRepeat", PathRepeatPlacement>; export type AssetInstanceComponent = BaseComponentNode<"AssetInstance", AssetInstancePlacement>; export type DoorComponent = BaseComponentNode<"Door", WallAttachmentPlacement>; @@ -390,6 +424,7 @@ export type AssemblyComponentNode = | StairRunComponent | LightComponent | CircleRingComponent + | EllipseRingComponent | RectRingComponent | DiagonalBeamComponent | RadialRepeatComponent @@ -405,6 +440,7 @@ export type AssemblyComponentNode = export type ComponentNode = | AssemblyComponentNode | InstanceComponent + | PathRepeatComponent | AssetInstanceComponent; export interface ComponentAssemblyDefinition { diff --git a/packages/core/test/ellipsePathRepeat.test.ts b/packages/core/test/ellipsePathRepeat.test.ts new file mode 100644 index 0000000..9654714 --- /dev/null +++ b/packages/core/test/ellipsePathRepeat.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeComponentPlanSupport, + compileComponentPlan, + ComponentPlanDocument, + expandComponentPlan, + validateComponentPlan, + ValidationError, +} from "../src/index.js"; + +describe("EllipseRing and ellipse PathRepeat", () => { + it("compiles a true hollow ellipse shell rather than a stadium", () => { + const plan: ComponentPlanDocument = { + version: "0.1", + name: "Ellipse Shell Fixture", + policy: { sizeTier: "medium" }, + bounds: { width: 33, height: 8, length: 17 }, + palette: { wall: "minecraft:stone_bricks" }, + components: [{ + id: "ellipse_shell", + type: "EllipseRing", + placement: { center: { x: 16, z: 8 }, y: 1, radiusX: 8, radiusZ: 4 }, + options: { thickness: 1, height: 3 }, + materials: { main: "wall" }, + }], + }; + + const expanded = expandComponentPlan(plan); + const voxel = compileComponentPlan(plan); + const positions = new Set(voxel.blocks.map(({ pos }) => pos.join(","))); + + expect(expanded.nodes.length).toBeGreaterThan(0); + expect(voxel.blocks.length).toBe(expanded.nodes.length); + expect(positions.has("23,1,9")).toBe(true); // Ellipse boundary. + expect(positions.has("22,1,11")).toBe(false); // Inside a radius-4 stadium cap, outside this ellipse. + expect(positions.has("16,1,8")).toBe(false); // Hollow center. + expect(voxel.blocks.every(({ pos }) => pos[0] >= 8 && pos[0] <= 24 && pos[2] >= 4 && pos[2] <= 12)).toBe(true); + expect(voxel.blocks.every(({ sourceNodeId }) => sourceNodeId?.startsWith("ellipse_shell__ellipse_"))).toBe(true); + }); + + it("rejects the full declared ellipse when it exceeds component bounds", () => { + const plan: ComponentPlanDocument = { + version: "0.1", + name: "Out of Bounds Ellipse Ring", + bounds: { width: 7, height: 4, length: 7 }, + palette: { wall: "minecraft:stone" }, + components: [{ + id: "outer_ring", + type: "EllipseRing", + placement: { center: { x: 3, z: 3 }, y: 1, radiusX: 4, radiusZ: 2 }, + }], + }; + + expect(() => validateComponentPlan(plan)).toThrow(ValidationError); + try { + validateComponentPlan(plan); + } catch (error) { + expect((error as ValidationError).details).toEqual([ + expect.objectContaining({ code: "ELLIPSE_RING_OUT_OF_BOUNDS", componentId: "outer_ring" }), + ]); + } + }); + + it("repeats a two-level arcade assembly around a closed ellipse with tangent bays", () => { + const plan: ComponentPlanDocument = { + version: "0.1", + name: "Two-Level Elliptical Arcade Fixture", + policy: { sizeTier: "medium" }, + bounds: { width: 40, height: 8, length: 32 }, + palette: { trim: "minecraft:stone_bricks" }, + assemblies: [{ + id: "bay_module", + bounds: { width: 5, height: 7, length: 3 }, + components: [ + { id: "left_post", type: "SupportPost", placement: { anchor: { x: 0, y: 0, z: 1 }, size: { width: 1, height: 7, length: 1 } }, materials: { main: "trim" } }, + { id: "right_post", type: "SupportPost", placement: { anchor: { x: 4, y: 0, z: 1 }, size: { width: 1, height: 7, length: 1 } }, materials: { main: "trim" } }, + { id: "lower_lintel", type: "Beam", placement: { anchor: { x: 0, y: 3, z: 1 }, size: { width: 5, height: 1, length: 1 } }, materials: { main: "trim" } }, + { id: "upper_lintel", type: "Beam", placement: { anchor: { x: 0, y: 6, z: 1 }, size: { width: 5, height: 1, length: 1 } }, materials: { main: "trim" } }, + ], + }], + components: [{ + id: "arcade", + type: "PathRepeat", + placement: { + path: { type: "ellipse", center: { x: 20, z: 16 }, radiusX: 12, radiusZ: 8 }, + source: "bay_module", + count: 24, + startAngle: 0, + endAngle: 360, + closed: true, + orientToTangent: true, + }, + }], + }; + + const document = expandComponentPlan(plan); + const voxel = compileComponentPlan(plan); + const support = analyzeComponentPlanSupport(plan); + const firstBayBlocks = voxel.blocks.filter(({ sourceNodeId }) => sourceNodeId?.startsWith("arcade__bay_module_0__")); + const generatedBayIds = new Set(document.nodes.map(({ id }) => id.match(/^arcade__bay_module_(\d+)__/)?.[1]).filter(Boolean)); + + expect(generatedBayIds.size).toBe(24); // Closed path has no sample at the duplicate 360° seam. + expect(firstBayBlocks.some(({ pos }) => pos[0] === 32 && pos[2] === 14)).toBe(true); + expect(firstBayBlocks.some(({ pos }) => pos[0] === 32 && pos[2] === 18)).toBe(true); + expect(document.nodes.every(({ params }) => [...params.from, ...params.to].every(Number.isInteger))).toBe(true); + expect(voxel.blocks.every(({ pos }) => pos.every(Number.isInteger))).toBe(true); + expect(support.sourceSummaries.some(({ sourceNodeId }) => sourceNodeId.startsWith("arcade__bay_module_0__"))).toBe(true); + expect(() => validateComponentPlan(plan)).not.toThrow(); + }); + + it("includes both endpoints and exactly nine samples on an open half-ellipse", () => { + const plan: ComponentPlanDocument = { + version: "0.1", + name: "Open Ellipse Path Fixture", + grid: { unitBlocks: 2 }, + bounds: { width: 20, height: 2, length: 14 }, + palette: { floor: "minecraft:stone" }, + assemblies: [{ + id: "marker_module", + bounds: { width: 1, height: 1, length: 1 }, + components: [{ + id: "marker", + type: "Platform", + placement: { anchor: { x: 0, y: 0, z: 0 }, size: { width: 1, height: 1, length: 1 } }, + }], + }], + components: [{ + id: "half_path", + type: "PathRepeat", + placement: { + path: { type: "ellipse", center: { x: 10, z: 7 }, radiusX: 8, radiusZ: 5 }, + source: "marker_module", + count: 9, + startAngle: 0, + endAngle: 180, + closed: false, + orientToTangent: true, + }, + }], + }; + + const document = expandComponentPlan(plan); + const voxel = compileComponentPlan(plan); + const positions = voxel.blocks.map(({ pos }) => pos.join(",")); + const generatedSampleIds = new Set(document.nodes.map(({ id }) => id.match(/^half_path__marker_module_(\d+)__/)?.[1]).filter(Boolean)); + + expect(generatedSampleIds.size).toBe(9); + expect(positions).toHaveLength(72); + expect(new Set(positions).size).toBe(72); + expect(positions).toContain("36,0,14"); + expect(positions).toContain("4,0,14"); + }); + + it("rejects a rotated bay envelope outside bounds with path and sample context", () => { + const plan: ComponentPlanDocument = { + version: "0.1", + name: "Out of Bounds Ellipse Path", + bounds: { width: 12, height: 2, length: 12 }, + palette: { trim: "minecraft:stone" }, + assemblies: [{ + id: "wide_module", + bounds: { width: 7, height: 1, length: 7 }, + components: [{ + id: "corner", + type: "SupportPost", + placement: { anchor: { x: 0, y: 0, z: 0 }, size: { width: 1, height: 1, length: 1 } }, + }], + }], + components: [{ + id: "outer_path", + type: "PathRepeat", + placement: { + path: { type: "ellipse", center: { x: 6, z: 6 }, radiusX: 5, radiusZ: 5 }, + source: "wide_module", + count: 4, + startAngle: 0, + endAngle: 360, + closed: true, + orientToTangent: true, + }, + }], + }; + + expect(() => validateComponentPlan(plan)).toThrow(ValidationError); + try { + validateComponentPlan(plan); + } catch (error) { + expect((error as ValidationError).details).toEqual([ + expect.objectContaining({ + code: "PATH_REPEAT_OUT_OF_BOUNDS", + componentId: "outer_path", + assemblyId: "wide_module", + instanceId: "outer_path__wide_module_0", + sourceNodeId: expect.stringContaining("outer_path__wide_module_0__"), + message: expect.stringContaining("sample 0 emits voxel"), + }), + ]); + } + }); + + it("checks the rotated declared assembly envelope even when its emitted voxel fits", () => { + const plan: ComponentPlanDocument = { + version: "0.1", + name: "Declared Envelope Fixture", + bounds: { width: 12, height: 2, length: 12 }, + palette: { trim: "minecraft:stone" }, + assemblies: [{ + id: "bay", + bounds: { width: 7, height: 1, length: 7 }, + components: [{ + id: "center_post", + type: "SupportPost", + placement: { anchor: { x: 3, y: 0, z: 3 }, size: { width: 1, height: 1, length: 1 } }, + materials: { main: "trim" }, + }], + }], + components: [{ + id: "edge_path", + type: "PathRepeat", + placement: { + path: { type: "ellipse", center: { x: 6, z: 6 }, radiusX: 3, radiusZ: 2 }, + source: "bay", + count: 1, + startAngle: 0, + endAngle: 360, + closed: true, + orientToTangent: false, + }, + }], + }; + + expect(() => validateComponentPlan(plan)).toThrow(ValidationError); + try { + validateComponentPlan(plan); + } catch (error) { + expect((error as ValidationError).details).toEqual([ + expect.objectContaining({ + code: "PATH_REPEAT_OUT_OF_BOUNDS", + componentId: "edge_path", + assemblyId: "bay", + instanceId: "edge_path__bay_0", + message: expect.stringContaining("rotated assembly envelope"), + }), + ]); + } + }); + + it("keeps CircleRing and RadialRepeat circular output conventions stable", () => { + const plan: ComponentPlanDocument = { + version: "0.1", + name: "Circular Regression Fixture", + bounds: { width: 16, height: 4, length: 16 }, + palette: { wall: "minecraft:stone", trim: "minecraft:oak_log" }, + assemblies: [{ + id: "post_module", + bounds: { width: 1, height: 1, length: 1 }, + components: [{ + id: "post", + type: "SupportPost", + placement: { anchor: { x: 0, y: 0, z: 0 }, size: { width: 1, height: 1, length: 1 } }, + materials: { main: "trim" }, + }], + }], + components: [ + { id: "ring", type: "CircleRing", placement: { center: { x: 4, z: 4 }, y: 0, radius: 2 }, options: { thickness: 1 }, materials: { main: "wall" } }, + { id: "seed", type: "Instance", placement: { assembly: "post_module", anchor: { x: 0, y: 0, z: 0 } } }, + { id: "radial", type: "RadialRepeat", placement: { center: { x: 10, z: 10 }, radius: 2, source: "seed", count: 4, rotate: true } }, + ], + }; + + const voxel = compileComponentPlan(plan); + const circleBlocks = voxel.blocks.filter(({ sourceNodeId }) => sourceNodeId?.startsWith("ring__ring_")); + const radialBlocks = voxel.blocks.filter(({ sourceNodeId }) => sourceNodeId?.includes("radial__seed_")); + expect(circleBlocks).toHaveLength(12); + expect(circleBlocks.map(({ pos }) => pos.join(","))).toContain("6,0,4"); + expect(radialBlocks.map(({ pos }) => pos.join(","))).toEqual(expect.arrayContaining([ + [12, 0, 10].join(","), [10, 0, 12].join(","), [8, 0, 10].join(","), [10, 0, 8].join(","), + ])); + }); + + it("uses conservative repeated-assembly envelope estimates for the block budget", () => { + const plan: ComponentPlanDocument = { + version: "0.1", + name: "Ellipse Path Repeat Budget", + bounds: { width: 32, height: 8, length: 32 }, + palette: { trim: "minecraft:stone" }, + assemblies: [{ + id: "large_envelope", + bounds: { width: 32, height: 8, length: 32 }, + components: [{ + id: "center_post", + type: "SupportPost", + placement: { anchor: { x: 16, y: 0, z: 16 }, size: { width: 1, height: 1, length: 1 } }, + materials: { main: "trim" }, + }], + }], + components: [{ + id: "budgeted_path", + type: "PathRepeat", + placement: { + path: { type: "ellipse", center: { x: 16, z: 16 }, radiusX: 1, radiusZ: 1 }, + source: "large_envelope", + count: 5, + startAngle: 0, + endAngle: 180, + closed: false, + }, + }], + }; + + expect(() => validateComponentPlan(plan)).toThrow(/estimated expanded block count/); + }); +}); From 9101f1f8d90784e5d13148b7511ed4c265447b93 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 27 Sep 2026 16:27:27 +0800 Subject: [PATCH 2/2] fix(core): add vertical offset to path repeat --- docs/COMPONENT_DESIGN_GUIDE.md | 2 +- docs/COMPONENT_PLAN_SPEC.md | 5 +- docs/LLM_AUTHORING_CONTRACT.md | 2 +- docs/llm.txt | 2 +- packages/core/src/componentPlan.ts | 20 ++- packages/core/src/types.ts | 1 + packages/core/test/ellipsePathRepeat.test.ts | 173 +++++++++++++++++++ 7 files changed, 192 insertions(+), 13 deletions(-) diff --git a/docs/COMPONENT_DESIGN_GUIDE.md b/docs/COMPONENT_DESIGN_GUIDE.md index 9996512..9d3e6e6 100644 --- a/docs/COMPONENT_DESIGN_GUIDE.md +++ b/docs/COMPONENT_DESIGN_GUIDE.md @@ -81,7 +81,7 @@ Examples: Add a high-level component only when it captures a reusable pattern that agents otherwise express verbosely or incorrectly. -`EllipseRing` captures true bounded oval shells that cannot be represented by stadium caps without changing curvature. `PathRepeat` is deliberately limited to count-based ellipse sampling of a reusable assembly, reusing the existing arbitrary-angle instance rotation machinery. Keep their semantics Minecraft-oriented; do not expand them into a general curve or CAD language. +`EllipseRing` captures true bounded oval shells that cannot be represented by stadium caps without changing curvature. `PathRepeat` is deliberately limited to count-based ellipse sampling of a reusable assembly, reusing the existing arbitrary-angle instance rotation machinery. Keep the assembly Y local and apply global level offsets with non-negative integer `placement.y`; closed-loop `startAngle === endAngle` chooses a sampling phase without duplicating the seam. Keep the component semantics Minecraft-oriented; do not expand them into a general curve or CAD language. ## Component admission checklist diff --git a/docs/COMPONENT_PLAN_SPEC.md b/docs/COMPONENT_PLAN_SPEC.md index 11ecd11..47315b2 100644 --- a/docs/COMPONENT_PLAN_SPEC.md +++ b/docs/COMPONENT_PLAN_SPEC.md @@ -247,7 +247,7 @@ Use `RectRing` for rectangular ring bands around an open interior: eave bands on Use `EllipseRing` for bounded oval shells, arenas, courtyards, and curved walls that need true ellipse curvature rather than a stadium approximation. Its integer `placement.center`, `radiusX`, and `radiusZ` define the ellipse on XZ; `placement.y` is the base layer. `options.height` and `options.thickness` default to 1, and `options.fill` defaults to `hollow`. Hollow rings subtract an inner ellipse inset by the thickness on both axes. Optional `startAngle`/`endAngle` select a partial ellipse using the same 0° (+X), increasing-toward-+Z convention as `CircleRing`. Bounds validation uses the full declared ellipse, including for a partial arc. Expansion emits deterministic single-block `SolidBox` nodes and keeps ordinary material, provenance, and support analysis behavior. -Use `PathRepeat` to place one reusable assembly at count-based samples along an ellipse, such as arcade bays or colonnade modules. The source is an assembly ID. The path point aligns with the XZ center of the assembly bounds, rounded to the nearest voxel when the pivot falls between blocks; its local Y coordinates are preserved. `orientToTangent: true` rotates local +X onto the analytic ellipse tangent and uses the existing deterministic integer voxel rotation. It defaults to `false`. The schema is intentionally ellipse-only: +Use `PathRepeat` to place one reusable assembly at count-based samples along an ellipse, such as arcade bays or colonnade modules. The source is an assembly ID. `placement.y` is a non-negative logical height offset applied globally to every repeated sample as `y * grid.unitBlocks`; assembly member Y coordinates stay local and unchanged. The path point aligns with the XZ center of the assembly bounds, rounded to the nearest voxel when the pivot falls between blocks. `orientToTangent: true` rotates local +X onto the analytic ellipse tangent and uses the existing deterministic integer voxel rotation. It defaults to `false`. The schema is intentionally ellipse-only: ```json { @@ -257,6 +257,7 @@ Use `PathRepeat` to place one reusable assembly at count-based samples along an "path": { "type": "ellipse", "center": { "x": 36, "z": 20 }, "radiusX": 25, "radiusZ": 13 }, "source": "bay_module", "count": 24, + "y": 0, "startAngle": 0, "endAngle": 360, "closed": true, @@ -265,7 +266,7 @@ Use `PathRepeat` to place one reusable assembly at count-based samples along an } ``` -Closed paths require one full turn and emit `count` samples without a duplicate seam endpoint. Open paths require at least two samples over a non-zero sweep and include both endpoints. If `endAngle` is less than `startAngle`, sampling continues forward through 360°. Angles use the ellipse parameterization `x=cx+radiusX*cos(t)`, `z=cz+radiusZ*sin(t)`. The declared ellipse must fit the plan bounds, and every rotated emitted voxel is checked against those bounds. Block budgeting conservatively sums each rotated source-assembly bounds envelope per sample without discounting overlap. `PathRepeat` supports assemblies only; use `Instance` for one-off placements. It does not support arc-length spacing or general curves. +Closed paths emit `count` samples over one full turn without a duplicate seam endpoint. Use equal `startAngle` and `endAngle` values to choose a phase (for example, `7.5` to `7.5`); the legacy `0` to `360` form remains valid. Open paths require at least two samples over a non-zero sweep and include both endpoints. If `endAngle` is less than `startAngle` on an open path, sampling continues forward through 360°. Angles use the ellipse parameterization `x=cx+radiusX*cos(t)`, `z=cz+radiusZ*sin(t)`. The declared ellipse, Y-shifted rotated assembly envelope, and every emitted voxel must fit the plan bounds. Block budgeting conservatively sums each rotated source-assembly bounds envelope per sample without discounting overlap; vertical placement does not reduce that estimate. `PathRepeat` supports assemblies only; use `Instance` for one-off placements. It does not support arc-length spacing or general curves. For stepped eave curvature, set `options.cornerRise` to a small non-negative integer: each corner column gains up to that many extra blocks above `options.height`, stepping down over `options.riseSpan` columns (defaults to `cornerRise`, one block per step) toward the middle of each edge. This approximates curved eave edges and corner uplift with deterministic integer segments named `ring__seg`; keep the ring's inner hole aligned with the tower cross-section at that height so the band bears on the walls. The placement must declare vertical room (`options.height + options.cornerRise <= placement.size.height`) or validation rejects the plan. Flat rings without `cornerRise` expand exactly as before. Components that consume a cornerRise ring through `inputs` are connected to every emitted segment, preserving whole-ring bearing in the expanded graph. diff --git a/docs/LLM_AUTHORING_CONTRACT.md b/docs/LLM_AUTHORING_CONTRACT.md index aaa2ca1..89807f1 100644 --- a/docs/LLM_AUTHORING_CONTRACT.md +++ b/docs/LLM_AUTHORING_CONTRACT.md @@ -124,7 +124,7 @@ Use `RectRing` for rectangular eave bands, cornices, parapets, plinth bands, and Use `EllipseRing` for true oval shells and curved walls. Set `placement.center`, `radiusX`, and `radiusZ`; use `options.height` and `options.thickness` for a hollow wall. It uses a deterministic voxel ellipse, not two circular caps and tangent walls. Partial `startAngle`/`endAngle` use the `CircleRing` angle convention, and bounds must contain the full declared ellipse. -Use `PathRepeat` for a reusable assembly repeated along an ellipse, such as a curved arcade. Set `placement.path` to `{ "type": "ellipse", "center": { "x": ..., "z": ... }, "radiusX": ..., "radiusZ": ... }`, then set `source`, `count`, `startAngle`, `endAngle`, and `closed`. Closed paths use `count` samples without a duplicate seam endpoint; open paths include both endpoints and need at least two samples. Set `orientToTangent: true` to align assembly local +X to the ellipse tangent. Path points follow the center of the assembly bounds, and every rotated voxel must fit plan bounds. This is count-based ellipse sampling, not arc-length spacing or a generic curve language. +Use `PathRepeat` for a reusable assembly repeated along an ellipse, such as a curved arcade. Set `placement.path` to `{ "type": "ellipse", "center": { "x": ..., "z": ... }, "radiusX": ..., "radiusZ": ... }`, then set `source`, `count`, non-negative integer `y`, `startAngle`, `endAngle`, and `closed`. `y` shifts every sample globally by `y * grid.unitBlocks`; keep Y coordinates in the reusable assembly local so the same module can be repeated at multiple levels. Closed paths use `count` samples without a duplicate seam endpoint; equal start/end angles set the phase of the full loop (legacy `0` to `360` is also accepted). Open paths include both endpoints and need at least two samples. Set `orientToTangent: true` to align assembly local +X to the ellipse tangent. The declared shifted assembly envelope and every rotated voxel must fit plan bounds. This is count-based ellipse sampling, not arc-length spacing or a generic curve language. Use `ArcadeRun` for repeated stepped arch-like facade rhythms: colosseum bays, gothic arcades, palace cloisters, bridge arches, and monumental walls. Do not try to describe exact curves; use Minecraft-like blocky arches. diff --git a/docs/llm.txt b/docs/llm.txt index 86cce07..6d00b71 100644 --- a/docs/llm.txt +++ b/docs/llm.txt @@ -37,7 +37,7 @@ When to use key components - VerticalSetbackVolume: tall towers, pagodas, skyscrapers, spires. - SteppedDome: Minecraft-style dome roofs and caps. - EllipseRing: true bounded oval shell using integer center and radii. -- PathRepeat: count-based ellipse path for a reusable assembly; closed paths omit the seam sample, open paths include both endpoints, and orientToTangent aligns local +X. +- PathRepeat: count-based ellipse path for a reusable assembly; non-negative integer `placement.y` globally offsets local assembly Y, closed paths omit the seam and allow phase with equal start/end angles, open paths include both endpoints, and orientToTangent aligns local +X. - ArcadeRun: repeated blocky arches in facades or bridges. - SupportBracket: visible supports under overhangs, balconies, lifeboat shelves. - TreeCanopy, OrganicPatch, PathRun, RockCluster: landscape and garden detail. diff --git a/packages/core/src/componentPlan.ts b/packages/core/src/componentPlan.ts index f6a06ab..7cc4d9a 100644 --- a/packages/core/src/componentPlan.ts +++ b/packages/core/src/componentPlan.ts @@ -578,6 +578,7 @@ const PathRepeatPlacementSchema = z.object({ path: EllipsePathSchema, source: z.string().min(1), count: PositiveIntSchema, + y: NonNegativeIntSchema, startAngle: z.number().min(0).max(360), endAngle: z.number().min(0).max(360), closed: z.boolean(), @@ -2416,7 +2417,7 @@ function pathRepeatSamples(component: Extract): void { const { startAngle, endAngle, closed, count } = component.placement; const sweep = ellipsePathSweep(startAngle, endAngle); - if (closed && sweep !== 360) { + if (closed && sweep !== 360 && startAngle !== endAngle) { throw componentValidationError({ code: "INVALID_PATH_REPEAT_ARC", componentId: component.id, - message: `PathRepeat "${component.id}" marks an ellipse arc as closed but its angle span is ${sweep} degrees.`, - repairHint: "Use startAngle/endAngle that cover exactly one full turn (0° to 360°), or set closed to false.", + message: `PathRepeat "${component.id}" marks an ellipse arc as closed but its angles do not describe a full turn.`, + repairHint: "For a closed path, use the same startAngle/endAngle as the phase or retain the legacy 0° to 360° pair; set closed to false for a partial arc.", }); } if (!closed && (count < 2 || sweep === 0)) { @@ -2553,7 +2554,7 @@ function validatePathRepeatSemantics(component: Extract [x + shiftX, y, z + shiftZ]); const minX = Math.min(...corners.map(([x]) => x)); const maxX = Math.max(...corners.map(([x]) => x)); + const minY = shiftY; + const maxY = shiftY + localHeight - 1; const minZ = Math.min(...corners.map(([, , z]) => z)); const maxZ = Math.max(...corners.map(([, , z]) => z)); - if (minX < 0 || maxX >= blockBounds[0] || localHeight > blockBounds[1] || minZ < 0 || maxZ >= blockBounds[2]) { + if (minX < 0 || maxX >= blockBounds[0] || minY < 0 || maxY >= blockBounds[1] || minZ < 0 || maxZ >= blockBounds[2]) { throw componentValidationError({ code: "PATH_REPEAT_OUT_OF_BOUNDS", componentId: component.id, assemblyId: assembly.id, instanceId: `${component.id}__${assembly.id}_${sample.index}`, - message: `PathRepeat "${component.id}" source "${assembly.id}" sample ${sample.index} rotated assembly envelope x=${minX}..${maxX}, y=0..${localHeight - 1}, z=${minZ}..${maxZ} exceeds ComponentPlan bounds.`, + message: `PathRepeat "${component.id}" source "${assembly.id}" sample ${sample.index} rotated assembly envelope x=${minX}..${maxX}, y=${minY}..${maxY}, z=${minZ}..${maxZ} exceeds ComponentPlan bounds.`, repairHint: "Move the ellipse path inward, reduce the source assembly envelope, or enlarge the plan bounds for the rotated bay.", }); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e41c7c9..b72c1a4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -191,6 +191,7 @@ export interface PathRepeatPlacement { path: EllipsePath; source: string; count: number; + y: number; startAngle: number; endAngle: number; closed: boolean; diff --git a/packages/core/test/ellipsePathRepeat.test.ts b/packages/core/test/ellipsePathRepeat.test.ts index 9654714..d1916b6 100644 --- a/packages/core/test/ellipsePathRepeat.test.ts +++ b/packages/core/test/ellipsePathRepeat.test.ts @@ -85,6 +85,7 @@ describe("EllipseRing and ellipse PathRepeat", () => { path: { type: "ellipse", center: { x: 20, z: 16 }, radiusX: 12, radiusZ: 8 }, source: "bay_module", count: 24, + y: 0, startAngle: 0, endAngle: 360, closed: true, @@ -108,6 +109,77 @@ describe("EllipseRing and ellipse PathRepeat", () => { expect(() => validateComponentPlan(plan)).not.toThrow(); }); + it("reuses one assembly definition at two global Y levels and allows closed-loop phase", () => { + const plan: ComponentPlanDocument = { + version: "0.1", + name: "Vertically Reused Phased Ellipse Assembly", + grid: { unitBlocks: 2 }, + bounds: { width: 20, height: 6, length: 14 }, + palette: { floor: "minecraft:stone" }, + assemblies: [{ + id: "bay", + bounds: { width: 1, height: 1, length: 1 }, + components: [{ + id: "marker", + type: "Platform", + placement: { anchor: { x: 0, y: 0, z: 0 }, size: { width: 1, height: 1, length: 1 } }, + }], + }], + components: [ + { + id: "lower_arcade", + type: "PathRepeat", + placement: { + path: { type: "ellipse", center: { x: 10, z: 7 }, radiusX: 6, radiusZ: 3 }, + source: "bay", + count: 8, + y: 0, + startAngle: 22.5, + endAngle: 22.5, + closed: true, + orientToTangent: true, + }, + }, + { + id: "upper_arcade", + type: "PathRepeat", + placement: { + path: { type: "ellipse", center: { x: 10, z: 7 }, radiusX: 6, radiusZ: 3 }, + source: "bay", + count: 8, + y: 3, + startAngle: 22.5, + endAngle: 22.5, + closed: true, + orientToTangent: true, + }, + }, + ], + }; + + const document = expandComponentPlan(plan); + const voxel = compileComponentPlan(plan); + const lowerIds = new Set(document.nodes.map(({ id }) => id.match(/^lower_arcade__bay_(\d+)__/)?.[1]).filter(Boolean)); + const upperIds = new Set(document.nodes.map(({ id }) => id.match(/^upper_arcade__bay_(\d+)__/)?.[1]).filter(Boolean)); + const xzAtEachLevel = new Map>(); + for (const { pos } of voxel.blocks) { + const level = pos[1] < 6 ? pos[1] : pos[1] - 6; + const positions = xzAtEachLevel.get(level) ?? new Set(); + positions.add(`${pos[0]},${pos[2]}`); + xzAtEachLevel.set(level, positions); + } + + expect(lowerIds.size).toBe(8); + expect(upperIds.size).toBe(8); + expect(new Set(voxel.blocks.map(({ pos }) => pos[1]))).toEqual(new Set([0, 1, 6, 7])); + expect(voxel.blocks.some(({ sourceNodeId, pos }) => sourceNodeId?.startsWith("lower_arcade__bay_0__") && pos[0] === 32 && pos[2] === 16)).toBe(true); + const positionsByLevel = [...xzAtEachLevel.values()]; + expect(positionsByLevel).toHaveLength(2); + expect(positionsByLevel[0]?.size).toBe(32); + expect(positionsByLevel[1]).toEqual(positionsByLevel[0]); + expect(() => validateComponentPlan(plan)).not.toThrow(); + }); + it("includes both endpoints and exactly nine samples on an open half-ellipse", () => { const plan: ComponentPlanDocument = { version: "0.1", @@ -131,6 +203,7 @@ describe("EllipseRing and ellipse PathRepeat", () => { path: { type: "ellipse", center: { x: 10, z: 7 }, radiusX: 8, radiusZ: 5 }, source: "marker_module", count: 9, + y: 0, startAngle: 0, endAngle: 180, closed: false, @@ -173,6 +246,7 @@ describe("EllipseRing and ellipse PathRepeat", () => { path: { type: "ellipse", center: { x: 6, z: 6 }, radiusX: 5, radiusZ: 5 }, source: "wide_module", count: 4, + y: 0, startAngle: 0, endAngle: 360, closed: true, @@ -221,6 +295,7 @@ describe("EllipseRing and ellipse PathRepeat", () => { path: { type: "ellipse", center: { x: 6, z: 6 }, radiusX: 3, radiusZ: 2 }, source: "bay", count: 1, + y: 0, startAngle: 0, endAngle: 360, closed: true, @@ -245,6 +320,103 @@ describe("EllipseRing and ellipse PathRepeat", () => { } }); + it("rejects a shifted assembly envelope that leaves plan bounds even if its emitted voxel fits", () => { + const plan: ComponentPlanDocument = { + version: "0.1", + name: "Shifted Vertical Envelope Fixture", + bounds: { width: 12, height: 3, length: 12 }, + palette: { trim: "minecraft:stone" }, + assemblies: [{ + id: "local_bay", + bounds: { width: 1, height: 2, length: 1 }, + components: [{ + id: "base_post", + type: "SupportPost", + placement: { anchor: { x: 0, y: 0, z: 0 }, size: { width: 1, height: 1, length: 1 } }, + materials: { main: "trim" }, + }], + }], + components: [{ + id: "raised_path", + type: "PathRepeat", + placement: { + path: { type: "ellipse", center: { x: 6, z: 6 }, radiusX: 3, radiusZ: 2 }, + source: "local_bay", + count: 1, + y: 2, + startAngle: 0, + endAngle: 360, + closed: true, + orientToTangent: false, + }, + }], + }; + + expect(() => validateComponentPlan(plan)).toThrow(ValidationError); + try { + validateComponentPlan(plan); + } catch (error) { + expect((error as ValidationError).details).toEqual([ + expect.objectContaining({ + code: "PATH_REPEAT_OUT_OF_BOUNDS", + componentId: "raised_path", + assemblyId: "local_bay", + instanceId: "raised_path__local_bay_0", + message: expect.stringContaining("y=2..3"), + }), + ]); + } + }); + + it("reports an emitted voxel outside height after applying placement.y", () => { + const plan: ComponentPlanDocument = { + version: "0.1", + name: "Shifted Emitted Voxel Bounds Fixture", + bounds: { width: 12, height: 3, length: 12 }, + palette: { trim: "minecraft:stone" }, + assemblies: [{ + id: "post_module", + bounds: { width: 1, height: 1, length: 1 }, + components: [{ + id: "post", + type: "SupportPost", + placement: { anchor: { x: 0, y: 0, z: 0 }, size: { width: 1, height: 1, length: 1 } }, + materials: { main: "trim" }, + }], + }], + components: [{ + id: "high_path", + type: "PathRepeat", + placement: { + path: { type: "ellipse", center: { x: 6, z: 6 }, radiusX: 3, radiusZ: 2 }, + source: "post_module", + count: 1, + y: 3, + startAngle: 0, + endAngle: 360, + closed: true, + orientToTangent: false, + }, + }], + }; + + expect(() => validateComponentPlan(plan)).toThrow(ValidationError); + try { + validateComponentPlan(plan); + } catch (error) { + expect((error as ValidationError).details).toEqual([ + expect.objectContaining({ + code: "PATH_REPEAT_OUT_OF_BOUNDS", + componentId: "high_path", + assemblyId: "post_module", + instanceId: "high_path__post_module_0", + sourceNodeId: expect.stringContaining("high_path__post_module_0__"), + message: expect.stringContaining("emits voxel"), + }), + ]); + } + }); + it("keeps CircleRing and RadialRepeat circular output conventions stable", () => { const plan: ComponentPlanDocument = { version: "0.1", @@ -301,6 +473,7 @@ describe("EllipseRing and ellipse PathRepeat", () => { path: { type: "ellipse", center: { x: 16, z: 16 }, radiusX: 1, radiusZ: 1 }, source: "large_envelope", count: 5, + y: 0, startAngle: 0, endAngle: 180, closed: false,