From f2d3d72c266eaae23d6009feccbd8630c7634d39 Mon Sep 17 00:00:00 2001 From: reposlayer Date: Sun, 16 Aug 2026 06:24:18 +0200 Subject: [PATCH 1/4] Add the Axiom Relay straight-wall group and its loose-run kit Ten of the twenty-four briefs under docs/assets/reusable/architecture/walls had no model source. This adds ten: the three run lengths, the four opening and service variants, an obtuse turn, a faceted arc, and a cast boundary wall. Nine of the ten are cut from buildWallSection, the same function the prefab shells use, so their thickness, datum heights, cassette rhythm, skirt, and stepped opening frames cannot drift away from the shells they butt against. A wall family that re-authored any of that would produce pieces that look like the shells and do not line up with them. axiom-wall-kit therefore adds only what a loose run needs and a shell does not: a footing to stand on, end returns closing the cassettes, a coping, and the shared capture rig, which reuses the modular kit's existing lighting exactly so a loose wall can be compared to the shell it joins. low-concrete-wall deliberately does not use the cassette system. A boundary wall is cast in bays against formwork; building it from framed composite panels would put a 1.1 m tall building facade in a car park. Modules: wall-2-m, wall-4-m, wall-8-m, wall-with-door, wall-with-window, wall-with-vent, wall-with-inset-panel, angled-wall, curved-wall, low-concrete-wall. typecheck, build, and the 49 existing tests pass; the registry builds 179 items from 174 models. Co-Authored-By: Claude Opus 5 --- .changeset/architecture-walls-wave.md | 18 ++ assets/prototypes/angled-wall/model.ts | 61 +++++ assets/prototypes/axiom-wall-kit/index.ts | 220 ++++++++++++++++++ assets/prototypes/axiom-wall-kit/straight.ts | 112 +++++++++ assets/prototypes/curved-wall/model.ts | 63 +++++ assets/prototypes/low-concrete-wall/model.ts | 87 +++++++ assets/prototypes/wall-2-m/model.ts | 34 +++ assets/prototypes/wall-4-m/model.ts | 32 +++ assets/prototypes/wall-8-m/model.ts | 33 +++ assets/prototypes/wall-with-door/model.ts | 41 ++++ .../prototypes/wall-with-inset-panel/model.ts | 66 ++++++ assets/prototypes/wall-with-vent/model.ts | 66 ++++++ assets/prototypes/wall-with-window/model.ts | 37 +++ registries/scifi-kit/src/build.ts | 1 + 14 files changed, 871 insertions(+) create mode 100644 .changeset/architecture-walls-wave.md create mode 100644 assets/prototypes/angled-wall/model.ts create mode 100644 assets/prototypes/axiom-wall-kit/index.ts create mode 100644 assets/prototypes/axiom-wall-kit/straight.ts create mode 100644 assets/prototypes/curved-wall/model.ts create mode 100644 assets/prototypes/low-concrete-wall/model.ts create mode 100644 assets/prototypes/wall-2-m/model.ts create mode 100644 assets/prototypes/wall-4-m/model.ts create mode 100644 assets/prototypes/wall-8-m/model.ts create mode 100644 assets/prototypes/wall-with-door/model.ts create mode 100644 assets/prototypes/wall-with-inset-panel/model.ts create mode 100644 assets/prototypes/wall-with-vent/model.ts create mode 100644 assets/prototypes/wall-with-window/model.ts diff --git a/.changeset/architecture-walls-wave.md b/.changeset/architecture-walls-wave.md new file mode 100644 index 00000000..a0daf358 --- /dev/null +++ b/.changeset/architecture-walls-wave.md @@ -0,0 +1,18 @@ +--- +"@scifi-kit/registry": patch +--- + +Add the Axiom Relay straight-wall group: ten procedural architecture modules +covering three run lengths, four opening and service variants, an obtuse turn, a +faceted arc, and a cast boundary wall. + +The nine framed modules are cut from `buildWallSection` — the same authoring +function the prefab shells use — so their thickness, datum heights, cassette +rhythm, skirt, and stepped opening frames cannot drift away from the shells they +butt against. The new `axiom-wall-kit` support item adds only what a *loose* run +needs and a shell does not: a footing to stand on, end returns that close the +cassettes, a coping, and the shared capture rig. + +`low-concrete-wall` deliberately does not use the cassette system. A boundary +wall is cast concrete poured in bays, not a framed composite panel, and building +it from cassettes would put a 1.1 m tall building facade in a car park. diff --git a/assets/prototypes/angled-wall/model.ts b/assets/prototypes/angled-wall/model.ts new file mode 100644 index 00000000..177f5702 --- /dev/null +++ b/assets/prototypes/angled-wall/model.ts @@ -0,0 +1,61 @@ +import { wallFace } from '../axiom-modular-kit/parts.ts' +import { createWallModel, createWallPreview, WALL, type WallModel, type WallPreview } from '../axiom-wall-kit/index.ts' +import { coping, endReturn, footing } from '../axiom-wall-kit/straight.ts' + +/** + * Axiom Relay angled wall — two runs meeting at an obtuse corner. + * + * The kit already has 90-degree corners; what it has no way to express is a plan + * that turns by less than a right angle — a street that bends, a compound that + * follows a contour, a frontage cut back from a junction. This module is that + * turn: two 2 m runs at 135 degrees, sharing one post at the vertex. + * + * The vertex post is the whole design problem. Two cassette runs mitred directly + * into each other leave a feather edge at the inside of the angle and a visible + * seam at the outside, so instead both runs stop short of the vertex and a + * full-height post fills it. That is also how the kit's own right-angle corners + * are built, which is why this piece sits beside them without argument. + */ + +const LEG = 2 +const ANGLE = Math.PI / 4 +const ENVELOPE = { + width: (LEG + 0.4) * 2 * Math.cos(ANGLE / 2), + depth: (LEG + 0.4) * Math.sin(ANGLE / 2) + WALL.thickness + 0.3, + height: WALL.top + 0.12, +} + +export function createModel(): WallModel { + return createWallModel({ + id: 'angled-wall', + envelope: ENVELOPE, + build: ({ m, part, section }) => { + const wall = part('wall') + // Each leg runs outward from the vertex at the origin, splayed half the + // turn either way, so the module is symmetric about +Z and the vertex post + // is the only part either leg has to agree with. + for (const side of [-1, 1] as const) { + const yaw = side * ANGLE / 2 + const face = wallFace([0, 0, -WALL.thickness * 0.5], yaw) + const u0 = side < 0 ? -(LEG + 0.24) : 0.24 + const u1 = side < 0 ? -0.24 : LEG + 0.24 + footing(wall, m, face, u0 - 0.11, u1 + 0.11) + section(wall, face, u0, u1) + endReturn(wall, m, face, side < 0 ? u0 : u1, side < 0 ? -1 : 1) + coping(wall, m, face, u0 - 0.11, u1 + 0.11) + } + + // The vertex post, filling the turn both legs stopped short of. + const post = part('vertex-post') + const face = wallFace([0, 0, -WALL.thickness * 0.5], 0) + footing(post, m, face, -0.34, 0.34) + endReturn(post, m, face, 0, 1) + endReturn(post, m, face, 0, -1) + coping(post, m, face, -0.34, 0.34) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): WallPreview { + return createWallPreview(createModel(), ENVELOPE, { distance: 9.4, yaw: -0.5, ...options }) +} diff --git a/assets/prototypes/axiom-wall-kit/index.ts b/assets/prototypes/axiom-wall-kit/index.ts new file mode 100644 index 00000000..71ed9a0b --- /dev/null +++ b/assets/prototypes/axiom-wall-kit/index.ts @@ -0,0 +1,220 @@ +import { + Color, + DirectionalLight, + Group, + HemisphereLight, + Mesh, + MeshPhysicalMaterial, + PerspectiveCamera, + Scene, + Vector3, + type BufferGeometry, +} from 'three/webgpu' + +import { + WEAR_ATTRIBUTES, + bakeOcclusion, + bakeSurfaceAttributes, + createWearMaterial, + mergeStaticByMaterial, + type WearProfile, +} from '../../../src/asset-forge/generator/index.ts' +import { KIT_BUILD, createKitMaterials, wallFace, type KitMaterials, type WallFace } from '../axiom-modular-kit/parts.ts' +import { buildWallSection, type WallSectionOptions } from '../axiom-modular-kit/layout.ts' +import { AXIOM_KIT } from '../axiom-modular-kit/contract.ts' + +/** + * The straight-wall family: loose wall runs cut from the same authoring code the + * prefab shells use. + * + * The important decision here is what this kit does *not* own. The Axiom wall — + * its thickness, its datum heights, its cassette rhythm, its skirt, and the + * stepped frame around a door or window — already exists in `axiom-modular-kit` + * and is what the prefab shells are cut from. A wall family that re-authored any + * of that would produce pieces that look like the shells and do not line up with + * them, which is worse than having no loose walls at all. + * + * So every module in this group is `buildWallSection` called with a length and + * at most one opening. What this kit adds is only the parts a *loose* run needs + * and a shell does not: end conditions, a free-standing footing, and the shared + * capture rig. + */ + +export const WALL = Object.freeze({ + thickness: KIT_BUILD.wallThickness, + base: KIT_BUILD.floorY, + top: KIT_BUILD.wallTop, + /** Height above the module's own ground plane. */ + height: KIT_BUILD.wallTop - KIT_BUILD.floorY, + grid: AXIOM_KIT.grid, + increment: AXIOM_KIT.structuralIncrement, + front: '+Z', + pivot: 'centre-ground', +} as const) + +/** Wear tiers matching the modular kit's maintained, clean-condition read. */ +const PROFILES = (m: KitMaterials): Map => new Map([ + [m.shell, { rub: 0.012, grime: 0.006, scratch: 0 }], + [m.porcelain, { rub: 0.008, grime: 0.005, scratch: 0 }], + [m.graphite, { rub: 0.014, grime: 0.012, scratch: 0 }], + [m.deck, { rub: 0.016, grime: 0.02, scratch: 0 }], + [m.ink, { rub: 0.012, grime: 0.045, scratch: 0.003 }], +]) + +export interface WallEnvelope { + readonly width: number + readonly depth: number + readonly height: number +} + +export interface WallModel { + readonly root: Group + readonly parts: Record + update(deltaSeconds: number): void + dispose(): void +} + +export interface WallBuild { + readonly id: string + readonly envelope: WallEnvelope + build(context: { + root: Group + m: KitMaterials + part(name: string): Group + /** A face on the module's own centreline, for a run of the given yaw. */ + face(origin: [number, number, number], yaw: number): WallFace + /** The canonical wall author, pre-bound to this module's root. */ + section(parent: Group, face: WallFace, u0: number, u1: number, options?: WallSectionOptions): void + }): { readonly tick?: (elapsed: number) => void } | void +} + +export function createWallModel(spec: WallBuild): WallModel { + const { materials, handles } = createKitMaterials(6_200 + spec.id.length * 173) + const root = new Group() + root.name = `AXR_ARCH_${spec.id.toUpperCase()}_ROOT_DEFAULT` + + const parts: Record = {} + const part = (name: string): Group => { + const existing = parts[name] + if (existing) return existing + const group = new Group() + group.name = `AXR_ARCH_${spec.id.toUpperCase()}_PART_${name.toUpperCase()}_DEFAULT` + parts[name] = group + root.add(group) + return group + } + + const built = spec.build({ + root, + m: materials, + part, + face: (origin, yaw) => wallFace(origin, yaw), + section: (parent, face, u0, u1, options) => buildWallSection(parent, materials, face, u0, u1, options), + }) + + root.userData.wallKit = { + version: 1, + moduleId: spec.id, + pivot: WALL.pivot, + front: WALL.front, + envelope: spec.envelope, + grid: WALL.grid, + increment: WALL.increment, + } + + bakeOcclusion(root, { reach: 0.18 }) + const profiles = PROFILES(materials) + bakeSurfaceAttributes(root, profiles) + const wearMaterial = createWearMaterial({ + name: `${spec.id} / maintained kit surfaces`, + clearcoat: 0.1, + clearcoatRoughness: 0.5, + }) + const worn = new Set(profiles.keys()) + const geometries: BufferGeometry[] = [...mergeStaticByMaterial(root, { + resolveMaterial: (source: MeshPhysicalMaterial) => worn.has(source) ? wearMaterial : source, + retainedAttributes: (resolved: unknown) => resolved === wearMaterial ? WEAR_ATTRIBUTES : [], + meshName: (material: { name?: string }) => `${spec.id} / ${material.name || 'batch'}`, + } as Parameters[1])] + + let elapsed = 0 + return { + root, + parts, + update: (deltaSeconds: number) => { + elapsed += Math.min(Math.max(deltaSeconds, 0), 0.05) + built?.tick?.(elapsed) + }, + dispose: () => { + for (const geometry of geometries) geometry.dispose() + wearMaterial.dispose() + for (const handle of handles) handle.release() + root.traverse((object) => { + if (object instanceof Mesh) object.geometry.dispose() + }) + }, + } +} + +export interface WallPreview { + readonly scene: Scene + readonly root: Group + readonly camera: PerspectiveCamera + update(deltaSeconds: number): void + dispose(): void +} + +/** + * The group's shared capture rig, matching the modular kit's existing lighting + * exactly. A wall photographed under its own lights cannot be compared to the + * shell it is meant to join. + */ +export function createWallPreview( + model: WallModel, + envelope: WallEnvelope, + options: { aspect?: number; distance?: number; yaw?: number; pitch?: number } = {}, +): WallPreview { + const scene = new Scene() + scene.background = new Color(0x04070a) + scene.add(model.root) + scene.add(new HemisphereLight(0xa8bbc5, 0x07090c, 0.86)) + const key = new DirectionalLight(0xffedd6, 2.5) + key.position.set(-7, 10, 11) + scene.add(key) + const fill = new DirectionalLight(0x758bd0, 0.9) + fill.position.set(9, 5, 6) + scene.add(fill) + const rim = new DirectionalLight(0x82aabd, 0.72) + rim.position.set(6, 8, -9) + scene.add(rim) + + const span = Math.max(envelope.width, envelope.depth, envelope.height) + const target = new Vector3(0, envelope.height * 0.48, 0) + const distance = options.distance ?? span * 1.55 + 2.2 + const yaw = options.yaw ?? -0.66 + const pitch = options.pitch ?? 0.2 + const aspect = Number.isFinite(options.aspect) && (options.aspect ?? 0) > 0 ? options.aspect! : 1 + + const camera = new PerspectiveCamera(34, aspect, 0.08, 200) + camera.position.set( + target.x + Math.sin(yaw) * Math.cos(pitch) * distance, + target.y + Math.sin(pitch) * distance, + target.z + Math.cos(yaw) * Math.cos(pitch) * distance, + ) + camera.lookAt(target) + scene.add(camera) + + return { + scene, + root: model.root, + camera, + update: (deltaSeconds: number) => model.update(deltaSeconds), + dispose: () => { + scene.remove(model.root) + model.dispose() + }, + } +} + +export { KIT_BUILD, type KitMaterials, type WallFace } from '../axiom-modular-kit/parts.ts' +export type { WallSectionOptions } from '../axiom-modular-kit/layout.ts' diff --git a/assets/prototypes/axiom-wall-kit/straight.ts b/assets/prototypes/axiom-wall-kit/straight.ts new file mode 100644 index 00000000..b1fad2c9 --- /dev/null +++ b/assets/prototypes/axiom-wall-kit/straight.ts @@ -0,0 +1,112 @@ +import type { Group } from 'three/webgpu' + +import { prism } from '../../../src/asset-forge/generator/index.ts' +import { KIT_BUILD, facePrism, wallFace, type KitMaterials, type WallFace } from '../axiom-modular-kit/parts.ts' + +/** + * The parts a *loose* wall run needs and a prefab shell does not. + * + * Inside a shell the wall starts at the interior floor datum, because a floor + * slab is under it and the plinth carries it. A loose run has neither, so it + * would otherwise begin 440 mm in the air. These add the ground it stands on and + * the two ends a shell hides inside its corners. + */ + +/** Ground datum. The kit's wall base sits this far above it. */ +export const FOOTING_TOP = KIT_BUILD.floorY + +/** The face for a run centred on the origin at the given yaw. */ +export function runFace(yaw = 0): WallFace { + return wallFace([0, 0, -KIT_BUILD.wallThickness * 0.5], yaw) +} + +/** + * Footing: the plinth a free-standing run stands on. + * + * Wider than the wall on both faces, so the wall reads as *set on* it rather + * than as continuing into the ground. A footing flush with the wall is a wall + * that got taller, which is exactly the read a ground line must not have. + */ +export function footing(parent: Group, m: KitMaterials, face: WallFace, u0: number, u1: number): void { + const length = u1 - u0 + const centre = (u0 + u1) / 2 + facePrism(parent, face, m.deck, [length, FOOTING_TOP - 0.06, KIT_BUILD.wallThickness + 0.16], centre, (FOOTING_TOP - 0.06) / 2, 0, { + fillet: 0.02, bevel: 0.018, + }) + facePrism(parent, face, m.graphite, [length, 0.06, KIT_BUILD.wallThickness + 0.24], centre, FOOTING_TOP - 0.03, 0, { + fillet: 0.014, bevel: 0.012, + }) + // Anchor pads, one per 2 m of run, on both faces. + const pads = Math.max(2, Math.round(length / 2) + 1) + for (let index = 0; index < pads; index += 1) { + const u = u0 + (index / (pads - 1)) * length + for (const side of [1, -1]) { + facePrism(parent, face, m.steel, [0.16, 0.05, 0.1], u, 0.05, side * (KIT_BUILD.wallThickness * 0.5 + 0.1), { + fillet: 0.008, bevel: 0.007, + }) + } + } +} + +/** + * End condition for a cut run: a full-height post-section closing the cassettes. + * + * Without it the wall's cassette layers are visible in section at each end, and + * a 2 m wall reads as a fragment broken off a longer one rather than as a + * module that was made 2 m long. + */ +export function endReturn(parent: Group, m: KitMaterials, face: WallFace, u: number, side: -1 | 1): void { + const width = 0.22 + const centre = u + side * width * 0.5 + facePrism(parent, face, m.graphite, [width, KIT_BUILD.wallTop - KIT_BUILD.floorY, KIT_BUILD.wallThickness + 0.05], centre, (KIT_BUILD.floorY + KIT_BUILD.wallTop) / 2, 0, { + fillet: 0.024, bevel: 0.02, + }) + facePrism(parent, face, m.steel, [0.05, KIT_BUILD.wallTop - KIT_BUILD.floorY - 0.24, 0.04], centre, (KIT_BUILD.floorY + KIT_BUILD.wallTop) / 2, KIT_BUILD.wallThickness * 0.5 + 0.03, { + fillet: 0.008, bevel: 0.007, + }) +} + +/** Coping: the capping section along the top of a free-standing run. */ +export function coping(parent: Group, m: KitMaterials, face: WallFace, u0: number, u1: number): void { + const length = u1 - u0 + const centre = (u0 + u1) / 2 + facePrism(parent, face, m.graphite, [length, 0.12, KIT_BUILD.wallThickness + 0.1], centre, KIT_BUILD.wallTop + 0.06, 0, { + fillet: 0.02, bevel: 0.018, + }) + facePrism(parent, face, m.steel, [length - 0.1, 0.02, 0.03], centre, KIT_BUILD.wallTop + 0.13, KIT_BUILD.wallThickness * 0.5 + 0.04, { + fillet: 0.006, bevel: 0.005, + }) +} + +/** A free-standing straight run, complete: footing, wall, ends, and coping. */ +export function straightRun( + parent: Group, + m: KitMaterials, + face: WallFace, + length: number, + build: (u0: number, u1: number) => void, +): void { + const u0 = -length / 2 + const u1 = length / 2 + footing(parent, m, face, u0 - 0.11, u1 + 0.11) + build(u0, u1) + endReturn(parent, m, face, u0, -1) + endReturn(parent, m, face, u1, 1) + coping(parent, m, face, u0 - 0.11, u1 + 0.11) +} + +/** A plain block section, for the modules that do not use the cassette wall. */ +export function plainSection( + parent: Group, + material: Parameters[0], + size: [number, number, number], + position: [number, number, number], + rotation?: [number, number, number], +): void { + parent.add(prism(material, size, position, { + chamfer: Math.min(0.09, size[2] * 0.3), + fillet: 0.02, + bevel: 0.018, + ...(rotation ? { rotation } : {}), + })) +} diff --git a/assets/prototypes/curved-wall/model.ts b/assets/prototypes/curved-wall/model.ts new file mode 100644 index 00000000..f2e3c24d --- /dev/null +++ b/assets/prototypes/curved-wall/model.ts @@ -0,0 +1,63 @@ +import { wallFace } from '../axiom-modular-kit/parts.ts' +import { createWallModel, createWallPreview, WALL, type WallModel, type WallPreview } from '../axiom-wall-kit/index.ts' +import { coping, endReturn, footing } from '../axiom-wall-kit/straight.ts' + +/** + * Axiom Relay curved wall — a quarter-arc built from the straight kit. + * + * Nothing in this visual system is actually curved: the cassettes are flat + * pressings, the frame is folded plate, and a genuinely swept wall would need a + * second fabrication method the kit does not have. So this is a *faceted* arc — + * eight straight chords on a 6 m radius, each a real kit section, each meeting + * its neighbour at a 11.25-degree post. + * + * That is not a compromise, it is the honest answer, and it is what makes the + * piece sit beside a straight run without looking like it came from a different + * library. The chord length falls out of the radius and the facet count rather + * than being chosen, so changing either keeps every segment identical. + */ + +const RADIUS = 6 +const FACETS = 8 +const SWEEP = Math.PI / 2 +const STEP = SWEEP / FACETS +const CHORD = 2 * RADIUS * Math.sin(STEP / 2) +const ENVELOPE = { + width: RADIUS * Math.sin(SWEEP / 2) * 2 + WALL.thickness + 0.4, + depth: RADIUS * (1 - Math.cos(SWEEP / 2)) + WALL.thickness + 0.4, + height: WALL.top + 0.12, +} + +export function createModel(): WallModel { + return createWallModel({ + id: 'curved-wall', + envelope: ENVELOPE, + build: ({ m, part, section }) => { + const wall = part('wall') + // Centred on the arc's own mid-point, so the module's pivot is the middle + // of the run rather than one end of it — the same convention as every + // straight piece in the group. + const originZ = -RADIUS * Math.cos(SWEEP / 2) + for (let index = 0; index < FACETS; index += 1) { + const mid = -SWEEP / 2 + (index + 0.5) * STEP + const yaw = mid + const face = wallFace([ + Math.sin(mid) * (RADIUS - WALL.thickness * 0.5), + 0, + originZ + Math.cos(mid) * (RADIUS - WALL.thickness * 0.5), + ], yaw) + footing(wall, m, face, -CHORD / 2 - 0.06, CHORD / 2 + 0.06) + section(wall, face, -CHORD / 2, CHORD / 2) + coping(wall, m, face, -CHORD / 2 - 0.06, CHORD / 2 + 0.06) + // A post at every facet joint, and at both ends of the arc. + if (index === 0) endReturn(wall, m, face, -CHORD / 2, -1) + if (index === FACETS - 1) endReturn(wall, m, face, CHORD / 2, 1) + else endReturn(wall, m, face, CHORD / 2, 1) + } + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): WallPreview { + return createWallPreview(createModel(), ENVELOPE, { distance: 16, yaw: -0.42, pitch: 0.3, ...options }) +} diff --git a/assets/prototypes/low-concrete-wall/model.ts b/assets/prototypes/low-concrete-wall/model.ts new file mode 100644 index 00000000..eb7baf6d --- /dev/null +++ b/assets/prototypes/low-concrete-wall/model.ts @@ -0,0 +1,87 @@ +import { facePrism, wallFace } from '../axiom-modular-kit/parts.ts' +import { createWallModel, createWallPreview, type WallModel, type WallPreview } from '../axiom-wall-kit/index.ts' + +/** + * Axiom Relay low concrete wall — the site boundary, not a building wall. + * + * This is the one module in the straight-wall group that deliberately does *not* + * use `buildWallSection`, and the reason is that it is not the same thing. The + * kit wall is a framed composite panel system: a structural frame with light + * cassettes clipped into it, made in a shop and bolted up on site. A boundary + * wall is cast concrete — poured in bays against formwork, with a construction + * joint every bay, a chamfered arris, and a weathered top. + * + * Building it from cassettes would give a 1.1 m tall version of a building + * facade standing in a car park, which is exactly the mistake that makes a kit + * feel like it was applied rather than designed. The two share the grid, the + * palette, and the joint pitch. They do not share a construction. + */ + +const LENGTH = 4 +const HEIGHT = 1.1 +const THICKNESS = 0.26 +const BAYS = 2 +const ENVELOPE = { width: LENGTH + 0.2, depth: THICKNESS + 0.34, height: HEIGHT + 0.06 } + +export function createModel(): WallModel { + return createWallModel({ + id: 'low-concrete-wall', + envelope: ENVELOPE, + build: ({ m, part }) => { + const wall = part('wall') + const face = wallFace([0, 0, -THICKNESS * 0.5], 0) + + // Splayed foundation: wider at the bottom, because a free-standing wall + // with no building behind it is held up by its own footing. + facePrism(wall, face, m.deck, [LENGTH + 0.2, 0.16, THICKNESS + 0.34], 0, 0.08, 0, { + fillet: 0.02, bevel: 0.018, + }) + facePrism(wall, face, m.deck, [LENGTH + 0.1, 0.1, THICKNESS + 0.16], 0, 0.21, 0, { + fillet: 0.016, bevel: 0.014, + }) + + // The stem, cast in bays. Each bay is its own prism so the construction + // joint between them is a real edge rather than a scored line. + const bay = LENGTH / BAYS + for (let index = 0; index < BAYS; index += 1) { + const u = -LENGTH / 2 + (index + 0.5) * bay + facePrism(wall, face, m.deck, [bay - 0.012, HEIGHT - 0.26, THICKNESS], u, 0.26 + (HEIGHT - 0.26) / 2, 0, { + // A generous arris on a cast wall: the chamfer the formwork's fillet + // leaves, not a machined edge. + fillet: 0.014, + bevel: 0.026, + }) + // Lifting anchor and the recessed bay number, one per pour. + facePrism(wall, face, m.steel, [0.09, 0.03, 0.02], u, HEIGHT - 0.12, THICKNESS * 0.5 - 0.004, { + fillet: 0.005, bevel: 0.004, + }) + facePrism(wall, face, m.ink, [0.16, 0.1, 0.014], u - bay * 0.3, 0.5, THICKNESS * 0.5 - 0.004, { + fillet: 0.006, bevel: 0.005, + }) + } + + // Weathered capping with a drip on both faces, and the kit's dark service + // band carried across so the piece still belongs to the same world. + facePrism(wall, face, m.graphite, [LENGTH + 0.08, 0.07, THICKNESS + 0.1], 0, HEIGHT - 0.035, 0, { + fillet: 0.012, bevel: 0.014, + }) + facePrism(wall, face, m.graphite, [LENGTH, 0.09, THICKNESS + 0.012], 0, 0.31, 0, { + fillet: 0.008, bevel: 0.007, + }) + + // Impact posts at each end: what a low wall in a yard actually collects. + for (const side of [-1, 1] as const) { + facePrism(wall, face, m.graphite, [0.16, HEIGHT + 0.04, THICKNESS + 0.06], side * (LENGTH / 2 - 0.08), (HEIGHT + 0.04) / 2, 0, { + fillet: 0.018, bevel: 0.016, + }) + facePrism(wall, face, m.amber, [0.1, 0.14, 0.016], side * (LENGTH / 2 - 0.08), HEIGHT - 0.22, THICKNESS * 0.5 + 0.03, { + fillet: 0.006, bevel: 0.005, + }) + } + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): WallPreview { + return createWallPreview(createModel(), ENVELOPE, { distance: 6.8, pitch: 0.28, ...options }) +} diff --git a/assets/prototypes/wall-2-m/model.ts b/assets/prototypes/wall-2-m/model.ts new file mode 100644 index 00000000..12339387 --- /dev/null +++ b/assets/prototypes/wall-2-m/model.ts @@ -0,0 +1,34 @@ +import { createWallModel, createWallPreview, WALL, type WallModel, type WallPreview } from '../axiom-wall-kit/index.ts' +import { runFace, straightRun } from '../axiom-wall-kit/straight.ts' + +/** + * Axiom Relay wall, 2 m — the half-bay run. + * + * The kit's structural grid is 4 m, so a 2 m wall is not a small wall, it is the + * piece that lets a plan close on a half-bay: the offset between a 4 m module + * and a corner, the infill beside a door bay, the short return into a recess. + * It is cut from the same `buildWallSection` the prefab shells use, so its + * cassette rhythm, skirt height, and thickness cannot drift away from the walls + * it butts against. + */ + +const LENGTH = 2 +const ENVELOPE = { width: LENGTH + 0.22, depth: WALL.thickness + 0.24, height: WALL.top + 0.12 } + +export function createModel(): WallModel { + return createWallModel({ + id: 'wall-2-m', + envelope: ENVELOPE, + build: ({ m, part, section }) => { + const wall = part('wall') + const face = runFace() + straightRun(wall, m, face, LENGTH, (u0, u1) => { + section(wall, face, u0, u1) + }) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): WallPreview { + return createWallPreview(createModel(), ENVELOPE, options) +} diff --git a/assets/prototypes/wall-4-m/model.ts b/assets/prototypes/wall-4-m/model.ts new file mode 100644 index 00000000..0e566731 --- /dev/null +++ b/assets/prototypes/wall-4-m/model.ts @@ -0,0 +1,32 @@ +import { createWallModel, createWallPreview, WALL, type WallModel, type WallPreview } from '../axiom-wall-kit/index.ts' +import { runFace, straightRun } from '../axiom-wall-kit/straight.ts' + +/** + * Axiom Relay wall, 4 m — one full structural bay. + * + * This is the group's reference length: the kit's grid is 4 m, so this module is + * exactly what one bay of a prefab shell's elevation is, lifted out and given a + * footing, two ends, and a coping so it can stand on its own. Everything else in + * the straight-wall family is this piece made longer, shorter, or opened. + */ + +const LENGTH = 4 +const ENVELOPE = { width: LENGTH + 0.22, depth: WALL.thickness + 0.24, height: WALL.top + 0.12 } + +export function createModel(): WallModel { + return createWallModel({ + id: 'wall-4-m', + envelope: ENVELOPE, + build: ({ m, part, section }) => { + const wall = part('wall') + const face = runFace() + straightRun(wall, m, face, LENGTH, (u0, u1) => { + section(wall, face, u0, u1) + }) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): WallPreview { + return createWallPreview(createModel(), ENVELOPE, options) +} diff --git a/assets/prototypes/wall-8-m/model.ts b/assets/prototypes/wall-8-m/model.ts new file mode 100644 index 00000000..80bdcd95 --- /dev/null +++ b/assets/prototypes/wall-8-m/model.ts @@ -0,0 +1,33 @@ +import { createWallModel, createWallPreview, WALL, type WallModel, type WallPreview } from '../axiom-wall-kit/index.ts' +import { runFace, straightRun } from '../axiom-wall-kit/straight.ts' + +/** + * Axiom Relay wall, 8 m — two bays in one run. + * + * Two bays rather than a doubled 4 m module, which matters at the joint: butting + * two 4 m modules leaves two end returns back to back in the middle of a + * straight wall, and a straight wall with a seam every 4 m reads as fencing. + * Authored as one 8 m section the cassette rhythm runs continuously and the ends + * are the only ends. + */ + +const LENGTH = 8 +const ENVELOPE = { width: LENGTH + 0.22, depth: WALL.thickness + 0.24, height: WALL.top + 0.12 } + +export function createModel(): WallModel { + return createWallModel({ + id: 'wall-8-m', + envelope: ENVELOPE, + build: ({ m, part, section }) => { + const wall = part('wall') + const face = runFace() + straightRun(wall, m, face, LENGTH, (u0, u1) => { + section(wall, face, u0, u1) + }) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): WallPreview { + return createWallPreview(createModel(), ENVELOPE, options) +} diff --git a/assets/prototypes/wall-with-door/model.ts b/assets/prototypes/wall-with-door/model.ts new file mode 100644 index 00000000..acfefd6c --- /dev/null +++ b/assets/prototypes/wall-with-door/model.ts @@ -0,0 +1,41 @@ +import { createWallModel, createWallPreview, WALL, type WallModel, type WallPreview } from '../axiom-wall-kit/index.ts' +import { runFace, straightRun } from '../axiom-wall-kit/straight.ts' + +/** + * Axiom Relay wall with door — one bay with the kit's standard door bay cut in. + * + * The opening is not a hole this module invented. `buildWallSection` is the same + * function the prefab shells call, and the door it cuts is the kit's 1.56 m + * clear width on the shared head datum, complete with the stepped frame: proud + * outer flange, inward lip, and a reveal lining the jamb through the full 300 mm + * of wall. That is the whole reason to build this as a wall variant rather than + * as a wall with a door prop parked in it — a level builder can swap this module + * for a solid bay and the elevation still lines up. + */ + +const LENGTH = 4 +const ENVELOPE = { width: LENGTH + 0.22, depth: WALL.thickness + 0.24, height: WALL.top + 0.12 } + +export function createModel(): WallModel { + return createWallModel({ + id: 'wall-with-door', + envelope: ENVELOPE, + build: ({ m, part, section }) => { + const wall = part('wall') + const face = runFace() + straightRun(wall, m, face, LENGTH, (u0, u1) => { + section(wall, face, u0, u1, { + opening: { + kind: 'door', + // Centred on the bay, on the kit's own door datums. + spec: { centre: 0, width: 1.56, sill: WALL.base, head: 2.6, clip: [0.3, 0.3, 0, 0] }, + }, + }) + }) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): WallPreview { + return createWallPreview(createModel(), ENVELOPE, options) +} diff --git a/assets/prototypes/wall-with-inset-panel/model.ts b/assets/prototypes/wall-with-inset-panel/model.ts new file mode 100644 index 00000000..4818b6bd --- /dev/null +++ b/assets/prototypes/wall-with-inset-panel/model.ts @@ -0,0 +1,66 @@ +import { facePrism } from '../axiom-modular-kit/parts.ts' +import { createWallModel, createWallPreview, WALL, type WallModel, type WallPreview } from '../axiom-wall-kit/index.ts' +import { runFace, straightRun } from '../axiom-wall-kit/straight.ts' + +/** + * Axiom Relay wall with inset panel — one bay with a recessed service niche. + * + * The niche is the kit's answer to everything a wall has to carry that is not a + * door or a window: an isolator, a hose reel, a hydrant, a comms head, a bay + * number. Rather than adding a variant per fitting, this module provides the + * recess, its lit lip, and a blank backboard drilled on a regular pitch — the + * fitting is then whatever a level builder mounts to it. + * + * The recess stops well short of the wall's own thickness. Cut through, it would + * be an opening; at 120 mm it is a niche, and the difference is that the wall + * behind it still does its job. + */ + +const LENGTH = 4 +const ENVELOPE = { width: LENGTH + 0.22, depth: WALL.thickness + 0.24, height: WALL.top + 0.12 } +const NICHE = { width: 1.4, base: 0.94, top: 2.24, depth: 0.12 } as const + +export function createModel(): WallModel { + return createWallModel({ + id: 'wall-with-inset-panel', + envelope: ENVELOPE, + build: ({ m, part, section }) => { + const wall = part('wall') + const face = runFace() + straightRun(wall, m, face, LENGTH, (u0, u1) => { + section(wall, face, u0, u1) + }) + + const height = NICHE.top - NICHE.base + const centreY = (NICHE.base + NICHE.top) / 2 + const front = WALL.thickness * 0.5 + + // The recess: a dark box let into the face, and the lip that frames it. + facePrism(wall, face, m.ink, [NICHE.width, height, NICHE.depth], 0, centreY, front - NICHE.depth * 0.5 + 0.004, { + fillet: 0.012, bevel: 0.01, + }) + facePrism(wall, face, m.graphite, [NICHE.width + 0.1, height + 0.1, 0.05], 0, centreY, front - 0.006, { + fillet: 0.016, bevel: 0.014, + }) + // Backboard on a drilled pitch, so a fitting has somewhere real to bolt. + facePrism(wall, face, m.deck, [NICHE.width - 0.12, height - 0.12, 0.03], 0, centreY, front - NICHE.depth + 0.02, { + fillet: 0.008, bevel: 0.007, + }) + for (let column = 0; column < 3; column += 1) { + for (let row = 0; row < 4; row += 1) { + facePrism(wall, face, m.steel, [0.035, 0.035, 0.016], (column - 1) * 0.42, NICHE.base + 0.2 + row * ((height - 0.4) / 3), front - NICHE.depth + 0.04, { + fillet: 0.005, bevel: 0.004, + }) + } + } + // Lit lip along the head of the recess: the niche's only signal. + facePrism(wall, face, m.cyan, [NICHE.width - 0.16, 0.03, 0.02], 0, NICHE.top - 0.05, front - 0.03, { + fillet: 0.005, bevel: 0.004, + }) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): WallPreview { + return createWallPreview(createModel(), ENVELOPE, options) +} diff --git a/assets/prototypes/wall-with-vent/model.ts b/assets/prototypes/wall-with-vent/model.ts new file mode 100644 index 00000000..98b8b73b --- /dev/null +++ b/assets/prototypes/wall-with-vent/model.ts @@ -0,0 +1,66 @@ +import { facePrism } from '../axiom-modular-kit/parts.ts' +import { createWallModel, createWallPreview, WALL, type WallModel, type WallPreview } from '../axiom-wall-kit/index.ts' +import { runFace, straightRun } from '../axiom-wall-kit/straight.ts' + +/** + * Axiom Relay wall with vent — one bay carrying a plant-side louvre bank. + * + * The vent is cut as a *window* opening rather than as a louvre panel stuck on + * the wall, and that is the point: it uses the kit's own aperture, so the reveal + * runs the full wall thickness behind the blades and the frame is the same + * stepped section as every other opening. A louvre grille surface-mounted on a + * solid bay has no depth behind it, and every glancing camera angle says so. + * + * The blades are set back inside the reveal, with a bird mesh behind them and a + * drained sill below — the three parts that make an opening in a wall read as + * ventilation rather than as a window someone forgot to glaze. + */ + +const LENGTH = 4 +const ENVELOPE = { width: LENGTH + 0.22, depth: WALL.thickness + 0.24, height: WALL.top + 0.12 } +const VENT = { width: 1.9, sill: 1.32, head: 2.6 } as const +const BLADES = 7 + +export function createModel(): WallModel { + return createWallModel({ + id: 'wall-with-vent', + envelope: ENVELOPE, + build: ({ m, part, section }) => { + const wall = part('wall') + const face = runFace() + straightRun(wall, m, face, LENGTH, (u0, u1) => { + section(wall, face, u0, u1, { + opening: { + kind: 'window', + spec: { centre: 0, width: VENT.width, sill: VENT.sill, head: VENT.head, clip: [0.2, 0.2, 0.2, 0.2] }, + }, + }) + }) + + // Bird mesh, deep in the reveal: the dark plane the blades read against. + facePrism(wall, face, m.ink, [VENT.width - 0.1, VENT.head - VENT.sill - 0.1, 0.02], 0, (VENT.sill + VENT.head) / 2, -0.06, { + fillet: 0.006, bevel: 0.005, + }) + + // Blades, raked down and out so rain sheds and sight lines are blocked. + const span = VENT.head - VENT.sill - 0.14 + for (let index = 0; index < BLADES; index += 1) { + const y = VENT.sill + 0.07 + ((index + 0.5) / BLADES) * span + facePrism(wall, face, m.steel, [VENT.width - 0.14, span / BLADES * 0.72, 0.05], 0, y, 0.03, { + fillet: 0.006, bevel: 0.006, rotation: [-0.55, 0, 0], + }) + } + // Mullion splitting the bank, and the drained sill under it. + facePrism(wall, face, m.graphite, [0.06, VENT.head - VENT.sill - 0.06, 0.09], 0, (VENT.sill + VENT.head) / 2, 0.04, { + fillet: 0.01, bevel: 0.009, + }) + facePrism(wall, face, m.graphite, [VENT.width - 0.04, 0.06, 0.14], 0, VENT.sill + 0.01, 0.05, { + fillet: 0.012, bevel: 0.01, rotation: [0.22, 0, 0], + }) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): WallPreview { + return createWallPreview(createModel(), ENVELOPE, options) +} diff --git a/assets/prototypes/wall-with-window/model.ts b/assets/prototypes/wall-with-window/model.ts new file mode 100644 index 00000000..9e822745 --- /dev/null +++ b/assets/prototypes/wall-with-window/model.ts @@ -0,0 +1,37 @@ +import { createWallModel, createWallPreview, WALL, type WallModel, type WallPreview } from '../axiom-wall-kit/index.ts' +import { runFace, straightRun } from '../axiom-wall-kit/straight.ts' + +/** + * Axiom Relay wall with window — one bay with the kit's standard window cut in. + * + * Same argument as the door variant, and one extra datum: the window's 1.32 m + * sill is shared with every window bay in the kit, so a run assembled from these + * has a continuous sill line across the elevation. A sill chosen per module is + * the single fastest way to make a procedural facade look assembled by accident. + */ + +const LENGTH = 4 +const ENVELOPE = { width: LENGTH + 0.22, depth: WALL.thickness + 0.24, height: WALL.top + 0.12 } + +export function createModel(): WallModel { + return createWallModel({ + id: 'wall-with-window', + envelope: ENVELOPE, + build: ({ m, part, section }) => { + const wall = part('wall') + const face = runFace() + straightRun(wall, m, face, LENGTH, (u0, u1) => { + section(wall, face, u0, u1, { + opening: { + kind: 'window', + spec: { centre: 0, width: 1.9, sill: 1.32, head: 2.6, clip: [0.2, 0.2, 0.2, 0.2] }, + }, + }) + }) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): WallPreview { + return createWallPreview(createModel(), ENVELOPE, options) +} diff --git a/registries/scifi-kit/src/build.ts b/registries/scifi-kit/src/build.ts index d69b1601..cb9697cd 100644 --- a/registries/scifi-kit/src/build.ts +++ b/registries/scifi-kit/src/build.ts @@ -145,6 +145,7 @@ async function main(): Promise { await buildCoreItem(), await buildSupportItem('axiom-modular-kit'), await buildSupportItem('axiom-cargo-kit'), + await buildSupportItem('axiom-wall-kit'), ] for (const modelId of modelIds) items.push(await buildModelItem(modelId)) items.push({ From 9ad8e62fdffcd04816b98478579819cb15c1a18c Mon Sep 17 00:00:00 2001 From: reposlayer Date: Sun, 16 Aug 2026 06:52:28 +0200 Subject: [PATCH 2/4] Drop a dead branch in the curved wall, and name the angled wall's turn Review feedback on the straight-wall wave. Both are readability fixes; the triangle counts of both models are unchanged. curved-wall had an if/else whose two branches were identical - a leftover from an earlier edit that made it look as though the arc's last facet were a special case when it is not. Replaced with a single unconditional call, and the comment now states the actual rule: a post at every facet's trailing end gives one per joint plus one closing the far end, and facet zero adds the one that opens the near end. angled-wall's ANGLE held the deflection from straight (45 degrees) while the docstring described the interior corner (135 degrees). Both figures were right and the name was the problem, since the two are complements and it would be easy to "correct" one into the other later. Renamed to TURN, with the relationship to the dimensioned interior angle written down. Co-Authored-By: Claude Opus 5 --- assets/prototypes/angled-wall/model.ts | 20 +++++++++++++++----- assets/prototypes/curved-wall/model.ts | 7 ++++--- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/assets/prototypes/angled-wall/model.ts b/assets/prototypes/angled-wall/model.ts index 177f5702..186d9462 100644 --- a/assets/prototypes/angled-wall/model.ts +++ b/assets/prototypes/angled-wall/model.ts @@ -8,7 +8,9 @@ import { coping, endReturn, footing } from '../axiom-wall-kit/straight.ts' * The kit already has 90-degree corners; what it has no way to express is a plan * that turns by less than a right angle — a street that bends, a compound that * follows a contour, a frontage cut back from a junction. This module is that - * turn: two 2 m runs at 135 degrees, sharing one post at the vertex. + * turn: two 2 m runs meeting at a 135-degree interior corner, sharing one post + * at the vertex. That corner is a 45-degree deflection from straight, which is + * the figure {@link TURN} holds and the one the geometry is built from. * * The vertex post is the whole design problem. Two cassette runs mitred directly * into each other leave a feather edge at the inside of the angle and a visible @@ -18,10 +20,18 @@ import { coping, endReturn, footing } from '../axiom-wall-kit/straight.ts' */ const LEG = 2 -const ANGLE = Math.PI / 4 +/** + * Deflection from a straight run, not the corner's interior angle. + * + * The two are complements and it is easy to read one as the other: a 45-degree + * turn produces the 135-degree interior corner the brief asks for. Each leg is + * splayed by half the turn, so `TURN / 2` is what the leg yaws by and + * `Math.PI - TURN` is the angle a plan would dimension. + */ +const TURN = Math.PI / 4 const ENVELOPE = { - width: (LEG + 0.4) * 2 * Math.cos(ANGLE / 2), - depth: (LEG + 0.4) * Math.sin(ANGLE / 2) + WALL.thickness + 0.3, + width: (LEG + 0.4) * 2 * Math.cos(TURN / 2), + depth: (LEG + 0.4) * Math.sin(TURN / 2) + WALL.thickness + 0.3, height: WALL.top + 0.12, } @@ -35,7 +45,7 @@ export function createModel(): WallModel { // turn either way, so the module is symmetric about +Z and the vertex post // is the only part either leg has to agree with. for (const side of [-1, 1] as const) { - const yaw = side * ANGLE / 2 + const yaw = side * (TURN / 2) const face = wallFace([0, 0, -WALL.thickness * 0.5], yaw) const u0 = side < 0 ? -(LEG + 0.24) : 0.24 const u1 = side < 0 ? -0.24 : LEG + 0.24 diff --git a/assets/prototypes/curved-wall/model.ts b/assets/prototypes/curved-wall/model.ts index f2e3c24d..c7d78dea 100644 --- a/assets/prototypes/curved-wall/model.ts +++ b/assets/prototypes/curved-wall/model.ts @@ -49,10 +49,11 @@ export function createModel(): WallModel { footing(wall, m, face, -CHORD / 2 - 0.06, CHORD / 2 + 0.06) section(wall, face, -CHORD / 2, CHORD / 2) coping(wall, m, face, -CHORD / 2 - 0.06, CHORD / 2 + 0.06) - // A post at every facet joint, and at both ends of the arc. + // A post at every facet's trailing end — which gives one post at each + // joint between facets, plus one closing the far end of the arc — and a + // single leading post opening the near end. if (index === 0) endReturn(wall, m, face, -CHORD / 2, -1) - if (index === FACETS - 1) endReturn(wall, m, face, CHORD / 2, 1) - else endReturn(wall, m, face, CHORD / 2, 1) + endReturn(wall, m, face, CHORD / 2, 1) } }, }) From 3d04e9ac6bfe6a60c0dd876073fa60283d1655b9 Mon Sep 17 00:00:00 2001 From: reposlayer Date: Mon, 17 Aug 2026 01:15:38 +0200 Subject: [PATCH 3/4] Discover support kits instead of listing them by hand Every batch appends its kit to one hardcoded array in this file, so two batches in flight always collide on that single line even though their kits have nothing to do with each other. That is what put four of these PRs into conflict the moment the interiors batch landed, and it would do it again for every batch after them. A prototype directory with no model.ts is a shared kit rather than a model, which the directory walk here already has to determine in order to collect the models. Registering those directly removes the hand-maintained list, so a batch that adds a kit no longer touches this file at all and there is nothing left to conflict on. Both lists are sorted, so the output stays deterministic. The registry builds the same items as before; only their order within the lib tier changes, since discovery is alphabetical where the list was chronological. --- registries/scifi-kit/src/build.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/registries/scifi-kit/src/build.ts b/registries/scifi-kit/src/build.ts index 0f263741..a9872f08 100644 --- a/registries/scifi-kit/src/build.ts +++ b/registries/scifi-kit/src/build.ts @@ -131,6 +131,7 @@ async function buildModelItem(modelId: string): Promise { async function main(): Promise { const entries = await readdir(prototypesRoot, { withFileTypes: true }) const modelIds: string[] = [] + const supportIds: string[] = [] for (const entry of entries) { if (!entry.isDirectory()) continue try { @@ -138,15 +139,19 @@ async function main(): Promise { modelIds.push(entry.name) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + // A prototype directory with no `model.ts` is a shared kit rather than a + // model. Discovering those instead of naming them keeps this file out of + // every batch's diff: a hand-maintained list means each new kit edits the + // same line, so two batches in flight always conflict here even though + // their kits have nothing to do with each other. + supportIds.push(entry.name) } } modelIds.sort() + supportIds.sort() const items = [ await buildCoreItem(), - await buildSupportItem('axiom-modular-kit'), - await buildSupportItem('axiom-cargo-kit'), - await buildSupportItem('axiom-console-kit'), - await buildSupportItem('axiom-wall-kit'), + ...await Promise.all(supportIds.map((itemId) => buildSupportItem(itemId))), ] for (const modelId of modelIds) items.push(await buildModelItem(modelId)) items.push({ From 3c2231e9916e7f0e8cb4ca76a98fdfed94d459c2 Mon Sep 17 00:00:00 2001 From: reposlayer Date: Mon, 17 Aug 2026 01:54:12 +0200 Subject: [PATCH 4/4] Require a support kit to carry sources Absence of model.ts marks a shared kit, but it is equally true of a directory that is simply empty - one left behind by an abandoned model. Registering that published a support item with zero files, which the schema accepts and an install would silently resolve to nothing. Discovery now requires at least one TypeScript source, which is what actually distinguishes a kit from a leftover. Identical to the change on the other batch branches, so they still merge cleanly in any order. --- registries/scifi-kit/src/build.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/registries/scifi-kit/src/build.ts b/registries/scifi-kit/src/build.ts index a9872f08..8a9a9516 100644 --- a/registries/scifi-kit/src/build.ts +++ b/registries/scifi-kit/src/build.ts @@ -144,7 +144,15 @@ async function main(): Promise { // every batch's diff: a hand-maintained list means each new kit edits the // same line, so two batches in flight always conflict here even though // their kits have nothing to do with each other. - supportIds.push(entry.name) + // + // It has to carry sources to count. Absence of `model.ts` is also true of + // a directory that is simply empty — one left behind by an abandoned + // model, say — and registering that published a support item with zero + // files, which the schema accepts and an install would silently resolve + // to nothing. + if ((await collectTypeScriptFiles(join(prototypesRoot, entry.name))).length > 0) { + supportIds.push(entry.name) + } } } modelIds.sort()