From 38c34b9e2fc084cb72a14150428ed433ebc8f33f Mon Sep 17 00:00:00 2001 From: reposlayer Date: Sun, 16 Aug 2026 06:17:59 +0200 Subject: [PATCH 1/4] Add the Axiom Relay windows group and its shared window kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten of the twelve briefs under docs/assets/reusable/architecture/windows had no model source. This adds all ten, built on one new support item rather than as ten independent props. axiom-window-kit treats the brief's 1.5 x 0.20 x 1.4 m envelope as a bay pitch rather than a fixed prop size, because the brief says long and curtain variants tile on the grid. That makes long-horizontal-window three bays and high-rise-curtain-glazing a 2x2 grid of the same 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 and reveal ring, glazing seat, cill and drip, aperture lamps, plate strips, control paddle, and actuator ram, plus lit lamps at a lower emissive tier than the cargo wave's. Modules: window-frame (the bay alone), observation-window, small-industrial-window, long-horizontal-window, high-rise-curtain-glazing, corner-window, laboratory-window, luxury-residential-window, damaged-broken-window, external-sunshade. Every module was iterated against its reference plate with `bun run vibe:model preview`. typecheck, build, and the 49 existing tests pass; the registry now builds 179 items from 174 models. Co-Authored-By: Claude Opus 5 --- .changeset/architecture-windows-wave.md | 18 ++ .../prototypes/axiom-window-kit/contract.ts | 57 ++++ assets/prototypes/axiom-window-kit/frame.ts | 248 ++++++++++++++++ assets/prototypes/axiom-window-kit/index.ts | 264 ++++++++++++++++++ assets/prototypes/corner-window/model.ts | 127 +++++++++ .../prototypes/damaged-broken-window/model.ts | 105 +++++++ assets/prototypes/external-sunshade/model.ts | 108 +++++++ .../high-rise-curtain-glazing/model.ts | 126 +++++++++ assets/prototypes/laboratory-window/model.ts | 102 +++++++ .../long-horizontal-window/model.ts | 97 +++++++ .../luxury-residential-window/model.ts | 112 ++++++++ assets/prototypes/observation-window/model.ts | 84 ++++++ .../small-industrial-window/model.ts | 87 ++++++ assets/prototypes/window-frame/model.ts | 51 ++++ registries/scifi-kit/src/build.ts | 1 + 15 files changed, 1587 insertions(+) create mode 100644 .changeset/architecture-windows-wave.md create mode 100644 assets/prototypes/axiom-window-kit/contract.ts create mode 100644 assets/prototypes/axiom-window-kit/frame.ts create mode 100644 assets/prototypes/axiom-window-kit/index.ts create mode 100644 assets/prototypes/corner-window/model.ts create mode 100644 assets/prototypes/damaged-broken-window/model.ts create mode 100644 assets/prototypes/external-sunshade/model.ts create mode 100644 assets/prototypes/high-rise-curtain-glazing/model.ts create mode 100644 assets/prototypes/laboratory-window/model.ts create mode 100644 assets/prototypes/long-horizontal-window/model.ts create mode 100644 assets/prototypes/luxury-residential-window/model.ts create mode 100644 assets/prototypes/observation-window/model.ts create mode 100644 assets/prototypes/small-industrial-window/model.ts create mode 100644 assets/prototypes/window-frame/model.ts 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..e0a44365 --- /dev/null +++ b/assets/prototypes/axiom-window-kit/contract.ts @@ -0,0 +1,57 @@ +/** + * 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. So the bay pitch is the primary figure here and + * the envelope width is derived from it, not the other way round. + * + * 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. */ + bayPitch: 1.5, + front: '+Z', + pivot: 'sill-centre', +} as const) + +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..a5c0988c --- /dev/null +++ b/assets/prototypes/axiom-window-kit/index.ts @@ -0,0 +1,264 @@ +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 } 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 + built.apply?.(0) + + return { + root, + parts, + sockets, + get state() { + return state + }, + setState: (next: WindowState) => { + state = next + root.name = `AXR_ARCH_${tag}_ROOT_${next.toUpperCase()}` + 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) + } + 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..623f9aac --- /dev/null +++ b/assets/prototypes/corner-window/model.ts @@ -0,0 +1,127 @@ +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, + envelope: { width: WINDOW_KIT.bayPitch, depth: WINDOW_KIT.bayPitch, 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: [0, WINDOW_KIT.centreY, WINDOW_KIT.bayPitch], + mount_right: [WINDOW_KIT.bayPitch, WINDOW_KIT.centreY, 0], + 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..fa862e78 --- /dev/null +++ b/assets/prototypes/external-sunshade/model.ts @@ -0,0 +1,108 @@ +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') + 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..a0248e5c --- /dev/null +++ b/assets/prototypes/high-rise-curtain-glazing/model.ts @@ -0,0 +1,126 @@ +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, + 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 +const WIDTH = WINDOW_KIT.bayPitch * 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..635185d3 --- /dev/null +++ b/assets/prototypes/laboratory-window/model.ts @@ -0,0 +1,102 @@ +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') + 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..223d542a --- /dev/null +++ b/assets/prototypes/long-horizontal-window/model.ts @@ -0,0 +1,97 @@ +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, + type WindowModel, + type WindowPreviewOptions, +} from '../axiom-window-kit/index.ts' + +/** Three bays on the brief's 1 m tiling grid. */ +const BAYS = 3 +const WIDTH = WINDOW_KIT.bayPitch * 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 three window-frame bays sharing one + * plate, not one bay stretched to 4.5 m. That distinction is the whole point of + * a bay pitch — a stretched bay has a 3 m aperture with the same 50 mm reveal, + * which reads as a slot cut in a panel; three 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, WINDOW_KIT.centreY, PLATE_FRONT], + window_bay_right: [WINDOW_KIT.bayPitch, 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: 8.6, yaw: -0.44, pitch: 0.15, ...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..68e01428 --- /dev/null +++ b/assets/prototypes/luxury-residential-window/model.ts @@ -0,0 +1,112 @@ +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') + 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 d69b1601..9198b335 100644 --- a/registries/scifi-kit/src/build.ts +++ b/registries/scifi-kit/src/build.ts @@ -145,6 +145,7 @@ async function main(): Promise { await buildCoreItem(), await buildSupportItem('axiom-modular-kit'), await buildSupportItem('axiom-cargo-kit'), + await buildSupportItem('axiom-window-kit'), ] for (const modelId of modelIds) items.push(await buildModelItem(modelId)) items.push({ From 34a2422e217ecc1a04b255ebf58c221adbb86633 Mon Sep 17 00:00:00 2001 From: reposlayer Date: Sun, 16 Aug 2026 06:42:44 +0200 Subject: [PATCH 2/4] Land tiled window runs on the 1 m grid, and publish the corner's real footprint Review feedback on the windows wave. The bay pitch and the tiling rule contradicted each other. 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 - so the three-bay long window was 4.5 m wide and pushed everything placed after it half a metre off grid. Rather than break one rule to satisfy the other, the pitch stays at the brief's 1.5 m and tiled runs are constrained to even counts. A new tiledWidth() guard enforces that and throws rather than rounding, because a run that silently became 4.5 m would surface as a seam in a level instead of as an error. long-horizontal-window is now four bays at 6 m; the curtain wall's two columns were already 3 m and now go through the same guard. The contract prose and the module comments no longer claim a 1 m pitch. corner-window published the single-bay envelope, which was simply wrong for a module with two returns splayed 45 degrees each way: its mount sockets sat about a metre inside the geometry they are meant to bound. It now publishes the footprint it occupies, derived from the pitch rather than measured. Also applies the doors wave's part-naming fix here, since it is the same convention gap: articulated groups now carry the state token, so an exported open drawer, blind or shade is not full of parts claiming to be _CLOSED. Co-Authored-By: Claude Opus 5 --- .../prototypes/axiom-window-kit/contract.ts | 36 +++++++++++++++++-- assets/prototypes/axiom-window-kit/index.ts | 24 ++++++++++++- assets/prototypes/corner-window/model.ts | 15 ++++++-- assets/prototypes/external-sunshade/model.ts | 1 + .../high-rise-curtain-glazing/model.ts | 4 ++- assets/prototypes/laboratory-window/model.ts | 1 + .../long-horizontal-window/model.ts | 25 +++++++------ .../luxury-residential-window/model.ts | 1 + 8 files changed, 89 insertions(+), 18 deletions(-) diff --git a/assets/prototypes/axiom-window-kit/contract.ts b/assets/prototypes/axiom-window-kit/contract.ts index e0a44365..e5c61945 100644 --- a/assets/prototypes/axiom-window-kit/contract.ts +++ b/assets/prototypes/axiom-window-kit/contract.ts @@ -5,8 +5,15 @@ * 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. So the bay pitch is the primary figure here and - * the envelope width is derived from it, not the other way round. + * 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, @@ -26,12 +33,35 @@ export const WINDOW_KIT = Object.freeze({ outerClip: 0.2, /** Shell frame plate depth; the graphite reveal runs deeper behind it. */ platePitch: 0.16, - /** Tiling pitch for long and curtain variants. */ + /** + * 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, diff --git a/assets/prototypes/axiom-window-kit/index.ts b/assets/prototypes/axiom-window-kit/index.ts index a5c0988c..cc0c60dc 100644 --- a/assets/prototypes/axiom-window-kit/index.ts +++ b/assets/prototypes/axiom-window-kit/index.ts @@ -23,7 +23,7 @@ import { type WindowMetadata, } from './contract.ts' -export { APERTURE_HALF, PLATE_FRONT, WINDOW_KIT } from './contract.ts' +export { APERTURE_HALF, PLATE_FRONT, WINDOW_KIT, tiledWidth } from './contract.ts' export type { WindowEnvelope, WindowMetadata } from './contract.ts' export { actuatorRam, @@ -216,6 +216,23 @@ export function createWindowModel(spec: WindowBuild): WindowModel { 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 { @@ -228,6 +245,7 @@ export function createWindowModel(spec: WindowBuild): WindowModel { 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 @@ -239,6 +257,10 @@ export function createWindowModel(spec: WindowBuild): WindowModel { 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) }, diff --git a/assets/prototypes/corner-window/model.ts b/assets/prototypes/corner-window/model.ts index 623f9aac..cd30561b 100644 --- a/assets/prototypes/corner-window/model.ts +++ b/assets/prototypes/corner-window/model.ts @@ -38,7 +38,16 @@ export function createModel(): WindowModel { id: 'corner-window', condition: 0.3, bays: 2, - envelope: { width: WINDOW_KIT.bayPitch, depth: WINDOW_KIT.bayPitch, height: WINDOW_KIT.height }, + // 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) @@ -94,8 +103,8 @@ export function createModel(): WindowModel { return { sockets: { - mount_left: [0, WINDOW_KIT.centreY, WINDOW_KIT.bayPitch], - mount_right: [WINDOW_KIT.bayPitch, WINDOW_KIT.centreY, 0], + 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], diff --git a/assets/prototypes/external-sunshade/model.ts b/assets/prototypes/external-sunshade/model.ts index fa862e78..a0506fed 100644 --- a/assets/prototypes/external-sunshade/model.ts +++ b/assets/prototypes/external-sunshade/model.ts @@ -39,6 +39,7 @@ export function createModel(): WindowModel { 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) diff --git a/assets/prototypes/high-rise-curtain-glazing/model.ts b/assets/prototypes/high-rise-curtain-glazing/model.ts index a0248e5c..f708e997 100644 --- a/assets/prototypes/high-rise-curtain-glazing/model.ts +++ b/assets/prototypes/high-rise-curtain-glazing/model.ts @@ -11,6 +11,7 @@ import { plateBorder, plateFixings, signalLamp, + tiledWidth, type WindowModel, type WindowPreviewOptions, } from '../axiom-window-kit/index.ts' @@ -18,7 +19,8 @@ import { /** Two bays wide, two storeys tall, on the brief's tiling grid. */ const COLUMNS = 2 const ROWS = 2 -const WIDTH = WINDOW_KIT.bayPitch * COLUMNS +// 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 diff --git a/assets/prototypes/laboratory-window/model.ts b/assets/prototypes/laboratory-window/model.ts index 635185d3..623d1510 100644 --- a/assets/prototypes/laboratory-window/model.ts +++ b/assets/prototypes/laboratory-window/model.ts @@ -34,6 +34,7 @@ export function createModel(): WindowModel { 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) diff --git a/assets/prototypes/long-horizontal-window/model.ts b/assets/prototypes/long-horizontal-window/model.ts index 223d542a..2f5ecbd2 100644 --- a/assets/prototypes/long-horizontal-window/model.ts +++ b/assets/prototypes/long-horizontal-window/model.ts @@ -14,22 +14,27 @@ import { plateFixings, plateStrips, signalLamp, + tiledWidth, type WindowModel, type WindowPreviewOptions, } from '../axiom-window-kit/index.ts' -/** Three bays on the brief's 1 m tiling grid. */ -const BAYS = 3 -const WIDTH = WINDOW_KIT.bayPitch * BAYS +/** + * 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 three window-frame bays sharing one - * plate, not one bay stretched to 4.5 m. That distinction is the whole point of - * a bay pitch — a stretched bay has a 3 m aperture with the same 50 mm reveal, - * which reads as a slot cut in a panel; three tiled bays keep the reveal, 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 @@ -80,8 +85,8 @@ export function createModel(): WindowModel { 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, WINDOW_KIT.centreY, PLATE_FRONT], - window_bay_right: [WINDOW_KIT.bayPitch, WINDOW_KIT.centreY, PLATE_FRONT], + 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 @@ -92,6 +97,6 @@ export function createModel(): WindowModel { } export function createPreview(options: WindowPreviewOptions = {}): CargoPreview { - return createWindowPreview(createModel(), { distance: 8.6, yaw: -0.44, pitch: 0.15, ...options }) + 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 index 68e01428..70288889 100644 --- a/assets/prototypes/luxury-residential-window/model.ts +++ b/assets/prototypes/luxury-residential-window/model.ts @@ -39,6 +39,7 @@ export function createModel(): WindowModel { 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 }) From 1b75188712559cab6b4219d1e6c61c3f96168ce9 Mon Sep 17 00:00:00 2001 From: reposlayer Date: Mon, 17 Aug 2026 01:15:37 +0200 Subject: [PATCH 3/4] Discover support kits instead of listing them by hand Every batch appends its kit to one hardcoded array in this file, so two batches in flight always collide on that single line even though their kits have nothing to do with each other. That is what put four of these PRs into conflict the moment the interiors batch landed, and it would do it again for every batch after them. A prototype directory with no model.ts is a shared kit rather than a model, which the directory walk here already has to determine in order to collect the models. Registering those directly removes the hand-maintained list, so a batch that adds a kit no longer touches this file at all and there is nothing left to conflict on. Both lists are sorted, so the output stays deterministic. The registry builds the same items as before; only their order within the lib tier changes, since discovery is alphabetical where the list was chronological. --- registries/scifi-kit/src/build.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/registries/scifi-kit/src/build.ts b/registries/scifi-kit/src/build.ts index 946a8cd2..a9872f08 100644 --- a/registries/scifi-kit/src/build.ts +++ b/registries/scifi-kit/src/build.ts @@ -131,6 +131,7 @@ async function buildModelItem(modelId: string): Promise { async function main(): Promise { const entries = await readdir(prototypesRoot, { withFileTypes: true }) const modelIds: string[] = [] + const supportIds: string[] = [] for (const entry of entries) { if (!entry.isDirectory()) continue try { @@ -138,15 +139,19 @@ async function main(): Promise { modelIds.push(entry.name) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + // A prototype directory with no `model.ts` is a shared kit rather than a + // model. Discovering those instead of naming them keeps this file out of + // every batch's diff: a hand-maintained list means each new kit edits the + // same line, so two batches in flight always conflict here even though + // their kits have nothing to do with each other. + supportIds.push(entry.name) } } modelIds.sort() + supportIds.sort() const items = [ await buildCoreItem(), - await buildSupportItem('axiom-modular-kit'), - await buildSupportItem('axiom-cargo-kit'), - await buildSupportItem('axiom-console-kit'), - await buildSupportItem('axiom-window-kit'), + ...await Promise.all(supportIds.map((itemId) => buildSupportItem(itemId))), ] for (const modelId of modelIds) items.push(await buildModelItem(modelId)) items.push({ From c4588204fa1fda1e8fb41373dd595da6a93c5796 Mon Sep 17 00:00:00 2001 From: reposlayer Date: Mon, 17 Aug 2026 01:54:11 +0200 Subject: [PATCH 4/4] Require a support kit to carry sources Absence of model.ts marks a shared kit, but it is equally true of a directory that is simply empty - one left behind by an abandoned model. Registering that published a support item with zero files, which the schema accepts and an install would silently resolve to nothing. Discovery now requires at least one TypeScript source, which is what actually distinguishes a kit from a leftover. Identical to the change on the other batch branches, so they still merge cleanly in any order. --- registries/scifi-kit/src/build.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/registries/scifi-kit/src/build.ts b/registries/scifi-kit/src/build.ts index a9872f08..8a9a9516 100644 --- a/registries/scifi-kit/src/build.ts +++ b/registries/scifi-kit/src/build.ts @@ -144,7 +144,15 @@ async function main(): Promise { // every batch's diff: a hand-maintained list means each new kit edits the // same line, so two batches in flight always conflict here even though // their kits have nothing to do with each other. - supportIds.push(entry.name) + // + // It has to carry sources to count. Absence of `model.ts` is also true of + // a directory that is simply empty — one left behind by an abandoned + // model, say — and registering that published a support item with zero + // files, which the schema accepts and an install would silently resolve + // to nothing. + if ((await collectTypeScriptFiles(join(prototypesRoot, entry.name))).length > 0) { + supportIds.push(entry.name) + } } } modelIds.sort()