diff --git a/docs/COMPONENT_DESIGN_GUIDE.md b/docs/COMPONENT_DESIGN_GUIDE.md index 617194a..9d3e6e6 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 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 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..47315b2 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,29 @@ 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. `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 +{ + "id": "arcade_bays", + "type": "PathRepeat", + "placement": { + "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, + "orientToTangent": true + } +} +``` + +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. 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..89807f1 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`, 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. 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..6d00b71 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; 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 5af13a9..7cc4d9a 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,33 @@ 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, + y: NonNegativeIntSchema, + 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 +630,7 @@ const AssemblyComponentNodeSchema = z.discriminatedUnion("type", [ StairRunComponentSchema, LightComponentSchema, CircleRingComponentSchema, + EllipseRingComponentSchema, RectRingComponentSchema, DiagonalBeamComponentSchema, RadialRepeatComponentSchema, @@ -612,6 +666,7 @@ const ComponentNodeSchema = z.discriminatedUnion("type", [ StairRunComponentSchema, LightComponentSchema, CircleRingComponentSchema, + EllipseRingComponentSchema, RectRingComponentSchema, DiagonalBeamComponentSchema, RadialRepeatComponentSchema, @@ -624,6 +679,7 @@ const ComponentNodeSchema = z.discriminatedUnion("type", [ SupportPostComponentSchema, RepeatComponentSchema, InstanceComponentSchema, + PathRepeatComponentSchema, AssetInstanceComponentSchema, ]); @@ -811,6 +867,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 +992,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 +1016,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 +1058,10 @@ function validateComponentSet( validateAnchoredBounds(bounds, component); } + if (component.type === "EllipseRing") { + validateEllipseRingBounds(component, bounds); + } + validateInteriorLayoutComponent(component); validateShapeComponent(component); validateComponentMaterials(plan, component); @@ -1149,12 +1241,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 +2243,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 +2410,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 = closed ? 360 : 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), + component.placement.y * unit, + 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 +2518,150 @@ 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 && startAngle !== endAngle) { + throw componentValidationError({ + code: "INVALID_PATH_REPEAT_ARC", + componentId: component.id, + 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)) { + 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 for an open arc.", + }); + } +} + +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 shiftY = component.placement.y * 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 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] || 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=${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.", + }); + } + } +} + + function validateInstanceBounds( bounds: ComponentSize, component: Extract, @@ -2854,6 +3231,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 +3400,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 +3421,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 +3432,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 +4380,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 +4488,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 +4900,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 +4965,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..b72c1a4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -165,6 +165,39 @@ 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; + y: 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 +378,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 +390,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 +425,7 @@ export type AssemblyComponentNode = | StairRunComponent | LightComponent | CircleRingComponent + | EllipseRingComponent | RectRingComponent | DiagonalBeamComponent | RadialRepeatComponent @@ -405,6 +441,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..d1916b6 --- /dev/null +++ b/packages/core/test/ellipsePathRepeat.test.ts @@ -0,0 +1,486 @@ +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, + y: 0, + 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("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", + 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, + y: 0, + 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, + y: 0, + 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, + y: 0, + 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("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", + 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, + y: 0, + startAngle: 0, + endAngle: 180, + closed: false, + }, + }], + }; + + expect(() => validateComponentPlan(plan)).toThrow(/estimated expanded block count/); + }); +});