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..186d9462 --- /dev/null +++ b/assets/prototypes/angled-wall/model.ts @@ -0,0 +1,71 @@ +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 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 + * 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 +/** + * 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(TURN / 2), + depth: (LEG + 0.4) * Math.sin(TURN / 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 * (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 + 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..c7d78dea --- /dev/null +++ b/assets/prototypes/curved-wall/model.ts @@ -0,0 +1,64 @@ +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'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) + 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 154073b9..8a9a9516 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,14 +139,27 @@ 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. + // + // 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() + supportIds.sort() const items = [ await buildCoreItem(), - await buildSupportItem('axiom-modular-kit'), - await buildSupportItem('axiom-cargo-kit'), - await buildSupportItem('axiom-console-kit'), + ...await Promise.all(supportIds.map((itemId) => buildSupportItem(itemId))), ] for (const modelId of modelIds) items.push(await buildModelItem(modelId)) items.push({