diff --git a/.changeset/architecture-windows-wave.md b/.changeset/architecture-windows-wave.md new file mode 100644 index 00000000..3dec4530 --- /dev/null +++ b/.changeset/architecture-windows-wave.md @@ -0,0 +1,18 @@ +--- +"@scifi-kit/registry": patch +--- + +Add the Axiom Relay windows group: ten procedural architecture modules covering +the shared bay, the rated and sealed ports, the tiled ribbon and curtain-wall +runs, a building corner, a residential variant, a damage variant, and an +external shading device. + +All ten are built on a new `axiom-window-kit` support item. It treats the +brief's 1.5 m envelope as a *bay pitch* rather than a fixed prop size, which is +what makes the long and curtain variants exact multiples of one bay instead of +one bay stretched — a stretched bay keeps its 50 mm reveal across a 3 m opening +and reads as a slot cut in a panel. The kit carries the shared plate, reveal +ring, glazing seat, cill and drip, aperture lamps, plate strips, control paddle, +and actuator ram, plus lit signal lamps at a lower emissive tier than the cargo +wave's, because a window lights the full head and cill of its aperture where a +crate lights a 70 mm lens. diff --git a/assets/prototypes/axiom-window-kit/contract.ts b/assets/prototypes/axiom-window-kit/contract.ts new file mode 100644 index 00000000..e5c61945 --- /dev/null +++ b/assets/prototypes/axiom-window-kit/contract.ts @@ -0,0 +1,87 @@ +/** + * The datums every Axiom Relay window module is built on. + * + * The windows brief gives the whole group one envelope — 1.5 m wide, 0.20 m + * deep, 1.4 m tall — and says long and curtain variants tile it on the 1 m + * grid. That last clause is the important one: it means the module is not a + * fixed prop but a *bay*, and anything wider has to be an exact number of bays + * rather than a stretched copy. + * + * The two figures do not agree on their own. The bay is 1.5 m because the brief + * fixes the envelope, but `production-rules.md` requires modular architecture to + * *terminate* on the 1 m grid, and a run of 1.5 m bays only lands there on an + * even bay count. Rather than pick one rule and break the other, the pitch stays + * at the brief's 1.5 m and tiled runs are constrained to even counts — which is + * why `long-horizontal-window` is four bays at 6 m and not three at 4.5 m. Use + * {@link tiledWidth} rather than multiplying the pitch by hand. + * + * The aperture is centred on the plate for the same reason the doors' opening + * is: a symmetric plate can be cut as one prism with one centred octagonal hole, + * and the reference sheets show a symmetric plate. Sill asymmetry is restored by + * the drip and the cill board, which are the parts that actually read. + */ +export const WINDOW_KIT = Object.freeze({ + width: 1.5, + height: 1.4, + depth: 0.2, + /** Clear glazed aperture. */ + clearWidth: 0.98, + clearHeight: 0.86, + /** Aperture centre; also the plate's centre. */ + centreY: 0.7, + clip: 0.16, + outerClip: 0.2, + /** Shell frame plate depth; the graphite reveal runs deeper behind it. */ + platePitch: 0.16, + /** + * Tiling pitch for long and curtain variants. Equal to the module width: a + * bay is the module, not a subdivision of it. + */ + bayPitch: 1.5, + front: '+Z', + pivot: 'sill-centre', +} as const) + +/** + * Width of a tiled run, and the guard that keeps it on the 1 m grid. + * + * Throws rather than rounding. A run that silently became 4.5 m wide would place + * every module downstream of it a half metre off the grid, and the failure would + * surface as a seam in a level rather than as an error here. + */ +export function tiledWidth(bays: number): number { + if (!Number.isInteger(bays) || bays < 1) { + throw new Error(`Bay count must be a positive integer, received ${bays}`) + } + if (bays > 1 && bays % 2 !== 0) { + throw new Error( + `A ${bays}-bay run at the ${WINDOW_KIT.bayPitch} m pitch is ${bays * WINDOW_KIT.bayPitch} m wide, ` + + 'which does not terminate on the 1 m grid. Tiled runs take an even bay count.', + ) + } + return bays * WINDOW_KIT.bayPitch +} + +export const APERTURE_HALF: readonly [number, number] = [ + WINDOW_KIT.clearWidth * 0.5, + WINDOW_KIT.clearHeight * 0.5, +] + +/** Frame plate front face; glazing and hardware are placed against it. */ +export const PLATE_FRONT = WINDOW_KIT.platePitch * 0.5 + +export interface WindowEnvelope { + readonly width: number + readonly depth: number + readonly height: number +} + +export interface WindowMetadata { + readonly version: 1 + readonly moduleId: string + readonly pivot: typeof WINDOW_KIT.pivot + readonly front: typeof WINDOW_KIT.front + readonly envelope: WindowEnvelope + readonly clear: { readonly width: number; readonly height: number } + readonly bays: number +} diff --git a/assets/prototypes/axiom-window-kit/frame.ts b/assets/prototypes/axiom-window-kit/frame.ts new file mode 100644 index 00000000..c1ba3c13 --- /dev/null +++ b/assets/prototypes/axiom-window-kit/frame.ts @@ -0,0 +1,248 @@ +import { Group, type MeshPhysicalMaterial } from 'three/webgpu' + +import { cylinder, extrudeProfile, prism } from '../../../src/asset-forge/generator/index.ts' +import { + AXIS_Y, + boltRun, + box, + slot, + statusLens, + type CargoMaterials, +} from '../axiom-cargo-kit/index.ts' +import { APERTURE_HALF, PLATE_FRONT, WINDOW_KIT } from './contract.ts' + +/** + * The shared window bay: shell plate, graphite reveal ring, glazing seat, and + * the jamb hardware every module in the group hangs off. + * + * Same construction as the doors group, turned landscape: nested octagonal cuts + * rather than four mitred members, because the reference sheets' aperture has + * clipped corners at two radii and four members cannot produce them. + * + * Every lit part stands proud of the plate. Housed inside the plate's own + * thickness an emissive strip is buried in solid shell and renders as a black + * slot — emissive material here is a visible surface, not a light source. + */ + +const HOUSING_Z = PLATE_FRONT + 0.012 +const LAMP_Z = PLATE_FRONT + 0.03 + +export interface BayOptions { + /** Horizontal centre, for multi-bay modules. */ + readonly centreX?: number + /** Aperture half-extents, where a module narrows or deepens the opening. */ + readonly half?: readonly [number, number] + /** Overall plate width, for the outer bays of a tiled module. */ + readonly width?: number + readonly height?: number + /** Omit the outer plate on interior bays of a tiled run. */ + readonly plate?: boolean + /** + * Omit the stepped picture-frame border. + * + * A tiled run draws one border around the whole run instead. Per-bay borders + * make three bays read as three windows bolted edge to edge — the border is + * what says "this is one fabrication", so a run that repeats it is saying the + * opposite of what it means. + */ + readonly border?: boolean +} + +/** The stepped outer border, drawn once per fabrication rather than per bay. */ +export function plateBorder(parent: Group, m: CargoMaterials, options: BayOptions = {}): void { + const cx = options.centreX ?? 0 + const width = options.width ?? WINDOW_KIT.width + const height = options.height ?? WINDOW_KIT.height + parent.add(extrudeProfile(m.shellLight, slot(width * 0.5, height * 0.5, WINDOW_KIT.outerClip), 0.028, [ + cx, WINDOW_KIT.centreY, PLATE_FRONT + 0.013, + ], { + fillet: 0.01, + bevel: 0.013, + holes: [slot(width * 0.5 - 0.1, height * 0.5 - 0.1, WINDOW_KIT.outerClip - 0.04)], + })) +} + +/** Plate and reveal for one bay. */ +export function bayPlate(parent: Group, m: CargoMaterials, options: BayOptions = {}): void { + const cx = options.centreX ?? 0 + const [hx, hy] = options.half ?? APERTURE_HALF + const width = options.width ?? WINDOW_KIT.width + const height = options.height ?? WINDOW_KIT.height + + if (options.plate ?? true) { + parent.add(prism(m.shell, [width, height, WINDOW_KIT.platePitch], [cx, WINDOW_KIT.centreY, 0], { + chamfer: WINDOW_KIT.outerClip, + fillet: 0.016, + bevel: 0.014, + capChamfer: [0.04, 0.024], + holes: [slot(hx + 0.05, hy + 0.05, WINDOW_KIT.clip + 0.015)], + })) + if (options.border ?? true) plateBorder(parent, m, options) + } + + // Reveal ring: a chunky dark octagon standing proud and running back through + // the plate. The group's strongest value break, with real walls rather than a + // dark quad, so it catches the key on one flank and goes black on the other. + parent.add(extrudeProfile(m.graphite, slot(hx + 0.052, hy + 0.052, WINDOW_KIT.clip + 0.015), 0.15, [ + cx, WINDOW_KIT.centreY, PLATE_FRONT - 0.058, + ], { + fillet: 0.009, + bevel: 0.012, + holes: [slot(hx, hy, WINDOW_KIT.clip)], + })) + // Inner liner, the seat the glazing beds into. + parent.add(extrudeProfile(m.ink, slot(hx + 0.022, hy + 0.022, WINDOW_KIT.clip - 0.01), 0.09, [ + cx, WINDOW_KIT.centreY, PLATE_FRONT - 0.17, + ], { + fillet: 0.006, + bevel: 0.007, + holes: [slot(hx - 0.012, hy - 0.012, WINDOW_KIT.clip - 0.02)], + })) +} + +/** + * The aperture's edge lighting: a lit line inset just inside the reveal on the + * head and cill. It is what makes a glazed hole read as a fitted window rather + * than as a hole, and it is the group's dominant signal surface. + */ +export function apertureLamps( + parent: Group, + m: CargoMaterials, + signal: MeshPhysicalMaterial, + options: BayOptions = {}, +): void { + const cx = options.centreX ?? 0 + const [hx, hy] = options.half ?? APERTURE_HALF + for (const sy of [-1, 1]) { + const y = WINDOW_KIT.centreY + sy * (hy - 0.028) + box(parent, m.ink, [hx * 1.5, 0.05, 0.04], [cx, y, PLATE_FRONT - 0.055], { + chamfer: 0.014, fillet: 0.006, bevel: 0.005, + }) + box(parent, signal, [hx * 1.42, 0.026, 0.028], [cx, y, PLATE_FRONT - 0.038], { + chamfer: 0.009, fillet: 0.004, bevel: 0.003, + }) + } +} + +/** Cyan status strips on the head and cill of the outer plate. */ +export function plateStrips(parent: Group, m: CargoMaterials, options: BayOptions = {}): void { + const cx = options.centreX ?? 0 + const height = options.height ?? WINDOW_KIT.height + for (const sy of [-1, 1]) { + const y = WINDOW_KIT.centreY + sy * (height * 0.5 - 0.075) + box(parent, m.ink, [0.34, 0.05, 0.045], [cx, y, HOUSING_Z], { + chamfer: 0.016, fillet: 0.006, bevel: 0.005, + }) + box(parent, m.cyan, [0.28, 0.026, 0.03], [cx, y, LAMP_Z], { + chamfer: 0.009, fillet: 0.004, bevel: 0.003, + }) + } +} + +/** + * The jamb control paddle: a raised bezel carrying one large paddle switch and + * a small state read. Sits on the jamb opposite the actuator. + */ +export function controlPaddle( + parent: Group, + m: CargoMaterials, + signal: MeshPhysicalMaterial, + options: BayOptions = {}, +): void { + const width = options.width ?? WINDOW_KIT.width + const x = (options.centreX ?? 0) + width * 0.5 - 0.135 + box(parent, m.graphite, [0.13, 0.36, 0.05], [x, WINDOW_KIT.centreY, HOUSING_Z + 0.008], { + chamfer: 0.04, fillet: 0.011, bevel: 0.009, capChamfer: 0.018, + }) + statusLens(parent, m, [0.07, 0.155], [x, WINDOW_KIT.centreY - 0.02, LAMP_Z + 0.008], signal, 'front') + statusLens(parent, m, [0.055, 0.022], [x, WINDOW_KIT.centreY + 0.13, LAMP_Z + 0.008], m.cyan, 'front') + box(parent, m.steel, [0.055, 0.022, 0.018], [x, WINDOW_KIT.centreY - 0.135, LAMP_Z + 0.01], { + chamfer: 0.006, fillet: 0.003, bevel: 0.003, + }) +} + +/** + * Shutter actuator: a ram on the aperture's left jamb. + * + * It is the physical reason the group's shutter and blast variants can close. + * On the modules that never close it is still present, because the bay is one + * fabrication and a window that omits the ram is a different casting. + */ +export function actuatorRam(parent: Group, m: CargoMaterials, options: BayOptions = {}): void { + const cx = options.centreX ?? 0 + const [hx, hy] = options.half ?? APERTURE_HALF + // Inside the aperture, not on the jamb behind it. Set one ram-radius outboard + // of the clear opening the ram sits *inside* the reveal ring's own material + // and is invisible from every angle the bay is ever seen from. + const x = cx - hx + 0.055 + const z = PLATE_FRONT - 0.105 + parent.add(cylinder(m.steel, 0.024, hy * 1.55, [x, WINDOW_KIT.centreY, z], AXIS_Y, 12)) + for (const sy of [-1, 1]) { + parent.add(cylinder(m.graphiteEdge, 0.046, 0.12, [x, WINDOW_KIT.centreY + sy * hy * 0.62, z], AXIS_Y, 12)) + box(parent, m.graphite, [0.1, 0.12, 0.06], [x - 0.045, WINDOW_KIT.centreY + sy * hy * 0.62, z - 0.03], { + chamfer: 0.02, fillet: 0.008, bevel: 0.007, + }) + } + parent.add(cylinder(m.ironOxide, 0.034, 0.2, [x, WINDOW_KIT.centreY, z], AXIS_Y, 12)) +} + +/** + * Cill: a drip board that throws water clear of the plate below, plus the + * ribbed board itself. This is where the module stops being symmetric. + */ +export function cill(parent: Group, m: CargoMaterials, options: BayOptions = {}): void { + const cx = options.centreX ?? 0 + const width = options.width ?? WINDOW_KIT.width + const y = WINDOW_KIT.centreY - (options.half ?? APERTURE_HALF)[1] - 0.075 + // Reaches 50 mm past the plate, not 90. A cill drawn as far forward as the + // whole module is deep stops reading as part of the bay and starts reading as + // a rail bolted across it. + const reach = WINDOW_KIT.depth + 0.05 + box(parent, m.graphiteEdge, [width - 0.3, 0.05, reach], [cx, y, 0.022], { + chamfer: 0.014, fillet: 0.006, bevel: 0.005, capChamfer: [0.026, 0.012], + }) + for (let index = 0; index < 6; index += 1) { + box(parent, m.ink, [0.02, 0.009, reach - 0.05], [ + cx + (index - 2.5) * (width - 0.48) / 5, y + 0.029, 0.022, + ], { chamfer: 0.004, fillet: 0.002, bevel: 0.002 }) + } + box(parent, m.amberPaint, [width - 0.38, 0.009, 0.024], [cx, y + 0.029, reach * 0.5 - 0.02], { + chamfer: 0.004, fillet: 0.002, bevel: 0.002, + }) +} + +/** Corner fixings on the diagonal returns the outer clip creates. */ +export function plateFixings(parent: Group, m: CargoMaterials, options: BayOptions = {}): void { + const cx = options.centreX ?? 0 + const width = options.width ?? WINDOW_KIT.width + const height = options.height ?? WINDOW_KIT.height + for (const sx of [-1, 1]) { + for (const sy of [-1, 1]) { + boltRun( + parent, + m.steel, + [cx + sx * (width * 0.5 - 0.052), WINDOW_KIT.centreY + sy * (height * 0.5 - 0.19), PLATE_FRONT + 0.028], + [cx + sx * (width * 0.5 - 0.052), WINDOW_KIT.centreY + sy * (height * 0.5 - 0.42), PLATE_FRONT + 0.028], + 2, + 0.013, + 'front', + ) + } + } +} + +/** Everything a standard single-bay window shares. */ +export function buildBay( + parent: Group, + m: CargoMaterials, + signal: MeshPhysicalMaterial, + options: BayOptions = {}, +): void { + bayPlate(parent, m, options) + plateFixings(parent, m, options) + cill(parent, m, options) + apertureLamps(parent, m, signal, options) + plateStrips(parent, m, options) + controlPaddle(parent, m, signal, options) + actuatorRam(parent, m, options) +} diff --git a/assets/prototypes/axiom-window-kit/index.ts b/assets/prototypes/axiom-window-kit/index.ts new file mode 100644 index 00000000..cc0c60dc --- /dev/null +++ b/assets/prototypes/axiom-window-kit/index.ts @@ -0,0 +1,286 @@ +import { Group, Object3D, type MeshPhysicalMaterial } from 'three/webgpu' + +import { MaterialLibrary, extrudeProfile, tuneMaterial } from '../../../src/asset-forge/generator/index.ts' +import { + TOKEN, + acquireCargoMaterials, + box, + createCargoPreview, + finishModel, + shade, + slot, + socket, + type CargoMaterialBundle, + type CargoMaterials, + type CargoPreview, + type CargoPreviewOptions, +} from '../axiom-cargo-kit/index.ts' +import { + APERTURE_HALF, + PLATE_FRONT, + WINDOW_KIT, + type WindowEnvelope, + type WindowMetadata, +} from './contract.ts' + +export { APERTURE_HALF, PLATE_FRONT, WINDOW_KIT, tiledWidth } from './contract.ts' +export type { WindowEnvelope, WindowMetadata } from './contract.ts' +export { + actuatorRam, + apertureLamps, + bayPlate, + buildBay, + cill, + controlPaddle, + plateFixings, + plateStrips, +} from './frame.ts' +export { plateBorder } from './frame.ts' +export type { BayOptions } from './frame.ts' + +/** + * The scaffold every window module in the batch is assembled through: one + * material bundle at one condition, the wave's `AXR_ARCH__...` naming, and + * an occlusion reach scaled to the bay rather than guessed per model. + */ + +const library = new MaterialLibrary() + +export type SignalToken = 'AMBER-400' | 'CYAN-400' | 'RED-500' | 'ORANGE-500' + +const TOKEN_VALUE: Record = { + 'AMBER-400': TOKEN.AMBER_400, + 'CYAN-400': TOKEN.CYAN_400, + 'RED-500': TOKEN.RED_500, + 'ORANGE-500': TOKEN.ORANGE_500, +} + +/** + * A lit lamp, one emissive tier below the cargo wave's. + * + * A crate lights a 70 mm lens; a window lights the full head and cill of its + * aperture. At the cargo tier that line clips to cream and the module loses the + * saturated colour the brief asked it to carry — the lit area is what differs + * between the waves, so the intensity has to differ with it. + * + * Built here rather than added to `CargoMaterials` so the source hashes of the + * models already in the registry are untouched. The handle is pushed onto the + * caller's bundle, so the existing dispose path releases it. + */ +export function signalLamp( + bundle: CargoMaterialBundle, + token: SignalToken, + seed = 2_400, + emissive = 0.72, +): MeshPhysicalMaterial { + const handle = library.acquire({ recipeId: 'MAT-09', palette: token, condition: 'maintained', seed }) + bundle.handles.push(handle) + return tuneMaterial(handle, shade(TOKEN_VALUE[token], -0.22), 0.24, 0.03, { emissive }) +} + +/** + * Glazing for one aperture. + * + * A real pane seated into the liner, not a quad floated in the hole: it has + * thickness, so it catches a highlight on its edge, and it sits behind the + * reveal so the reveal's inner wall reads in front of it. + */ +export function glazing( + parent: Group, + m: CargoMaterials, + options: { centreX?: number; half?: readonly [number, number]; thickness?: number } = {}, +): void { + const cx = options.centreX ?? 0 + const [hx, hy] = options.half ?? APERTURE_HALF + parent.add(extrudeProfile(m.glass, slot(hx - 0.006, hy - 0.006, WINDOW_KIT.clip - 0.015), options.thickness ?? 0.018, [ + cx, WINDOW_KIT.centreY, PLATE_FRONT - 0.135, + ], { fillet: 0.004, bevel: 0.006 })) +} + +/** Glazing bars dividing one aperture into a grid. */ +export function glazingBars( + parent: Group, + m: CargoMaterials, + columns: number, + rows: number, + options: { centreX?: number; half?: readonly [number, number] } = {}, +): void { + const cx = options.centreX ?? 0 + const [hx, hy] = options.half ?? APERTURE_HALF + const z = PLATE_FRONT - 0.115 + for (let index = 1; index < columns; index += 1) { + box(parent, m.shellShade, [0.035, hy * 2, 0.04], [cx - hx + (index / columns) * hx * 2, WINDOW_KIT.centreY, z], { + chamfer: 0.01, fillet: 0.004, bevel: 0.004, + }) + } + for (let index = 1; index < rows; index += 1) { + box(parent, m.shellShade, [hx * 2, 0.035, 0.04], [cx, WINDOW_KIT.centreY - hy + (index / rows) * hy * 2, z], { + chamfer: 0.01, fillet: 0.004, bevel: 0.004, + }) + } +} + +export type WindowState = 'open' | 'closed' + +export interface WindowModel { + readonly root: Group + readonly parts: Record + readonly sockets: Record + readonly state: WindowState + setState(state: WindowState): WindowState + update(deltaSeconds: number): void + dispose(): void +} + +export interface WindowBuild { + readonly id: string + readonly condition?: number + readonly envelope?: WindowEnvelope + /** Bay count, recorded in metadata so a tiled run is self-describing. */ + readonly bays?: number + build(context: { + root: Group + m: CargoMaterials + bundle: CargoMaterialBundle + part(name: string): Group + }): { + /** + * Articulated groups, batched separately so they keep their transforms. + * They must be **siblings, not nested**: the batch merge collapses each + * assembly's descendants into meshes directly under it, destroying any + * intermediate group, so a nested assembly is re-attached to something no + * longer in the scene and vanishes without an error. + */ + readonly assemblies?: readonly Group[] + readonly apply?: (blend: number) => void + readonly sockets?: Readonly> + readonly tick?: (elapsed: number) => void + readonly cycleSeconds?: number + } +} + +export function createWindowModel(spec: WindowBuild): WindowModel { + const bundle = acquireCargoMaterials(21_700 + spec.id.length * 211, { condition: spec.condition ?? 0.38 }) + const m = bundle.materials + const envelope = spec.envelope ?? { + width: WINDOW_KIT.width, + depth: WINDOW_KIT.depth, + height: WINDOW_KIT.height, + } + const tag = spec.id.toUpperCase() + + const root = new Group() + root.name = `AXR_ARCH_${tag}_ROOT_CLOSED` + const parts: Record = {} + const part = (name: string): Group => { + const existing = parts[name] + if (existing) return existing + const group = new Group() + group.name = `AXR_ARCH_${tag}_PART_${name.toUpperCase()}_DEFAULT` + parts[name] = group + root.add(group) + return group + } + + const built = spec.build({ root, m, bundle, part }) + root.userData.windowKit = { + version: 1, + moduleId: spec.id, + pivot: WINDOW_KIT.pivot, + front: WINDOW_KIT.front, + envelope, + clear: { width: WINDOW_KIT.clearWidth, height: WINDOW_KIT.clearHeight }, + bays: spec.bays ?? 1, + } satisfies WindowMetadata + + const sockets: Record = {} + const shared: Record = { + window_cill: [0, WINDOW_KIT.centreY - APERTURE_HALF[1] - 0.075, 0], + window_head: [0, WINDOW_KIT.centreY + APERTURE_HALF[1], 0], + mount_left: [-envelope.width * 0.5, WINDOW_KIT.centreY, 0], + mount_right: [envelope.width * 0.5, WINDOW_KIT.centreY, 0], + cover_glazing: [0, WINDOW_KIT.centreY, PLATE_FRONT - 0.135], + } + for (const [name, position] of Object.entries({ ...shared, ...(built.sockets ?? {}) })) { + sockets[name] = socket(name, [...position] as [number, number, number]) + } + + const finished = finishModel(root, bundle, { + name: spec.id, + assemblies: built.assemblies, + reach: 0.2, + sockets: Object.values(sockets), + }) + + let state: WindowState = 'closed' + let blend = 0 + let elapsed = 0 + const cycle = built.cycleSeconds ?? 1.6 + + /** + * Carries the state token on the articulated groups as well as on the root. + * + * Every animated model already in the library keeps its moving parts in step — + * a crate's lid becomes `..._LID_OPEN`. Renaming only the root leaves an + * exported open blind or drawer full of parts still claiming to be `_CLOSED`, + * which is worse than not naming them, because a consumer reading part names + * has no way to know they are stale. + */ + const retagAssemblies = (next: WindowState): void => { + const token = next.toUpperCase() + for (const assembly of built.assemblies ?? []) { + assembly.name = assembly.name.replace(/_(CLOSED|OPEN)$/, `_${token}`) + } + } + + built.apply?.(0) + + return { + root, + parts, + sockets, + get state() { + return state + }, + setState: (next: WindowState) => { + state = next + root.name = `AXR_ARCH_${tag}_ROOT_${next.toUpperCase()}` + retagAssemblies(next) + blend = next === 'open' ? 1 : 0 + built.apply?.(blend) + return state + }, + update: (deltaSeconds: number) => { + const step = Math.min(Math.max(deltaSeconds, 0), 0.05) + elapsed += step + const target = state === 'open' ? 1 : 0 + if (Math.abs(target - blend) > 1e-4) { + blend += Math.sign(target - blend) * Math.min(Math.abs(target - blend), step / cycle) + built.apply?.(blend) + // A part that has started moving is no longer closed, so the name flips + // on the first frame of travel rather than only at the far end. + if (blend > 0.02 && state === 'open') retagAssemblies('open') + else if (blend < 0.98 && state === 'closed') retagAssemblies('closed') + } + built.tick?.(elapsed) + }, + dispose: finished.dispose, + } +} + +export interface WindowPreviewOptions extends CargoPreviewOptions { + readonly state?: WindowState +} + +/** The group's shared capture framing: a catalogue page, not fifty product shots. */ +export function createWindowPreview(model: WindowModel, options: WindowPreviewOptions = {}): CargoPreview { + model.setState(options.state ?? 'closed') + return createCargoPreview(model, { + target: [0, WINDOW_KIT.centreY, 0], + distance: 3.3, + yaw: -0.62, + pitch: 0.18, + fov: 32, + ...options, + }) +} diff --git a/assets/prototypes/corner-window/model.ts b/assets/prototypes/corner-window/model.ts new file mode 100644 index 00000000..cd30561b --- /dev/null +++ b/assets/prototypes/corner-window/model.ts @@ -0,0 +1,136 @@ +import { box, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + APERTURE_HALF, + PLATE_FRONT, + WINDOW_KIT, + actuatorRam, + apertureLamps, + bayPlate, + cill, + createWindowModel, + createWindowPreview, + glazing, + plateBorder, + plateFixings, + signalLamp, + type WindowModel, + type WindowPreviewOptions, +} from '../axiom-window-kit/index.ts' + +const HALF = WINDOW_KIT.bayPitch * 0.5 + +/** + * Axiom Relay corner window — one bay turned through the corner of a building. + * + * The whole design problem is the corner post. A corner window's appeal is that + * there *isn't* one, so the glass has to meet the glass — but something still + * has to carry the wall above, and pretending otherwise is the difference + * between a corner window and two windows near each other. + * + * The answer here is the kit's honest one: a slim structural post set *behind* + * the glass line at the mitre, carrying a bracket back into each return. The + * corner reads as glass-to-glass from outside, and from inside it is obvious + * what is holding the building up. + */ + +export function createModel(): WindowModel { + return createWindowModel({ + id: 'corner-window', + condition: 0.3, + bays: 2, + // Not the single-bay 0.20 m depth. Two returns splayed 45 degrees each way + // occupy a real footprint: 1.5 m of bay along each leg projects to about + // 1.06 m in X and the same in Z, and the plate, cill and coping add to both. + // Publishing the flat 0.20 m here would put the mount sockets a metre inside + // the geometry they are meant to bound. + envelope: { + width: WINDOW_KIT.bayPitch * Math.SQRT2 + WINDOW_KIT.depth, + depth: WINDOW_KIT.bayPitch / Math.SQRT2 + WINDOW_KIT.depth + 0.18, + height: WINDOW_KIT.height, + }, + build: ({ m, bundle, part }) => { + const amber = signalLamp(bundle, 'AMBER-400', 2_960) + + // Two returns, each a standard bay, meeting at the origin. The leg groups + // are rotated rather than the geometry re-authored, so both returns are + // literally the same bay and cannot drift apart. + const legs = [part('return-front'), part('return-side')] + // Splayed symmetrically about +Z, and each return authored running *away* + // from the corner rather than toward it. + // + // Both halves of that matter. Laid on the world axes the corner's bisector + // runs diagonally, so an external-corner view has to look down the + // bisector — and looking down the bisector puts both leg centres on one + // view ray, where the far return hides exactly behind the near one. + // Splaying 45 degrees each way fixes the bisector on +Z. But a return + // whose bay is authored at +HALF then swings *forward* of the corner + // instead of back from it, and the two ends up side by side on the same + // plane rather than meeting at an edge. The left return is therefore + // authored at -HALF: mirrored about the corner, not rotated onto it. + legs[0]!.rotation.y = -Math.PI / 4 + legs[1]!.rotation.y = Math.PI / 4 + + for (const [index, leg] of legs.entries()) { + const centreX = index === 0 ? -HALF : HALF + bayPlate(leg, m, { centreX, width: WINDOW_KIT.bayPitch, border: false }) + plateBorder(leg, m, { centreX, width: WINDOW_KIT.bayPitch }) + plateFixings(leg, m, { centreX, width: WINDOW_KIT.bayPitch }) + apertureLamps(leg, m, amber, { centreX }) + glazing(leg, m, { centreX }) + cill(leg, m, { centreX, width: WINDOW_KIT.bayPitch }) + // One actuator for the pair, on the right return: two rams on a corner + // window would both have to drive into the same corner post. + if (index === 1) actuatorRam(leg, m, { centreX }) + } + + // The corner post, behind the glass line, with a bracket into each return. + const post = part('corner-post') + box(post, m.graphite, [0.09, WINDOW_KIT.height - 0.06, 0.09], [0, WINDOW_KIT.centreY, 0], { + chamfer: 0.024, fillet: 0.009, bevel: 0.008, + }) + for (const [dx, dz] of [[0.13, 0], [0, 0.13]] as const) { + box(post, m.graphiteEdge, [dx > 0 ? 0.18 : 0.06, 0.08, dz > 0 ? 0.18 : 0.06], [dx, WINDOW_KIT.centreY + 0.5, dz], { + chamfer: 0.016, fillet: 0.006, bevel: 0.005, + }) + box(post, m.graphiteEdge, [dx > 0 ? 0.18 : 0.06, 0.08, dz > 0 ? 0.18 : 0.06], [dx, WINDOW_KIT.centreY - 0.5, dz], { + chamfer: 0.016, fillet: 0.006, bevel: 0.005, + }) + } + // Mitre cover: the slim external cap over the glass-to-glass joint. + box(post, m.shellShade, [0.05, WINDOW_KIT.height - 0.24, 0.05], [ + PLATE_FRONT - 0.02, WINDOW_KIT.centreY, PLATE_FRONT - 0.02, + ], { chamfer: 0.014, fillet: 0.005, bevel: 0.005, rotation: [0, Math.PI / 4, 0] }) + + return { + sockets: { + mount_left: [-HALF * Math.SQRT2, WINDOW_KIT.centreY, -HALF * Math.SQRT2], + mount_right: [HALF * Math.SQRT2, WINDOW_KIT.centreY, -HALF * Math.SQRT2], + window_head: [0, WINDOW_KIT.centreY + APERTURE_HALF[1], 0], + window_cill: [0, WINDOW_KIT.centreY - APERTURE_HALF[1] - 0.075, 0], + mount_corner_post: [0, WINDOW_KIT.centreY, 0], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.7 + Math.sin(elapsed * 1.2) * 0.1 + }, + } + }, + }) +} + +/** + * Framed on the *external* corner. + * + * The two outward faces are the +Z face of the front return and the -X face of + * the side return, so the camera has to sit in the -X/+Z quadrant. From the + * opposite quadrant the module reads as an inside corner and the side return + * shows nothing but its back. + */ +export function createPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), { + target: [0, WINDOW_KIT.centreY, -HALF * 0.5], + distance: 6.4, + yaw: 0, + pitch: 0.2, + ...options, + }) +} diff --git a/assets/prototypes/damaged-broken-window/model.ts b/assets/prototypes/damaged-broken-window/model.ts new file mode 100644 index 00000000..03752a86 --- /dev/null +++ b/assets/prototypes/damaged-broken-window/model.ts @@ -0,0 +1,105 @@ +import { extrudeProfile } from '../../../src/asset-forge/generator/index.ts' +import { box, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + APERTURE_HALF, + PLATE_FRONT, + WINDOW_KIT, + buildBay, + createWindowModel, + createWindowPreview, + signalLamp, + type WindowModel, + type WindowPreviewOptions, +} from '../axiom-window-kit/index.ts' + +/** + * Axiom Relay damaged broken window — the same bay after an impact. + * + * The damage has one cause and everything else follows from it: something struck + * the pane low and left of centre. So the shards that remain are the ones the + * frame still grips — long slivers along the head and the two jambs, nothing at + * the point of impact — the surviving glass is hinged outward where the bead let + * go, the cill below carries the fallen fragments, and the aperture lamp on that + * side is dark because the impact took its housing with it. + * + * The plate itself is undamaged. A thrown object breaks glass; it does not + * deform a 160 mm alloy plate, and modelling both is how a damage variant stops + * being legible as a *specific* event and turns into general grime. + */ + +export function createModel(): WindowModel { + return createWindowModel({ + id: 'damaged-broken-window', + condition: 0.96, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const red = signalLamp(bundle, 'RED-500', 3_290) + + buildBay(frame, m, red) + + const [hx, hy] = APERTURE_HALF + const z = PLATE_FRONT - 0.135 + + // Surviving glass: slivers still held by the bead. Each is a wedge whose + // wide end is at the frame and whose point aims at the impact, which is + // how glass actually fails — radially, away from the strike. + const impact: readonly [number, number] = [-hx * 0.42, WINDOW_KIT.centreY - hy * 0.44] + const shards: readonly (readonly [number, number][])[] = [ + [[-hx, hy], [hx * 0.1, hy], [-hx * 0.2, hy * 0.42], [-hx, hy * 0.5]], + [[hx * 0.22, hy], [hx, hy], [hx, hy * 0.1], [hx * 0.34, hy * 0.46]], + [[hx, -hy * 0.06], [hx, -hy], [hx * 0.3, -hy], [hx * 0.42, -hy * 0.3]], + [[-hx, hy * 0.34], [-hx * 0.34, hy * 0.2], [-hx * 0.5, -hy * 0.08], [-hx, -hy * 0.1]], + ] + for (const shard of shards) { + frame.add(extrudeProfile(m.glass, shard.map(([x, y]) => [x, y] as [number, number]), 0.014, [ + 0, WINDOW_KIT.centreY, z, + ], { fillet: 0.003, bevel: 0.004 })) + } + + // Failed bead along the bottom-left, peeled back from the opening. + box(frame, m.shellShade, [hx * 0.7, 0.03, 0.03], [-hx * 0.4, WINDOW_KIT.centreY - hy + 0.03, z + 0.05], { + chamfer: 0.008, fillet: 0.003, bevel: 0.003, rotation: [0, 0, -0.22], + }) + + // Fallen fragments caught on the cill. + const cillY = WINDOW_KIT.centreY - hy - 0.048 + for (let index = 0; index < 7; index += 1) { + const t = (index / 6 - 0.5) * 1.6 + box(frame, m.glass, [0.03 + (index % 3) * 0.018, 0.012, 0.028], [ + -hx * 0.35 + t * 0.28, cillY, 0.05 + (index % 2) * 0.05, + ], { chamfer: 0.004, fillet: 0.002, bevel: 0.002, rotation: [0, index * 0.7, 0] }) + } + + // The dead lamp: fixture present, circuit gone. Its housing is crushed on + // the impact side, which is what took the circuit out. + box(frame, m.ink, [hx * 0.7, 0.048, 0.038], [-hx * 0.42, WINDOW_KIT.centreY - hy + 0.028, PLATE_FRONT - 0.05], { + chamfer: 0.012, fillet: 0.005, bevel: 0.004, rotation: [0, 0, 0.09], + }) + box(frame, m.amberDim, [hx * 0.5, 0.022, 0.024], [-hx * 0.42, WINDOW_KIT.centreY - hy + 0.028, PLATE_FRONT - 0.036], { + chamfer: 0.007, fillet: 0.003, bevel: 0.003, rotation: [0, 0, 0.09], + }) + + // Impact scatter on the plate: a short scar, only around the strike. + for (const [dx, dy, angle] of [[0.09, 0.05, 0.6], [-0.07, -0.04, -0.5], [0.03, -0.1, 1.1]] as const) { + box(frame, m.shellShade, [0.11, 0.02, 0.014], [impact[0] + dx, impact[1] + dy, PLATE_FRONT + 0.02], { + chamfer: 0.005, fillet: 0.002, bevel: 0.002, rotation: [0, 0, angle], + }) + } + + return { + sockets: { + fx_impact: [impact[0], impact[1], z], + cover_glazing: [0, WINDOW_KIT.centreY, z], + }, + tick: (elapsed) => { + // A fault flash, not the steady breathe the healthy bays use. + red.emissiveIntensity = 0.3 + (Math.sin(elapsed * 5.1) > 0.3 ? 0.55 : 0) + }, + } + }, + }) +} + +export function createPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), options) +} diff --git a/assets/prototypes/external-sunshade/model.ts b/assets/prototypes/external-sunshade/model.ts new file mode 100644 index 00000000..a0506fed --- /dev/null +++ b/assets/prototypes/external-sunshade/model.ts @@ -0,0 +1,109 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, box, boltRun, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + APERTURE_HALF, + PLATE_FRONT, + WINDOW_KIT, + buildBay, + createWindowModel, + createWindowPreview, + glazing, + signalLamp, + type WindowModel, + type WindowPreviewOptions, +} from '../axiom-window-kit/index.ts' + +const BLADES = 6 +/** Projection of the shade, and the reach of the arms that carry it. */ +const PROJECTION = 0.46 + +/** + * Axiom Relay external sunshade — the brise-soleil over a standard bay. + * + * A sunshade is the one module in the group that is mostly *outside* the wall, + * and that is the whole design: aerofoil blades on two arms, projecting far + * enough to cut a high sun and angled steeply enough to pass a low one. It is + * fitted to the standard bay rather than replacing it, so the same window can be + * shaded on a south elevation and bare on a north one without becoming two + * different props. + * + * The blades rotate as one on a common tie bar. A shade whose blades are fixed + * is a canopy; the thing that makes it a brise-soleil is that it tracks. + */ + +export function createModel(): WindowModel { + return createWindowModel({ + id: 'external-sunshade', + condition: 0.56, + envelope: { width: WINDOW_KIT.width, depth: WINDOW_KIT.depth + PROJECTION, height: WINDOW_KIT.height }, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const blades = part('blades') + blades.name = blades.name.replace(/_DEFAULT$/, '_CLOSED') + const amber = signalLamp(bundle, 'AMBER-400', 3_400) + + buildBay(frame, m, amber) + glazing(frame, m) + + // Support arms: two cantilevers off the head, each with a diagonal tie + // back into the plate, because a 460 mm cantilever carrying six blades + // does not hold itself up. + const armY = WINDOW_KIT.centreY + APERTURE_HALF[1] + 0.12 + const armX = WINDOW_KIT.width * 0.5 - 0.26 + for (const sx of [-1, 1]) { + box(frame, m.graphite, [0.055, 0.075, PROJECTION], [sx * armX, armY, PLATE_FRONT + PROJECTION * 0.5], { + chamfer: 0.018, fillet: 0.007, bevel: 0.006, + }) + box(frame, m.steel, [0.03, 0.24, 0.03], [sx * armX, armY - 0.11, PLATE_FRONT + 0.16], { + chamfer: 0.009, fillet: 0.004, bevel: 0.003, rotation: [-0.86, 0, 0], + }) + box(frame, m.graphiteEdge, [0.11, 0.12, 0.07], [sx * armX, armY, PLATE_FRONT + 0.03], { + chamfer: 0.024, fillet: 0.009, bevel: 0.008, + }) + boltRun(frame, m.steel, [sx * armX, armY - 0.04, PLATE_FRONT + 0.07], [sx * armX, armY + 0.04, PLATE_FRONT + 0.07], 2, 0.012, 'front') + } + + // The blades, on a common tie bar. Each is a shallow aerofoil: a wide + // plate with a rolled leading edge and a thinner trailing lip. + const span = armX * 2 + 0.16 + for (let index = 0; index < BLADES; index += 1) { + const z = PLATE_FRONT + 0.08 + (index / (BLADES - 1)) * (PROJECTION - 0.14) + const blade = box(blades, m.shellLight, [span, 0.105, 0.028], [0, armY - 0.02, z], { + chamfer: 0.014, fillet: 0.006, bevel: 0.007, + }) + blade.rotation.x = -0.62 + blades.add(cylinder(m.steel, 0.014, span, [0, armY - 0.02 + 0.048, z - 0.026], AXIS_X, 8)) + } + // Tie bar linking the blades' trailing edges, and its actuator crank. + box(blades, m.graphiteEdge, [0.03, 0.03, PROJECTION - 0.1], [armX + 0.05, armY - 0.09, PLATE_FRONT + PROJECTION * 0.5], { + chamfer: 0.008, fillet: 0.003, bevel: 0.003, + }) + + return { + assemblies: [blades], + cycleSeconds: 2.8, + apply: (blend) => { + // `open` rotates the blades flat, letting the light through; closed + // is the steep shading angle they rest at. + blades.rotation.x = blend * 0.62 + }, + sockets: { + mount_shade_left: [-armX, armY, PLATE_FRONT + PROJECTION], + mount_shade_right: [armX, armY, PLATE_FRONT + PROJECTION], + fx_shade_pivot: [0, armY - 0.02, PLATE_FRONT + PROJECTION * 0.5], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.64 + Math.sin(elapsed * 1.1) * 0.08 + }, + } + }, + }) +} + +export function createPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), { distance: 3.7, pitch: 0.3, ...options }) +} + +export function createOpenPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), { ...options, state: 'open' }) +} diff --git a/assets/prototypes/high-rise-curtain-glazing/model.ts b/assets/prototypes/high-rise-curtain-glazing/model.ts new file mode 100644 index 00000000..f708e997 --- /dev/null +++ b/assets/prototypes/high-rise-curtain-glazing/model.ts @@ -0,0 +1,128 @@ +import { box, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + APERTURE_HALF, + PLATE_FRONT, + WINDOW_KIT, + apertureLamps, + bayPlate, + createWindowModel, + createWindowPreview, + glazing, + plateBorder, + plateFixings, + signalLamp, + tiledWidth, + type WindowModel, + type WindowPreviewOptions, +} from '../axiom-window-kit/index.ts' + +/** Two bays wide, two storeys tall, on the brief's tiling grid. */ +const COLUMNS = 2 +const ROWS = 2 +// Two bays: an even count, so the run terminates on the 1 m grid at 3 m. +const WIDTH = tiledWidth(COLUMNS) +const STOREY = WINDOW_KIT.height +const HEIGHT = STOREY * ROWS + +/** + * Axiom Relay high-rise curtain glazing — the bay tiled in two directions. + * + * A curtain wall is not a big window: it is a non-loadbearing skin hung off the + * floor slabs behind it, so its structure runs *vertically* through unbroken + * mullions and only stops at the transoms that mark each slab edge. That is why + * the mullions here are continuous over the full 2.8 m and the transoms are + * interrupted by them, rather than the two being drawn as an even grid. Drawn as + * a grid the module reads as a warehouse window enlarged; drawn this way it + * reads as a facade. + * + * The spandrel — the opaque band at each slab line — is the other half of the + * difference. It is where the floor structure actually is, and a curtain wall + * without one is a glass box with invisible floors. + */ + +export function createModel(): WindowModel { + return createWindowModel({ + id: 'high-rise-curtain-glazing', + condition: 0.22, + bays: COLUMNS * ROWS, + envelope: { width: WIDTH, depth: WINDOW_KIT.depth, height: HEIGHT }, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const cyan = signalLamp(bundle, 'CYAN-400', 2_850) + + for (let row = 0; row < ROWS; row += 1) { + // The kit's builders are authored around one centre height, so a second + // storey is placed by lifting a group rather than by re-parameterising + // every helper on Y. + const storey = part(`storey-${row}`) + storey.position.y = row * STOREY + for (let column = 0; column < COLUMNS; column += 1) { + const centreX = (column - (COLUMNS - 1) / 2) * WINDOW_KIT.bayPitch + bayPlate(storey, m, { centreX, width: WINDOW_KIT.bayPitch, border: false }) + apertureLamps(storey, m, cyan, { centreX }) + glazing(storey, m, { centreX }) + } + // Spandrel band at the slab line: opaque, and deeper than the glass. + box(storey, m.shellShade, [WIDTH - 0.12, 0.16, 0.06], [0, WINDOW_KIT.centreY - APERTURE_HALF[1] - 0.13, PLATE_FRONT + 0.02], { + chamfer: 0.03, fillet: 0.01, bevel: 0.009, + }) + box(storey, m.graphite, [WIDTH - 0.2, 0.05, 0.09], [0, WINDOW_KIT.centreY - APERTURE_HALF[1] - 0.13, PLATE_FRONT + 0.05], { + chamfer: 0.014, fillet: 0.006, bevel: 0.005, + }) + } + + // Mullions: continuous over the full height, standing proud of the skin. + for (let index = 0; index <= COLUMNS; index += 1) { + const x = (index - COLUMNS / 2) * WINDOW_KIT.bayPitch + box(frame, m.graphite, [0.1, HEIGHT - 0.04, 0.13], [x, HEIGHT * 0.5, PLATE_FRONT + 0.03], { + chamfer: 0.028, fillet: 0.01, bevel: 0.009, + }) + box(frame, m.shellShade, [0.044, HEIGHT - 0.16, 0.03], [x, HEIGHT * 0.5, PLATE_FRONT + 0.1], { + chamfer: 0.012, fillet: 0.005, bevel: 0.004, + }) + } + // Transoms: only between mullions, because that is what they span. + for (let row = 1; row < ROWS; row += 1) { + for (let column = 0; column < COLUMNS; column += 1) { + const centreX = (column - (COLUMNS - 1) / 2) * WINDOW_KIT.bayPitch + box(frame, m.graphiteEdge, [WINDOW_KIT.bayPitch - 0.11, 0.07, 0.1], [centreX, row * STOREY, PLATE_FRONT + 0.024], { + chamfer: 0.02, fillet: 0.008, bevel: 0.007, + }) + } + } + + // The border and fixings are the only parts authored about the kit's + // single-bay centre rather than about a storey, so they get their own + // group and it is lifted once. Shifting `frame` wholesale instead would + // take the mullions and transoms — which are already in run coordinates — + // up with it, and push the mullions a full storey off the glass. + const skin = part('skin') + skin.position.y = HEIGHT * 0.5 - WINDOW_KIT.centreY + plateBorder(skin, m, { width: WIDTH, height: HEIGHT }) + plateFixings(skin, m, { width: WIDTH, height: HEIGHT }) + + return { + sockets: { + mount_left: [-WIDTH * 0.5, HEIGHT * 0.5, 0], + mount_right: [WIDTH * 0.5, HEIGHT * 0.5, 0], + window_head: [0, HEIGHT, 0], + window_cill: [0, 0, 0], + mount_slab_line: [0, STOREY, PLATE_FRONT], + }, + tick: (elapsed) => { + cyan.emissiveIntensity = 0.58 + Math.sin(elapsed * 0.7) * 0.06 + }, + } + }, + }) +} + +export function createPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), { + target: [0, HEIGHT * 0.5, 0], + distance: 8.2, + yaw: -0.5, + pitch: 0.12, + ...options, + }) +} diff --git a/assets/prototypes/laboratory-window/model.ts b/assets/prototypes/laboratory-window/model.ts new file mode 100644 index 00000000..623d1510 --- /dev/null +++ b/assets/prototypes/laboratory-window/model.ts @@ -0,0 +1,103 @@ +import { extrudeProfile } from '../../../src/asset-forge/generator/index.ts' +import { box, boltRun, slot, statusLens, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + APERTURE_HALF, + PLATE_FRONT, + WINDOW_KIT, + buildBay, + createWindowModel, + createWindowPreview, + glazing, + signalLamp, + type WindowModel, + type WindowPreviewOptions, +} from '../axiom-window-kit/index.ts' + +/** + * Axiom Relay laboratory window — the sealed, cleanable port. + * + * A laboratory window's requirement is not strength, it is that nothing can + * collect in it: no ledge, no open fastener head, no gap a swab cannot reach. + * So this is the one module in the group whose reveal is *coved* rather than + * stepped, whose fixings are capped, and whose cill is flush with the plate + * instead of throwing water clear — there is no weather on the inside of a + * containment wall, and a drip board is somewhere for contamination to sit. + * + * The pass-through drawer beside the aperture is the module's real function: + * samples cross the boundary, people do not. + */ + +export function createModel(): WindowModel { + return createWindowModel({ + id: 'laboratory-window', + condition: 0.14, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const drawer = part('drawer') + drawer.name = drawer.name.replace(/_DEFAULT$/, '_CLOSED') + const cyan = signalLamp(bundle, 'CYAN-400', 3_070) + + buildBay(frame, m, cyan) + glazing(frame, m, { thickness: 0.026 }) + + // Coved seal ring: a soft rubber return between glass and reveal, so + // there is no square internal corner anywhere in the aperture. + const [hx, hy] = APERTURE_HALF + frame.add(extrudeProfile(m.rubber, slot(hx + 0.014, hy + 0.014, WINDOW_KIT.clip - 0.008), 0.038, [ + 0, WINDOW_KIT.centreY, PLATE_FRONT - 0.118, + ], { + fillet: 0.014, + bevel: 0.016, + holes: [slot(hx - 0.026, hy - 0.026, WINDOW_KIT.clip - 0.026)], + })) + + // Capped fixings: the heads are covered rather than exposed. + for (const sx of [-1, 1]) { + for (const sy of [-1, 1]) { + box(frame, m.shellLight, [0.055, 0.055, 0.022], [ + sx * (hx + 0.028), WINDOW_KIT.centreY + sy * (hy + 0.028), PLATE_FRONT + 0.03, + ], { chamfer: 0.02, fillet: 0.008, bevel: 0.007 }) + } + } + + // Pass-through drawer: a sealed tray in the plate below the aperture, + // with its own interlock read. Interlocked, because a pass-through that + // can be open on both sides at once is a hole in the containment. + const drawerY = WINDOW_KIT.centreY - hy - 0.2 + box(frame, m.graphite, [0.42, 0.15, 0.07], [0, drawerY, PLATE_FRONT + 0.01], { + chamfer: 0.03, fillet: 0.011, bevel: 0.009, + }) + statusLens(frame, m, [0.04, 0.04], [0.24, drawerY, PLATE_FRONT + 0.05], cyan, 'front') + box(drawer, m.shellLight, [0.37, 0.105, 0.12], [0, drawerY, PLATE_FRONT + 0.055], { + chamfer: 0.024, fillet: 0.009, bevel: 0.008, capChamfer: [0.02, 0.01], + }) + box(drawer, m.steel, [0.16, 0.03, 0.028], [0, drawerY, PLATE_FRONT + 0.125], { + chamfer: 0.008, fillet: 0.004, bevel: 0.003, + }) + boltRun(frame, m.steel, [-0.24, drawerY, PLATE_FRONT + 0.05], [-0.24, drawerY + 0.05, PLATE_FRONT + 0.05], 2, 0.011, 'front') + + return { + assemblies: [drawer], + cycleSeconds: 1.1, + apply: (blend) => { + drawer.position.z = blend * 0.22 + }, + sockets: { + cover_pass_through: [0, drawerY, PLATE_FRONT + 0.055], + fx_seal_ring: [0, WINDOW_KIT.centreY, PLATE_FRONT - 0.118], + }, + tick: (elapsed) => { + cyan.emissiveIntensity = 0.66 + Math.sin(elapsed * 0.9) * 0.06 + }, + } + }, + }) +} + +export function createPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), options) +} + +export function createOpenPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), { ...options, state: 'open' }) +} diff --git a/assets/prototypes/long-horizontal-window/model.ts b/assets/prototypes/long-horizontal-window/model.ts new file mode 100644 index 00000000..2f5ecbd2 --- /dev/null +++ b/assets/prototypes/long-horizontal-window/model.ts @@ -0,0 +1,102 @@ +import { box, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + PLATE_FRONT, + WINDOW_KIT, + actuatorRam, + apertureLamps, + bayPlate, + cill, + controlPaddle, + createWindowModel, + createWindowPreview, + glazing, + plateBorder, + plateFixings, + plateStrips, + signalLamp, + tiledWidth, + type WindowModel, + type WindowPreviewOptions, +} from '../axiom-window-kit/index.ts' + +/** + * Four bays. An even count, because a run of 1.5 m bays only terminates on the + * production rules' 1 m grid when the count is even — three bays would be 4.5 m + * and would push everything placed after it half a metre off the grid. + */ +const BAYS = 4 +const WIDTH = tiledWidth(BAYS) + +/** + * Axiom Relay long horizontal window — the ribbon light. + * + * The brief says long and curtain variants tile on the grid, and this is the + * module that takes that literally: it is four window-frame bays sharing one + * plate, not one bay stretched to 6 m. That distinction is the whole point of a + * bay pitch — a stretched bay has a 6 m aperture with the same 50 mm reveal, + * which reads as a slot cut in a panel; four tiled bays keep the reveal, the + * lamps, and the mullions at the size a hand and an eye expect them. + * + * Only the outer bays carry the plate's stepped border. The mullions between + * them are structure, and structure that is drawn as two picture frames meeting + * back to back is not carrying anything. + */ + +export function createModel(): WindowModel { + return createWindowModel({ + id: 'long-horizontal-window', + condition: 0.44, + bays: BAYS, + envelope: { width: WIDTH, depth: WINDOW_KIT.depth, height: WINDOW_KIT.height }, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const amber = signalLamp(bundle, 'AMBER-400', 2_740) + + // One continuous plate for the whole run, cut with three apertures. + for (let index = 0; index < BAYS; index += 1) { + const centreX = (index - (BAYS - 1) / 2) * WINDOW_KIT.bayPitch + bayPlate(frame, m, { centreX, width: WINDOW_KIT.bayPitch, border: false }) + apertureLamps(frame, m, amber, { centreX }) + glazing(frame, m, { centreX }) + cill(frame, m, { centreX, width: WINDOW_KIT.bayPitch }) + actuatorRam(frame, m, { centreX }) + } + + // Mullions: one structural post per bay boundary, running the full height + // and standing proud of the plate, so the run reads as framed rather than + // as a plate with three holes in it. + for (let index = 1; index < BAYS; index += 1) { + const x = (index - BAYS / 2) * WINDOW_KIT.bayPitch + box(frame, m.graphite, [0.11, WINDOW_KIT.height - 0.1, 0.11], [x, WINDOW_KIT.centreY, PLATE_FRONT + 0.005], { + chamfer: 0.03, fillet: 0.01, bevel: 0.009, + }) + box(frame, m.shellShade, [0.05, WINDOW_KIT.height - 0.28, 0.03], [x, WINDOW_KIT.centreY, PLATE_FRONT + 0.062], { + chamfer: 0.014, fillet: 0.005, bevel: 0.004, + }) + } + + // One border around the whole run, so three bays read as one ribbon. + plateBorder(frame, m, { width: WIDTH }) + plateFixings(frame, m, { width: WIDTH }) + plateStrips(frame, m, { centreX: 0 }) + controlPaddle(frame, m, amber, { width: WIDTH }) + + return { + sockets: { + mount_left: [-WIDTH * 0.5, WINDOW_KIT.centreY, 0], + mount_right: [WIDTH * 0.5, WINDOW_KIT.centreY, 0], + window_bay_left: [-WINDOW_KIT.bayPitch * 1.5, WINDOW_KIT.centreY, PLATE_FRONT], + window_bay_right: [WINDOW_KIT.bayPitch * 1.5, WINDOW_KIT.centreY, PLATE_FRONT], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.7 + Math.sin(elapsed * 1.3) * 0.1 + }, + } + }, + }) +} + +export function createPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), { distance: 10.8, yaw: -0.42, pitch: 0.14, ...options }) +} + diff --git a/assets/prototypes/luxury-residential-window/model.ts b/assets/prototypes/luxury-residential-window/model.ts new file mode 100644 index 00000000..70288889 --- /dev/null +++ b/assets/prototypes/luxury-residential-window/model.ts @@ -0,0 +1,113 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, box, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + PLATE_FRONT, + WINDOW_KIT, + apertureLamps, + bayPlate, + cill, + createWindowModel, + createWindowPreview, + glazing, + plateFixings, + signalLamp, + type WindowModel, + type WindowPreviewOptions, +} from '../axiom-window-kit/index.ts' + +/** A deliberately larger opening in the same plate: less frame, more view. */ +const WIDE_HALF: readonly [number, number] = [0.58, 0.5] + +/** + * Axiom Relay luxury residential window — the bay with its hardware hidden. + * + * Luxury in this visual system is not ornament, it is *absence*: the same bay + * with the aperture opened up, the reveal slimmed to a shadow gap, the control + * paddle and plate strips deleted, and the actuator ram concealed behind a cover + * instead of displayed. Everything the industrial modules show off because it + * proves they are serviceable, this one hides because someone lives here. + * + * What it adds instead is the only thing a residential window has that a plant + * room does not: a projecting ledge deep enough to sit on, and a fabric blind in + * a head cassette. + */ + +export function createModel(): WindowModel { + return createWindowModel({ + id: 'luxury-residential-window', + condition: 0.12, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const blind = part('blind') + blind.name = blind.name.replace(/_DEFAULT$/, '_CLOSED') + const amber = signalLamp(bundle, 'AMBER-400', 3_180, 0.5) + + bayPlate(frame, m, { half: WIDE_HALF }) + plateFixings(frame, m) + apertureLamps(frame, m, amber, { half: WIDE_HALF }) + glazing(frame, m, { half: WIDE_HALF, thickness: 0.022 }) + cill(frame, m, { half: WIDE_HALF }) + + // Ledge: the projecting sill board, deep enough to be furniture. + const ledgeY = WINDOW_KIT.centreY - WIDE_HALF[1] - 0.1 + box(frame, m.shellLight, [WINDOW_KIT.width - 0.2, 0.055, 0.4], [0, ledgeY, 0.16], { + chamfer: 0.024, fillet: 0.01, bevel: 0.012, capChamfer: [0.03, 0.015], + }) + for (const sx of [-1, 1]) { + // Brackets under it, because a 400 mm cantilever needs them and a + // ledge that floats is the detail that gives a render away. + box(frame, m.steel, [0.03, 0.13, 0.22], [sx * 0.42, ledgeY - 0.08, 0.18], { + chamfer: 0.012, fillet: 0.005, bevel: 0.005, rotation: [0.5, 0, 0], + }) + } + + // Head cassette and the blind rolled inside it. + const headY = WINDOW_KIT.centreY + WIDE_HALF[1] + 0.09 + box(frame, m.shellLight, [WINDOW_KIT.width - 0.26, 0.11, 0.13], [0, headY, PLATE_FRONT - 0.045], { + chamfer: 0.032, fillet: 0.012, bevel: 0.01, capChamfer: [0.03, 0.015], + }) + frame.add(cylinder(m.graphiteEdge, 0.04, WINDOW_KIT.width - 0.34, [0, headY, PLATE_FRONT - 0.045], AXIS_X, 12)) + box(blind, m.fabric, [WIDE_HALF[0] * 2 - 0.02, WIDE_HALF[1] * 2, 0.012], [ + 0, WINDOW_KIT.centreY, PLATE_FRONT - 0.09, + ], { chamfer: 0.008, fillet: 0.003, bevel: 0.003 }) + box(blind, m.steel, [WIDE_HALF[0] * 2 - 0.02, 0.03, 0.026], [ + 0, WINDOW_KIT.centreY - WIDE_HALF[1], PLATE_FRONT - 0.09, + ], { chamfer: 0.008, fillet: 0.003, bevel: 0.003 }) + + // Concealed ram: a cover over the actuator rather than the bare hardware. + box(frame, m.shellShade, [0.075, WIDE_HALF[1] * 1.7, 0.055], [ + -WIDE_HALF[0] + 0.05, WINDOW_KIT.centreY, PLATE_FRONT - 0.09, + ], { chamfer: 0.02, fillet: 0.008, bevel: 0.007 }) + + return { + assemblies: [blind], + cycleSeconds: 2.2, + apply: (blend) => { + // `open` retracts the blind into the cassette. The scale is anchored + // at the head, so it rolls up rather than shrinking about its middle. + const drop = 1 - blend + blind.scale.y = Math.max(0.001, drop) + blind.position.y = (1 - drop) * (WINDOW_KIT.centreY + WIDE_HALF[1]) + }, + sockets: { + window_head: [0, headY, PLATE_FRONT], + window_cill: [0, ledgeY, 0.16], + cover_blind: [0, WINDOW_KIT.centreY, PLATE_FRONT - 0.09], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.48 + Math.sin(elapsed * 0.6) * 0.05 + }, + } + }, + }) +} + +export function createPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), { ...options, state: options.state ?? 'open' }) +} + +/** Closed: the blind down, which is the state the fabric actually reads in. */ +export function createClosedPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), { ...options, state: 'closed' }) +} + diff --git a/assets/prototypes/observation-window/model.ts b/assets/prototypes/observation-window/model.ts new file mode 100644 index 00000000..1e4a9ad1 --- /dev/null +++ b/assets/prototypes/observation-window/model.ts @@ -0,0 +1,84 @@ +import { extrudeProfile } from '../../../src/asset-forge/generator/index.ts' +import { box, slot, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + APERTURE_HALF, + PLATE_FRONT, + WINDOW_KIT, + buildBay, + createWindowModel, + createWindowPreview, + glazing, + signalLamp, + type WindowModel, + type WindowPreviewOptions, +} from '../axiom-window-kit/index.ts' + +/** + * Axiom Relay observation window — the pressure-rated port. + * + * The thing that makes an observation window different from a window is that it + * is rated: the pane is thick enough to have a visible edge, it is clamped by a + * retaining ring rather than beaded in, and the aperture is stepped so the pane + * seats against a shoulder instead of against a seal alone. Those three parts + * are the whole design, and they are why the group's amber aperture lamps read + * *through* the glass here rather than around it — the light is inboard of the + * pane, in the thickness of the port. + */ + +export function createModel(): WindowModel { + return createWindowModel({ + id: 'observation-window', + condition: 0.32, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const amber = signalLamp(bundle, 'AMBER-400', 2_520) + + buildBay(frame, m, amber) + // A thick pane, so its edge catches a highlight the group's thin glazing + // never does. This is the module's whole near read. + glazing(frame, m, { thickness: 0.055 }) + + // Retaining ring: the clamp that holds the pane against its shoulder, + // with the fasteners that tension it. + const [hx, hy] = APERTURE_HALF + frame.add(extrudeProfile(m.steel, slot(hx + 0.006, hy + 0.006, WINDOW_KIT.clip - 0.01), 0.03, [ + 0, WINDOW_KIT.centreY, PLATE_FRONT - 0.098, + ], { + fillet: 0.006, + bevel: 0.008, + holes: [slot(hx - 0.042, hy - 0.042, WINDOW_KIT.clip - 0.03)], + })) + for (let index = 0; index < 6; index += 1) { + const angle = (index / 6) * Math.PI * 2 + Math.PI / 12 + box(frame, m.graphiteEdge, [0.05, 0.05, 0.038], [ + Math.cos(angle) * (hx - 0.018), + WINDOW_KIT.centreY + Math.sin(angle) * (hy - 0.018), + PLATE_FRONT - 0.09, + ], { chamfer: 0.012, fillet: 0.005, bevel: 0.004 }) + } + + // Pressure equalisation port beside the paddle: the fitting that explains + // why the pane needs a retaining ring at all. + box(frame, m.graphite, [0.09, 0.09, 0.045], [WINDOW_KIT.width * 0.5 - 0.135, WINDOW_KIT.centreY - 0.29, PLATE_FRONT + 0.026], { + chamfer: 0.025, fillet: 0.009, bevel: 0.007, + }) + box(frame, m.steel, [0.11, 0.024, 0.024], [WINDOW_KIT.width * 0.5 - 0.135, WINDOW_KIT.centreY - 0.29, PLATE_FRONT + 0.055], { + chamfer: 0.007, fillet: 0.003, bevel: 0.003, + }) + + return { + sockets: { + pipe_equalise: [WINDOW_KIT.width * 0.5 - 0.135, WINDOW_KIT.centreY - 0.29, PLATE_FRONT], + cover_retaining_ring: [0, WINDOW_KIT.centreY, PLATE_FRONT - 0.098], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.78 + Math.sin(elapsed * 1.1) * 0.1 + }, + } + }, + }) +} + +export function createPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), options) +} diff --git a/assets/prototypes/small-industrial-window/model.ts b/assets/prototypes/small-industrial-window/model.ts new file mode 100644 index 00000000..6069576d --- /dev/null +++ b/assets/prototypes/small-industrial-window/model.ts @@ -0,0 +1,87 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_Y, box, boltRun, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + PLATE_FRONT, + WINDOW_KIT, + buildBay, + createWindowModel, + createWindowPreview, + glazing, + glazingBars, + signalLamp, + type WindowModel, + type WindowPreviewOptions, +} from '../axiom-window-kit/index.ts' + +/** A tighter aperture in the same plate: the bay is shared, the opening is not. */ +const SMALL_HALF: readonly [number, number] = [0.34, 0.29] + +/** + * Axiom Relay small industrial window — the plant-room light. + * + * This is the module that proves the *bay* is the shared thing rather than the + * aperture. Same 1.5 m plate, same reveal language, same cill — but the opening + * is barely a third of the area, because a plant room wants daylight and does + * not want a 1 m hole in a wall carrying services. What the plate loses in + * aperture it gains in hardware: a protective grille, corner gussets, and the + * fixings to carry both. + */ + +export function createModel(): WindowModel { + return createWindowModel({ + id: 'small-industrial-window', + condition: 0.74, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const amber = signalLamp(bundle, 'AMBER-400', 2_630) + + buildBay(frame, m, amber, { half: SMALL_HALF }) + glazing(frame, m, { half: SMALL_HALF }) + glazingBars(frame, m, 2, 2, { half: SMALL_HALF }) + + // Protective grille: four bars and a surround, standing off the glass so + // it reads as a guard rather than as a pattern printed on the pane. + const [hx, hy] = SMALL_HALF + const z = PLATE_FRONT + 0.02 + for (let index = 0; index < 4; index += 1) { + box(frame, m.ironOxide, [0.026, hy * 2 - 0.02, 0.026], [ + (index - 1.5) * (hx * 2 - 0.08) / 3.4, WINDOW_KIT.centreY, z, + ], { chamfer: 0.008, fillet: 0.003, bevel: 0.003 }) + } + for (const sy of [-1, 1]) { + box(frame, m.ironOxide, [hx * 2 - 0.02, 0.03, 0.03], [0, WINDOW_KIT.centreY + sy * (hy - 0.02), z], { + chamfer: 0.009, fillet: 0.004, bevel: 0.003, + }) + } + for (const sx of [-1, 1]) { + for (const sy of [-1, 1]) { + frame.add(cylinder(m.steel, 0.02, 0.07, [sx * (hx - 0.02), WINDOW_KIT.centreY + sy * (hy - 0.02), z - 0.02], AXIS_Y, 8)) + } + } + + // Corner gussets on the plate: the plate is mostly solid here, so it gets + // the stiffening a mostly-glazed bay does not need. + for (const sx of [-1, 1]) { + for (const sy of [-1, 1]) { + box(frame, m.shellShade, [0.2, 0.055, 0.016], [ + sx * 0.44, WINDOW_KIT.centreY + sy * 0.42, PLATE_FRONT + 0.026, + ], { chamfer: 0.014, fillet: 0.005, bevel: 0.004, rotation: [0, 0, sx * sy * 0.62] }) + } + } + boltRun(frame, m.steel, [-0.52, WINDOW_KIT.centreY, PLATE_FRONT + 0.03], [0.52, WINDOW_KIT.centreY, PLATE_FRONT + 0.03], 2, 0.014, 'front') + + return { + sockets: { + cover_grille: [0, WINDOW_KIT.centreY, PLATE_FRONT + 0.02], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.6 + Math.sin(elapsed * 0.9) * 0.08 + }, + } + }, + }) +} + +export function createPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), options) +} diff --git a/assets/prototypes/window-frame/model.ts b/assets/prototypes/window-frame/model.ts new file mode 100644 index 00000000..495e541e --- /dev/null +++ b/assets/prototypes/window-frame/model.ts @@ -0,0 +1,51 @@ +import type { CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + APERTURE_HALF, + PLATE_FRONT, + WINDOW_KIT, + buildBay, + createWindowModel, + createWindowPreview, + signalLamp, + type WindowModel, + type WindowPreviewOptions, +} from '../axiom-window-kit/index.ts' + +/** + * Axiom Relay window frame — the shared bay with nothing glazed into it. + * + * Every other module in the windows group is this plus a pane, so it ships on + * its own: an opening onto an interior, a serving hatch, a bulkhead port that a + * scripted shutter will fill later, or the reference a new variant is authored + * against. It carries the whole interface — plate, reveal ring, glazing seat, + * cill and drip, aperture lamps, plate strips, control paddle, and the actuator + * ram a closing variant needs — which is what makes the group a kit rather than + * eleven lookalikes. + */ + +export function createModel(): WindowModel { + return createWindowModel({ + id: 'window-frame', + condition: 0.36, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const amber = signalLamp(bundle, 'AMBER-400', 2_410) + buildBay(frame, m, amber) + return { + sockets: { + // The glazing interface, published because the frame's whole job is + // to be the thing other modules are authored against. + cover_seat_left: [-APERTURE_HALF[0], WINDOW_KIT.centreY, PLATE_FRONT - 0.135], + cover_seat_right: [APERTURE_HALF[0], WINDOW_KIT.centreY, PLATE_FRONT - 0.135], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.72 + Math.sin(elapsed * 1.5) * 0.1 + }, + } + }, + }) +} + +export function createPreview(options: WindowPreviewOptions = {}): CargoPreview { + return createWindowPreview(createModel(), 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({