diff --git a/.changeset/architecture-doors-wave.md b/.changeset/architecture-doors-wave.md new file mode 100644 index 00000000..94a9441f --- /dev/null +++ b/.changeset/architecture-doors-wave.md @@ -0,0 +1,19 @@ +--- +"@scifi-kit/registry": patch +--- + +Add the Axiom Relay doors group: ten procedural architecture modules covering +the shared portal, the hardened swing leaves, the powered and rolling closures, +a glazed shopfront leaf, a damage variant, and a floor hatch. + +All ten are built on a new `axiom-door-kit` support item. It fixes one clear +opening — 1.06 m by 2.00 m, centred on a 1.6 m by 2.6 m plate — so every leaf in +the group hangs in the same doorway, and carries the shared portal plate, reveal +ring, threshold, jamb strips, head lamp, control plate, hinge stack, and leaf +construction. The kit also adds the lit RED-500 and CYAN-400 lamps the doors +brief asks for, at a lower emissive tier than the cargo wave's, because a door +lights an 850 mm jamb strip where a crate lights a 70 mm lens. + +The hangar door widens to the brief's 2.8 m double envelope and the floor hatch +lays the same language flat on a 1.2 m deck plate; both keep the group's +clipped-corner outline and shell-over-graphite-over-ink value order. diff --git a/assets/prototypes/airlock-door/model.ts b/assets/prototypes/airlock-door/model.ts new file mode 100644 index 00000000..3be71144 --- /dev/null +++ b/assets/prototypes/airlock-door/model.ts @@ -0,0 +1,105 @@ +import { Group } from 'three/webgpu' + +import { box, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + DOOR_KIT, + LEAF_HALF, + PANEL_FRONT, + buildPortal, + createDoorModel, + createDoorPreview, + leafHandle, + leafPanel, + leafRibs, + leafSkin, + signalLamp, + steppedSeam, + visionPort, + type DoorModel, + type DoorPreviewOptions, +} from '../axiom-door-kit/index.ts' + +/** + * Axiom Relay airlock door — the pressure-rated leaf with a vision port. + * + * What separates it from the blast door is not thickness, it is *procedure*: an + * airlock is a door you are meant to look through before you open, and whose + * seal state has to be readable before you touch it. So it gets the family's + * only glazed leaf opening, an inflatable-seal bead standing proud of the skin, + * and a cyan equalisation read next to the amber lock state — two signals, + * because "pressure equal" and "door unlocked" are different facts and a crew + * that conflates them opens onto vacuum. + */ + +export function createModel(): DoorModel { + return createDoorModel({ + id: 'airlock-door', + condition: 0.3, + build: ({ m, bundle, part, root }) => { + const frame = part('frame') + const leaf = part('leaf') + const amber = signalLamp(bundle, 'AMBER-400', 6_140) + + buildPortal(frame, m, { signal: amber }) + + leafSkin(leaf, m) + steppedSeam(leaf, m) + leafHandle(leaf, m) + visionPort(leaf, m, [0.3, 0.36], [-LEAF_HALF[0] * 0.34, DOOR_KIT.centreY + 0.42]) + leafPanel(leaf, m, amber, [LEAF_HALF[0] * 0.66, DOOR_KIT.centreY - 0.02]) + leafRibs(leaf, m) + + // Inflatable seal bead around the leaf edge. Rubber, proud, and continuous + // — the one part that explains why this door is rated and the others + // are not. + for (const sy of [-1, 1]) { + box(leaf, m.rubber, [LEAF_HALF[0] * 1.72, 0.03, 0.04], [ + 0, DOOR_KIT.centreY + sy * (LEAF_HALF[1] - 0.045), PANEL_FRONT - 0.028, + ], { chamfer: 0.01, fillet: 0.005, bevel: 0.004 }) + } + for (const sx of [-1, 1]) { + box(leaf, m.rubber, [0.03, LEAF_HALF[1] * 1.68, 0.04], [ + sx * (LEAF_HALF[0] - 0.045), DOOR_KIT.centreY, PANEL_FRONT - 0.028, + ], { chamfer: 0.01, fillet: 0.005, bevel: 0.004 }) + } + + // Equalisation valve: the physical thing the cyan read is about. + box(leaf, m.graphite, [0.11, 0.11, 0.05], [-LEAF_HALF[0] * 0.34, DOOR_KIT.centreY - 0.5, PANEL_FRONT + 0.018], { + chamfer: 0.03, fillet: 0.01, bevel: 0.008, + }) + box(leaf, m.steel, [0.13, 0.028, 0.028], [-LEAF_HALF[0] * 0.34, DOOR_KIT.centreY - 0.5, PANEL_FRONT + 0.05], { + chamfer: 0.008, fillet: 0.004, bevel: 0.004, + }) + + const swing = new Group() + swing.name = 'AXR_ARCH_AIRLOCK-DOOR_PART_SWING_CLOSED' + swing.position.set(-LEAF_HALF[0], 0, 0) + leaf.position.set(LEAF_HALF[0], 0, 0) + swing.add(leaf) + root.add(swing) + + return { + assemblies: [swing], + cycleSeconds: 2.6, + apply: (blend) => { + swing.rotation.y = blend * 1.7 + }, + sockets: { + fx_seal_bead: [0, DOOR_KIT.centreY + LEAF_HALF[1], PANEL_FRONT], + pipe_equalise: [-LEAF_HALF[0] * 0.34, DOOR_KIT.centreY - 0.5, PANEL_FRONT], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.72 + Math.sin(elapsed * 1.2) * 0.12 + }, + } + }, + }) +} + +export function createPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), options) +} + +export function createOpenPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), { ...options, state: 'open' }) +} diff --git a/assets/prototypes/axiom-door-kit/contract.ts b/assets/prototypes/axiom-door-kit/contract.ts new file mode 100644 index 00000000..9679343c --- /dev/null +++ b/assets/prototypes/axiom-door-kit/contract.ts @@ -0,0 +1,70 @@ +import type { Vec3 } from '../../../src/asset-forge/generator/index.ts' + +/** + * The one set of datums every Axiom Relay door module is built on. + * + * The doors brief gives each leaf the same public envelope — 1.6 m wide, 0.30 m + * deep, 2.6 m tall — and that only buys interchangeability if the *opening* + * inside it is shared too. A hangar door and a laboratory door authored to + * private clear dimensions are two props that happen to be the same size on the + * outside; authored to these numbers they are the same doorway wearing two + * leaves, which is what a kit is. + * + * The opening is centred on the plate on purpose. A head band deeper than the + * sill band is how real doors are built, but it also means the frame can no + * longer be cut as one symmetric plate with one centred octagonal hole, and the + * reference sheets show a symmetric plate. Symmetry wins: the sill band carries + * a threshold plate on top of it, which restores the asymmetry where it reads. + */ +export const DOOR_KIT = Object.freeze({ + /** Public envelope from the doors brief. */ + width: 1.6, + height: 2.6, + depth: 0.3, + /** Clear structural opening, centred on the plate. */ + clearWidth: 1.06, + clearHeight: 2, + /** Opening centre height; also the plate's own centre. */ + centreY: 1.3, + /** Corner clip on the opening, and the larger one on the outer edge. */ + clip: 0.2, + outerClip: 0.26, + /** Depth of the shell frame plate; the graphite reveal runs deeper behind it. */ + platePitch: 0.24, + /** Face the operational side points at, per the brief's orientation rule. */ + front: '+Z', + /** Ground contact, centred in width, on the wall plane. */ + pivot: 'ground-centre', + /** Double-leaf variants widen to this. */ + doubleWidth: 2.8, +} as const) + +export interface DoorEnvelope { + readonly width: number + readonly depth: number + readonly height: number +} + +export interface DoorMetadata { + readonly version: 1 + readonly moduleId: string + readonly pivot: typeof DOOR_KIT.pivot + readonly front: typeof DOOR_KIT.front + readonly envelope: DoorEnvelope + readonly clear: { readonly width: number; readonly height: number } +} + +/** Half-extents of the clear opening, the figure most builders actually want. */ +export const CLEAR_HALF: readonly [number, number] = [ + DOOR_KIT.clearWidth * 0.5, + DOOR_KIT.clearHeight * 0.5, +] + +/** Socket positions shared by every module in the family. */ +export const DOOR_SOCKETS: Readonly> = Object.freeze({ + door_threshold: [0, 0.06, 0], + door_head: [0, DOOR_KIT.centreY + CLEAR_HALF[1], 0], + mount_left: [-DOOR_KIT.width * 0.5, DOOR_KIT.centreY, 0], + mount_right: [DOOR_KIT.width * 0.5, DOOR_KIT.centreY, 0], + power_head: [DOOR_KIT.width * 0.5 - 0.14, DOOR_KIT.height - 0.18, -DOOR_KIT.depth * 0.4], +}) diff --git a/assets/prototypes/axiom-door-kit/frame.ts b/assets/prototypes/axiom-door-kit/frame.ts new file mode 100644 index 00000000..77bec554 --- /dev/null +++ b/assets/prototypes/axiom-door-kit/frame.ts @@ -0,0 +1,280 @@ +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 { CLEAR_HALF, DOOR_KIT } from './contract.ts' + +/** + * The shared portal: shell plate, graphite reveal ring, threshold, and the jamb + * hardware every door in the family hangs off. + * + * Built as nested octagonal rings rather than four mitred members. Four members + * give a rectangular hole, and the whole family's reference language is an + * octagonal opening with clipped corners inside an outer plate carrying the + * *same* cut at a larger radius. One prism with a centred octagonal hole says + * that in a single part; four members plus corner blocks say it in eleven, and + * the eleven never quite meet. + */ + +export interface PortalOptions { + /** Signal colour for the jamb strips and head lamp. */ + readonly signal?: MeshPhysicalMaterial + /** Omit the hinge stack for sliding, rolling, and hatch modules. */ + readonly hinges?: boolean + /** Omit the threshold where the module is not floor-mounted. */ + readonly threshold?: boolean +} + +/** Frame plate front face; leaves are recessed behind this. */ +export const PLATE_FRONT = DOOR_KIT.platePitch * 0.5 + +/** + * Hinge knuckle centre. Outside the clear opening so a swinging leaf clears its + * own jamb, and far enough forward that the barrel is not buried in the reveal. + */ +export const HINGE_X = -(CLEAR_HALF[0] - 0.105) +export const HINGE_Z = PLATE_FRONT - 0.155 + +/** + * Every lit part in this family is mounted the same way: a dark housing standing + * proud of the shell plate, and the lamp standing proud of *that*. + * + * The first pass placed both inside the plate's own thickness. Geometrically the + * lamp was still in front of its housing, but both were buried in solid shell, + * so a jamb strip rendered as a black slot and the head lamp as a dark blob — + * the model had no lit surface anywhere except the one panel that happened to be + * proud. Emissive material is not a light source here; it has to be visible. + */ +const HOUSING_Z = PLATE_FRONT + 0.014 +const LAMP_Z = PLATE_FRONT + 0.036 + +export function portalPlate(parent: Group, m: CargoMaterials): void { + // Outer shell plate with the family's two nested octagonal cuts. + parent.add(prism(m.shell, [DOOR_KIT.width, DOOR_KIT.height, DOOR_KIT.platePitch], [0, DOOR_KIT.centreY, 0], { + chamfer: DOOR_KIT.outerClip, + fillet: 0.02, + bevel: 0.018, + capChamfer: [0.05, 0.03], + holes: [slot(CLEAR_HALF[0] + 0.055, CLEAR_HALF[1] + 0.055, DOOR_KIT.clip + 0.02)], + })) + + // Raised outer border. The reference plates all carry a stepped picture-frame + // edge; without it the plate is one flat value across 1.6 m and the module + // reads as a printed panel rather than a fabrication. + parent.add(extrudeProfile(m.shellLight, slot(DOOR_KIT.width * 0.5, DOOR_KIT.height * 0.5, DOOR_KIT.outerClip), 0.035, [ + 0, DOOR_KIT.centreY, PLATE_FRONT + 0.016, + ], { + fillet: 0.012, + bevel: 0.016, + holes: [slot(DOOR_KIT.width * 0.5 - 0.13, DOOR_KIT.height * 0.5 - 0.13, DOOR_KIT.outerClip - 0.05)], + })) + + // The reveal ring: a chunky dark octagon standing proud of the plate and + // running back through it. This is the family's strongest value break, and it + // is a ring 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(CLEAR_HALF[0] + 0.058, CLEAR_HALF[1] + 0.058, DOOR_KIT.clip + 0.02), 0.2, [ + 0, DOOR_KIT.centreY, PLATE_FRONT - 0.078, + ], { + fillet: 0.01, + bevel: 0.014, + holes: [slot(CLEAR_HALF[0], CLEAR_HALF[1], DOOR_KIT.clip)], + })) + + // Deep liner behind the ring, so the opening bottoms out in black instead of + // showing whatever is behind the module. + parent.add(extrudeProfile(m.ink, slot(CLEAR_HALF[0] + 0.03, CLEAR_HALF[1] + 0.03, DOOR_KIT.clip), 0.12, [ + 0, DOOR_KIT.centreY, PLATE_FRONT - 0.238, + ], { + fillet: 0.008, + bevel: 0.008, + holes: [slot(CLEAR_HALF[0] - 0.012, CLEAR_HALF[1] - 0.012, DOOR_KIT.clip - 0.01)], + })) + + // Panel breaks across head and sill, stopping short of the outer clip. + for (const sy of [-1, 1]) { + box(parent, m.shellShade, [DOOR_KIT.width - DOOR_KIT.outerClip * 2.4, 0.03, 0.014], [ + 0, + DOOR_KIT.centreY + sy * (DOOR_KIT.height * 0.5 - 0.075), + PLATE_FRONT + 0.03, + ], { chamfer: 0.01, fillet: 0.004, bevel: 0.004 }) + } + + // Corner fixings on the diagonal returns the outer clip creates. + for (const sx of [-1, 1]) { + for (const sy of [-1, 1]) { + boltRun( + parent, + m.steel, + [sx * (DOOR_KIT.width * 0.5 - 0.062), DOOR_KIT.centreY + sy * (DOOR_KIT.height * 0.5 - 0.3), PLATE_FRONT + 0.032], + [sx * (DOOR_KIT.width * 0.5 - 0.062), DOOR_KIT.centreY + sy * (DOOR_KIT.height * 0.5 - 0.62), PLATE_FRONT + 0.032], + 3, + 0.014, + 'front', + ) + } + } +} + +/** Recessed jamb light strips. The family's only large emissive area. */ +export function jambStrips(parent: Group, m: CargoMaterials, signal: MeshPhysicalMaterial): void { + for (const sx of [-1, 1]) { + const x = sx * (DOOR_KIT.width * 0.5 - 0.066) + box(parent, m.ink, [0.062, 0.92, 0.05], [x, DOOR_KIT.centreY - 0.24, HOUSING_Z], { + chamfer: 0.022, fillet: 0.008, bevel: 0.006, + }) + box(parent, signal, [0.03, 0.85, 0.032], [x, DOOR_KIT.centreY - 0.24, LAMP_Z], { + chamfer: 0.011, fillet: 0.004, bevel: 0.004, + }) + } +} + +/** + * Threshold plate and the shallow ramp that carries it down to the deck. + * + * The ramp length is solved from the drop it has to bridge rather than chosen, + * so a changed threshold height cannot leave it hanging in air. + */ +export function threshold(parent: Group, m: CargoMaterials): void { + const rise = 0.07 + const span = DOOR_KIT.width - 0.12 + const reach = DOOR_KIT.depth + 0.06 + + // One ribbed plate with a chamfered leading edge, rather than a plate plus a + // separate raked ramp. The first pass built the ramp as its own oxidised + // wedge: at 1.38 m wide and rust-coloured it read as a loose board dropped in + // front of the module, and it was the brightest warm mass in a frame whose + // only warm accent is meant to be the signal. + box(parent, m.graphiteEdge, [span, rise, reach], [0, rise * 0.5, 0.01], { + chamfer: 0.018, + fillet: 0.008, + bevel: 0.007, + capChamfer: [rise * 0.75, 0.02], + }) + // Tread ribs across the walking surface. Repetition rather than texture, so + // they collect the same cavity occlusion as everything else in the bake. + for (let index = 0; index < 9; index += 1) { + const t = (index - 4) / 8 + box(parent, m.ink, [0.026, 0.012, reach - 0.05], [t * (span - 0.18), rise + 0.003, 0.01], { + chamfer: 0.005, fillet: 0.002, bevel: 0.002, + }) + } + // The sill's own warning line, inlaid at the opening's front edge. + box(parent, m.amberPaint, [span - 0.08, 0.012, 0.03], [0, rise + 0.003, reach * 0.5 - 0.05], { + chamfer: 0.005, fillet: 0.002, bevel: 0.002, + }) +} + +/** + * Hinge stack: two knuckle groups on a common pin, standing in the opening on + * the leaf's left side. + * + * It stands *in* the opening rather than flat on the jamb wall. Set against the + * wall it disappeared entirely at the family's capture angle — the near jamb + * occludes its own inner face — and a swing door whose hinges are never visible + * has no reason to open in the direction it opens. + */ +export function hingeStack(parent: Group, m: CargoMaterials): void { + const heights = [DOOR_KIT.centreY - 0.66, DOOR_KIT.centreY + 0.66] + parent.add(cylinder(m.steel, 0.026, 1.72, [HINGE_X, DOOR_KIT.centreY, HINGE_Z], AXIS_Y, 12)) + for (const y of heights) { + for (const offset of [-0.1, 0, 0.1]) { + parent.add(cylinder(m.graphiteEdge, 0.058, 0.085, [HINGE_X, y + offset, HINGE_Z], AXIS_Y, 12)) + } + // Strap back to the jamb, so the pin is carried by something. + box(parent, m.graphite, [0.14, 0.26, 0.07], [HINGE_X - 0.075, y, HINGE_Z - 0.03], { + chamfer: 0.024, fillet: 0.009, bevel: 0.008, + }) + boltRun(parent, m.steel, [HINGE_X - 0.12, y - 0.08, HINGE_Z + 0.038], [HINGE_X - 0.12, y + 0.08, HINGE_Z + 0.038], 2, 0.013, 'front') + } +} + +/** + * Head lamp bar: a short lit segment inside a housing above the opening. The + * family's state read at far distance, where a jamb strip is a single pixel. + */ +export function headLamp(parent: Group, m: CargoMaterials, signal: MeshPhysicalMaterial): void { + const y = DOOR_KIT.centreY + CLEAR_HALF[1] + 0.155 + box(parent, m.graphite, [0.44, 0.075, 0.05], [0, y, HOUSING_Z], { + chamfer: 0.022, fillet: 0.008, bevel: 0.006, + }) + for (let index = 0; index < 4; index += 1) { + statusLens(parent, m, [0.048, 0.03], [(index - 1.5) * 0.082, y, LAMP_Z], signal, 'front') + } +} + +/** + * The wall-side control plate: a raised bezel carrying one large state lens and + * two smaller reads. Sits on the jamb opposite the hinges. + */ +export function controlPlate( + parent: Group, + m: CargoMaterials, + signal: MeshPhysicalMaterial, + height = DOOR_KIT.centreY + 0.18, +): void { + const x = DOOR_KIT.width * 0.5 - 0.185 + box(parent, m.graphite, [0.15, 0.4, 0.055], [x, height, HOUSING_Z + 0.008], { + chamfer: 0.045, fillet: 0.012, bevel: 0.01, capChamfer: 0.02, + }) + statusLens(parent, m, [0.075, 0.075], [x, height + 0.085, LAMP_Z + 0.008], signal, 'front') + statusLens(parent, m, [0.065, 0.024], [x, height - 0.045, LAMP_Z + 0.008], m.cyan, 'front') + box(parent, m.steel, [0.062, 0.026, 0.02], [x, height - 0.12, LAMP_Z + 0.012], { + chamfer: 0.007, fillet: 0.003, bevel: 0.003, + }) +} + +/** Skirt blocks under each jamb, with a painted direction mark. */ +export function jambFeet(parent: Group, m: CargoMaterials): void { + for (const sx of [-1, 1]) { + const x = sx * (DOOR_KIT.width * 0.5 - 0.135) + box(parent, m.graphite, [0.27, 0.26, DOOR_KIT.depth + 0.02], [x, 0.13, 0], { + chamfer: 0.036, fillet: 0.012, bevel: 0.01, + }) + parent.add(extrudeProfile(m.amberPaint, [[-0.042, 0.036], [0.042, 0.036], [0, -0.042]], 0.012, [ + x, 0.15, PLATE_FRONT + 0.05, + ], { fillet: 0.004, bevel: 0.004 })) + boltRun(parent, m.steel, [x - 0.085, 0.04, PLATE_FRONT + 0.05], [x + 0.085, 0.04, PLATE_FRONT + 0.05], 2, 0.013, 'front') + } +} + +/** + * Rear bracing so the module still reads as built from behind. + * + * Kept off the opening's sightline. A cross-brace on the module's centreline is + * correct engineering and a visible bar straight across an empty doorway, which + * is the one thing a door frame must never have. + */ +export function rearBracing(parent: Group, m: CargoMaterials): void { + const back = -DOOR_KIT.depth * 0.5 + 0.035 + for (const sx of [-1, 1]) { + box(parent, m.graphiteEdge, [0.085, DOOR_KIT.height - 0.42, 0.07], [ + sx * (DOOR_KIT.width * 0.5 - 0.08), DOOR_KIT.centreY, back, + ], { chamfer: 0.02, fillet: 0.008, bevel: 0.006 }) + } + for (const sy of [-1, 1]) { + box(parent, m.graphiteEdge, [DOOR_KIT.width - 0.2, 0.075, 0.07], [ + 0, DOOR_KIT.centreY + sy * (DOOR_KIT.height * 0.5 - 0.12), back, + ], { chamfer: 0.02, fillet: 0.008, bevel: 0.006 }) + } +} + +/** Everything a portal shares, in the order the brief's construction plan lists. */ +export function buildPortal(parent: Group, m: CargoMaterials, options: PortalOptions = {}): void { + const signal = options.signal ?? m.amber + portalPlate(parent, m) + if (options.threshold ?? true) threshold(parent, m) + jambFeet(parent, m) + rearBracing(parent, m) + jambStrips(parent, m, signal) + headLamp(parent, m, signal) + controlPlate(parent, m, signal) + if (options.hinges ?? true) hingeStack(parent, m) +} diff --git a/assets/prototypes/axiom-door-kit/index.ts b/assets/prototypes/axiom-door-kit/index.ts new file mode 100644 index 00000000..2b374622 --- /dev/null +++ b/assets/prototypes/axiom-door-kit/index.ts @@ -0,0 +1,240 @@ +import { Group, Object3D } from 'three/webgpu' + +import { + acquireCargoMaterials, + createCargoPreview, + finishModel, + socket, + type CargoMaterialBundle, + type CargoMaterials, + type CargoPreview, + type CargoPreviewOptions, +} from '../axiom-cargo-kit/index.ts' +import { CLEAR_HALF, DOOR_KIT, DOOR_SOCKETS, type DoorEnvelope, type DoorMetadata } from './contract.ts' + +export { CLEAR_HALF, DOOR_KIT, DOOR_SOCKETS } from './contract.ts' +export type { DoorEnvelope, DoorMetadata } from './contract.ts' +export { + HINGE_X, + HINGE_Z, + PLATE_FRONT, + buildPortal, + controlPlate, + headLamp, + hingeStack, + jambFeet, + jambStrips, + portalPlate, + rearBracing, + threshold, +} from './frame.ts' +export type { PortalOptions } from './frame.ts' +export { signalLamp } from './signal.ts' +export type { SignalToken } from './signal.ts' +export { + LEAF_DEPTH, + LEAF_HALF, + LEAF_Z, + PANEL_FRONT, + leafHandle, + leafOutline, + leafPanel, + leafRecess, + leafRibs, + leafSkin, + steppedSeam, + visionPort, +} from './leaf.ts' +export type { LeafOptions } from './leaf.ts' + +/** + * The scaffold every door module in the batch is assembled through. + * + * Ten doors that each acquire their own materials, name their own nodes, and + * pick their own occlusion reach are ten props that merely share a folder. The + * scaffold fixes all three: one material bundle at one condition, the wave's + * `AXR_ARCH__...` naming, and a reach scaled to the door envelope rather + * than to whatever each model's author guessed. + */ + +export type DoorState = 'closed' | 'open' + +export interface DoorModel { + readonly root: Group + readonly parts: Record + readonly sockets: Record + readonly state: DoorState + setState(state: DoorState): DoorState + update(deltaSeconds: number): void + dispose(): void +} + +export interface DoorBuild { + /** Registry model id, e.g. `blast-door`. */ + readonly id: string + /** Wear condition, 0 factory-fresh to 1 depot-veteran. */ + readonly condition?: number + /** Public envelope, where a module widens past the shared one. */ + readonly envelope?: DoorEnvelope + /** + * Builds the module. Returns the articulated groups, which are batched + * separately so they survive the merge with their own transforms. + */ + 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 a handful of meshes directly under it, which + * destroys any intermediate group — so an assembly whose parent is inside + * another assembly is re-attached to a group that no longer exists in the + * scene, and it vanishes without an error. A wheel parented to a swinging + * leaf disappears; the same wheel parented to the swing itself survives. + */ + readonly assemblies?: readonly Group[] + /** Applies an open fraction, 0 closed to 1 open. */ + readonly apply?: (blend: number) => void + /** Extra sockets beyond the family's shared set. */ + readonly sockets?: Readonly> + /** Per-frame work, for pulsing lamps and moving parts. */ + readonly tick?: (elapsed: number) => void + /** Seconds for a full open or close cycle. */ + readonly cycleSeconds?: number + } +} + +function annotate(root: Group, id: string, envelope: DoorEnvelope): void { + root.userData.doorKit = { + version: 1, + moduleId: id, + pivot: DOOR_KIT.pivot, + front: DOOR_KIT.front, + envelope, + clear: { width: DOOR_KIT.clearWidth, height: DOOR_KIT.clearHeight }, + } satisfies DoorMetadata +} + +export function createDoorModel(spec: DoorBuild): DoorModel { + const bundle = acquireCargoMaterials(11_400 + spec.id.length * 137, { condition: spec.condition ?? 0.42 }) + const m = bundle.materials + const envelope = spec.envelope ?? { width: DOOR_KIT.width, depth: DOOR_KIT.depth, height: DOOR_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 }) + annotate(root, spec.id, envelope) + + const sockets: Record = {} + for (const [name, position] of Object.entries({ ...DOOR_SOCKETS, ...(built.sockets ?? {}) })) { + sockets[name] = socket(name, [...position] as [number, number, number]) + } + + const finished = finishModel(root, bundle, { + name: spec.id, + assemblies: built.assemblies, + // Scaled to the opening rather than to the plate: the deepest cavity in the + // family is the reveal, and its depth is what the bake has to reach across. + reach: 0.26, + sockets: Object.values(sockets), + }) + + let state: DoorState = 'closed' + let blend = 0 + let elapsed = 0 + const cycle = built.cycleSeconds ?? 1.4 + + /** + * Carries the state token on the articulated groups as well as on the root. + * + * The wave's node names end in their state, and 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 door full of + * parts still claiming to be `_CLOSED`, which is worse than not naming them at + * all, because a consumer reading part names has no way to know they are stale. + * + * Only a trailing `_CLOSED` or `_OPEN` is rewritten. The damaged door's leaf is + * `_JAMMED` and stays that way: it is not a state this controller drives, and + * overwriting it would claim the door closes. + */ + const retagAssemblies = (next: DoorState): 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: DoorState) => { + 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 DoorPreviewOptions extends CargoPreviewOptions { + readonly state?: DoorState +} + +/** + * The family's capture framing. Every door is the same size and is read from + * the same three-quarter angle, so the framing is shared rather than tuned per + * model — a catalogue page, not fifty product shots. + */ +export function createDoorPreview(model: DoorModel, options: DoorPreviewOptions = {}): CargoPreview { + model.setState(options.state ?? 'closed') + return createCargoPreview(model, { + target: [0, DOOR_KIT.centreY - 0.08, 0], + distance: 5.1, + yaw: -0.62, + pitch: 0.16, + fov: 32, + ...options, + }) +} + +/** Height of the clear opening's head, the figure hardware is hung from. */ +export const HEAD_Y = DOOR_KIT.centreY + CLEAR_HALF[1] diff --git a/assets/prototypes/axiom-door-kit/leaf.ts b/assets/prototypes/axiom-door-kit/leaf.ts new file mode 100644 index 00000000..a0b0ca3d --- /dev/null +++ b/assets/prototypes/axiom-door-kit/leaf.ts @@ -0,0 +1,187 @@ +import { Group, type MeshPhysicalMaterial } from 'three/webgpu' + +import { cylinder, extrudeProfile, type Vec2 } from '../../../src/asset-forge/generator/index.ts' +import { + AXIS_Y, + boltRun, + box, + recessedHandle, + slot, + statusLens, + type CargoMaterials, +} from '../axiom-cargo-kit/index.ts' +import { CLEAR_HALF, DOOR_KIT } from './contract.ts' +import { PLATE_FRONT } from './frame.ts' + +/** + * Leaf construction shared by every swinging and sliding module in the family. + * + * A leaf is three things stacked: a skin cut to the opening's octagon, one + * raised inner panel that gives the face a value break, and a stepped seam that + * says which half of the leaf is structural. Everything else a specific door + * needs — a vision port, a shutter's slats, a glass light — is added on top of + * that base by the model itself. + */ + +/** Leaf skin outline: 20 mm inside the clear opening on every side. */ +export const LEAF_HALF: readonly [number, number] = [CLEAR_HALF[0] - 0.02, CLEAR_HALF[1] - 0.02] +export const LEAF_DEPTH = 0.09 +/** Leaf mid-plane. Recessed behind the frame plate so the opening reads deep. */ +export const LEAF_Z = PLATE_FRONT - 0.145 +/** Front face of the raised panel — the datum leaf-mounted detail sits on. */ +export const PANEL_FRONT = LEAF_Z + LEAF_DEPTH * 0.5 + 0.027 + +export interface LeafOptions { + /** Skin material; glass and shutter variants swap it. */ + readonly skin?: MeshPhysicalMaterial + /** Half-extents, for double leaves that carry half the opening each. */ + readonly half?: readonly [number, number] + /** Horizontal centre, for a leaf that is not centred in the opening. */ + readonly centreX?: number + /** Corner clip; a narrow double leaf needs a smaller one than a single. */ + readonly clip?: number +} + +export function leafOutline(options: LeafOptions = {}): Vec2[] { + const [hx, hy] = options.half ?? LEAF_HALF + return slot(hx, hy, options.clip ?? DOOR_KIT.clip - 0.03) +} + +/** The skin, its raised inner panel, and the fixings that hold the two together. */ +export function leafSkin(parent: Group, m: CargoMaterials, options: LeafOptions = {}): void { + const [hx, hy] = options.half ?? LEAF_HALF + const cx = options.centreX ?? 0 + const skin = options.skin ?? m.shellLight + const clip = options.clip ?? DOOR_KIT.clip - 0.03 + + parent.add(extrudeProfile(skin, slot(hx, hy, clip), LEAF_DEPTH, [cx, DOOR_KIT.centreY, LEAF_Z], { + fillet: 0.014, + bevel: 0.02, + capChamfer: [0.03, 0.02], + })) + // Raised panel. Inset by a constant rather than a ratio so a narrow double + // leaf keeps the same physical border as a single one. + parent.add(extrudeProfile(m.shell, slot(hx - 0.09, hy - 0.09, clip - 0.02), 0.03, [ + cx, DOOR_KIT.centreY, LEAF_Z + LEAF_DEPTH * 0.5 + 0.012, + ], { fillet: 0.01, bevel: 0.014 })) + for (const sy of [-1, 1]) { + boltRun( + parent, + m.steel, + [cx - hx + 0.14, DOOR_KIT.centreY + sy * (hy - 0.075), LEAF_Z + LEAF_DEPTH * 0.5], + [cx + hx - 0.14, DOOR_KIT.centreY + sy * (hy - 0.075), LEAF_Z + LEAF_DEPTH * 0.5], + 4, + 0.015, + 'front', + ) + } +} + +/** + * The family's stepped structural seam: a shadow groove that runs up the leaf, + * jogs across, and continues. It is the one graphic every reference sheet in the + * doors group shares, and it is geometry rather than a decal so it holds an edge + * highlight on the step. + */ +export function steppedSeam(parent: Group, m: CargoMaterials, centreX = 0, half = LEAF_HALF): void { + const [hx, hy] = half + // Proud of the raised panel, not of the skin. Drawn against the skin the seam + // sat 15 mm inside the panel that covers most of the leaf and vanished + // everywhere except the two thin borders the panel does not reach. + const z = PANEL_FRONT - 0.004 + const gauge = 0.032 + const bar = (size: [number, number], position: [number, number]): void => { + box(parent, m.ink, [size[0], size[1], 0.03], [position[0], position[1], z], { + chamfer: 0.008, fillet: 0.004, bevel: 0.004, + }) + } + + // One continuous run: down the leaf, across, and down again. The first pass + // drew four disconnected bars around a shared centre, which read as a bracket + // rather than as a joint — a seam has to arrive somewhere and leave somewhere. + const upperX = centreX - hx * 0.14 + const lowerX = centreX + hx * 0.16 + const jogY = DOOR_KIT.centreY + hy * 0.19 + const top = DOOR_KIT.centreY + hy + const bottom = DOOR_KIT.centreY - hy + + bar([gauge, top - jogY], [upperX, (top + jogY) * 0.5]) + bar([lowerX - upperX + gauge, gauge], [(upperX + lowerX) * 0.5, jogY]) + bar([gauge, jogY - bottom], [lowerX, (jogY + bottom) * 0.5]) +} + +/** Vertical pull bar on the leaf's swing edge. */ +export function leafHandle(parent: Group, m: CargoMaterials, centreX = 0, half = LEAF_HALF): void { + const x = centreX + half[0] * 0.46 + box(parent, m.graphiteEdge, [0.058, 0.32, 0.05], [x, DOOR_KIT.centreY, PANEL_FRONT + 0.045], { + chamfer: 0.022, fillet: 0.009, bevel: 0.008, + }) + // Stand-offs, so the bar has somewhere to be bolted and a hand has somewhere + // to go behind it. + for (const sy of [-1, 1]) { + box(parent, m.graphite, [0.048, 0.048, 0.045], [x, DOOR_KIT.centreY + sy * 0.135, PANEL_FRONT + 0.018], { + chamfer: 0.013, fillet: 0.006, bevel: 0.005, + }) + } +} + +/** + * A vision port: a real opening through the skin with a glazed pane behind it, + * not a dark quad. The port is cut from the raised panel too, so its walls run + * the full leaf thickness. + */ +export function visionPort( + parent: Group, + m: CargoMaterials, + size: readonly [number, number], + centre: readonly [number, number], +): void { + const [w, h] = size + const [cx, cy] = centre + parent.add(extrudeProfile(m.graphite, slot(w * 0.5 + 0.05, h * 0.5 + 0.05, 0.05), 0.055, [ + cx, cy, PANEL_FRONT, + ], { + fillet: 0.008, + bevel: 0.01, + holes: [slot(w * 0.5, h * 0.5, 0.04)], + })) + parent.add(extrudeProfile(m.glass, slot(w * 0.5 + 0.012, h * 0.5 + 0.012, 0.04), 0.014, [ + cx, cy, LEAF_Z + 0.01, + ], { fillet: 0.005, bevel: 0.005 })) +} + +/** Leaf-mounted state panel, for the modules that carry their read on the door. */ +export function leafPanel( + parent: Group, + m: CargoMaterials, + signal: MeshPhysicalMaterial, + centre: readonly [number, number], +): void { + const [cx, cy] = centre + box(parent, m.graphite, [0.19, 0.31, 0.05], [cx, cy, PANEL_FRONT + 0.02], { + chamfer: 0.045, fillet: 0.012, bevel: 0.01, capChamfer: 0.018, + }) + statusLens(parent, m, [0.1, 0.1], [cx, cy + 0.062, PANEL_FRONT + 0.045], signal, 'front') + statusLens(parent, m, [0.11, 0.028], [cx, cy - 0.035, PANEL_FRONT + 0.045], signal, 'front') + statusLens(parent, m, [0.034, 0.034], [cx, cy - 0.108, PANEL_FRONT + 0.045], m.amber, 'front') +} + +/** Rear stiffening ribs, so an opened leaf is not a flat card from behind. */ +export function leafRibs(parent: Group, m: CargoMaterials, centreX = 0, half = LEAF_HALF): void { + const [hx, hy] = half + const z = LEAF_Z - LEAF_DEPTH * 0.5 - 0.018 + for (let index = 0; index < 3; index += 1) { + const y = DOOR_KIT.centreY + (index - 1) * hy * 0.62 + box(parent, m.graphiteEdge, [hx * 1.7, 0.055, 0.035], [centreX, y, z], { + chamfer: 0.014, fillet: 0.006, bevel: 0.005, + }) + } + for (const sy of [-1, 1]) { + parent.add(cylinder(m.steel, 0.02, 0.06, [centreX + hx * 0.72, DOOR_KIT.centreY + sy * hy * 0.55, z], AXIS_Y, 8)) + } +} + +/** A grab recess for hatches and shutters, which have no room for a pull bar. */ +export function leafRecess(parent: Group, m: CargoMaterials, centre: readonly [number, number]): void { + recessedHandle(parent, m, [0.24, 0.11], [centre[0], centre[1], PANEL_FRONT - 0.006], 'front') +} diff --git a/assets/prototypes/axiom-door-kit/signal.ts b/assets/prototypes/axiom-door-kit/signal.ts new file mode 100644 index 00000000..3cabee0c --- /dev/null +++ b/assets/prototypes/axiom-door-kit/signal.ts @@ -0,0 +1,56 @@ +import type { MeshPhysicalMaterial } from 'three/webgpu' + +import { MaterialLibrary, tuneMaterial } from '../../../src/asset-forge/generator/index.ts' +import { TOKEN, shade, type CargoMaterialBundle } from '../axiom-cargo-kit/index.ts' + +/** + * Lit signal lamps in the tokens the cargo wave never needed. + * + * The cargo, storage, and logistics wave lights exactly two colours, amber and + * cyan, because a crate's only states are "caution" and "has data". The doors + * brief asks four of its ten modules to carry RED-500 as the *dominant* signal, + * and a blast door whose danger state is painted rather than lit reads as a + * white door with a red sticker. + * + * These are built here rather than added to `CargoMaterials` on purpose. Adding + * a member to the shared bundle changes the source hash of all 166 models + * already in the registry, which is a release-wide event; a door-kit lamp is a + * door-kit concern. The handle is pushed onto the caller's bundle so the + * existing dispose path releases it — a lamp that leaks its library handle + * survives every model that lit it. + */ + +const library = new MaterialLibrary() + +export type SignalToken = 'RED-500' | 'ORANGE-500' | 'LIME-400' | 'FIELD-500' | 'AMBER-400' | 'CYAN-400' + +const TOKEN_VALUE: Record = { + 'RED-500': TOKEN.RED_500, + 'ORANGE-500': TOKEN.ORANGE_500, + 'LIME-400': TOKEN.LIME_400, + 'FIELD-500': TOKEN.FIELD_500, + 'AMBER-400': TOKEN.AMBER_400, + 'CYAN-400': TOKEN.CYAN_400, +} + +/** + * The doors' lamp tier, one step below the cargo wave's. + * + * A crate lights a 70 mm lens; a door lights an 850 mm jamb strip. At the cargo + * wave's emissive 2.2 that strip clips to cream and the module loses the one + * saturated colour the brief asked it to carry — the state read turns white, + * which is the absence of a state. The lit *area* is what differs between the + * waves, so the intensity has to differ with it. + * + * Small lenses still take the full tier; pass `emissive` explicitly for those. + */ +export function signalLamp( + bundle: CargoMaterialBundle, + token: SignalToken, + seed = 4_100, + 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 }) +} diff --git a/assets/prototypes/blast-door/model.ts b/assets/prototypes/blast-door/model.ts new file mode 100644 index 00000000..066e3288 --- /dev/null +++ b/assets/prototypes/blast-door/model.ts @@ -0,0 +1,98 @@ +import { Group } from 'three/webgpu' + +import { box, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + DOOR_KIT, + LEAF_HALF, + PANEL_FRONT, + buildPortal, + createDoorModel, + createDoorPreview, + leafHandle, + leafPanel, + leafRibs, + leafSkin, + signalLamp, + steppedSeam, + type DoorModel, + type DoorPreviewOptions, +} from '../axiom-door-kit/index.ts' + +/** + * Axiom Relay blast door — the family's hardened single leaf. + * + * The blast door is the reason the group's leaves are octagonal at all: a + * pressure leaf wants its load carried into the frame at 45 degrees rather than + * into four right-angle corners, and every lighter door in the kit then inherits + * that outline so they can share one opening. + * + * Its signal is RED-500 per the brief, and it is the only door in the batch that + * carries the dominant signal on the *leaf* rather than the jamb: a sealed blast + * door's state has to be legible from inside the corridor it seals, where the + * jamb strips are edge-on. + */ + +export function createModel(): DoorModel { + return createDoorModel({ + id: 'blast-door', + condition: 0.46, + build: ({ m, bundle, part, root }) => { + const frame = part('frame') + const leaf = part('leaf') + const red = signalLamp(bundle, 'RED-500', 5_180) + + buildPortal(frame, m, { signal: red }) + + leafSkin(leaf, m) + steppedSeam(leaf, m) + leafHandle(leaf, m) + leafPanel(leaf, m, red, [LEAF_HALF[0] * 0.66, DOOR_KIT.centreY - 0.02]) + leafRibs(leaf, m) + + // Dog bolts: the hardware that makes it a blast door rather than a thick + // door. Four per side, standing proud of the leaf's swing edge where they + // drive into the frame. + for (let index = 0; index < 4; index += 1) { + const y = DOOR_KIT.centreY + (index - 1.5) * 0.44 + for (const sx of [-1, 1]) { + box(leaf, m.steel, [0.075, 0.09, 0.055], [sx * (LEAF_HALF[0] - 0.045), y, PANEL_FRONT - 0.012], { + chamfer: 0.018, fillet: 0.007, bevel: 0.006, + }) + } + } + + // The leaf swings on the frame's hinge column. Its pivot is the hinge + // line, not the leaf centre, so the group is offset and counter-offset + // rather than rotated about the middle of the door. + const swing = new Group() + swing.name = 'AXR_ARCH_BLAST-DOOR_PART_SWING_CLOSED' + swing.position.set(-LEAF_HALF[0], 0, 0) + leaf.position.set(LEAF_HALF[0], 0, 0) + swing.add(leaf) + root.add(swing) + + return { + assemblies: [swing], + cycleSeconds: 2.1, + apply: (blend) => { + swing.rotation.y = blend * 1.85 + }, + sockets: { + door_leaf_edge: [LEAF_HALF[0], DOOR_KIT.centreY, PANEL_FRONT], + fx_seal_head: [0, DOOR_KIT.centreY + LEAF_HALF[1], PANEL_FRONT], + }, + tick: (elapsed) => { + red.emissiveIntensity = 0.72 + Math.sin(elapsed * 2.3) * 0.14 + }, + } + }, + }) +} + +export function createPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), options) +} + +export function createOpenPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), { ...options, state: 'open' }) +} diff --git a/assets/prototypes/bunker-door/model.ts b/assets/prototypes/bunker-door/model.ts new file mode 100644 index 00000000..6ab346f8 --- /dev/null +++ b/assets/prototypes/bunker-door/model.ts @@ -0,0 +1,135 @@ +import { Group } from 'three/webgpu' + +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_Z, box, boltRun, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + DOOR_KIT, + LEAF_HALF, + PANEL_FRONT, + buildPortal, + createDoorModel, + createDoorPreview, + leafRecess, + leafRibs, + leafSkin, + signalLamp, + type DoorModel, + type DoorPreviewOptions, +} from '../axiom-door-kit/index.ts' + +/** + * Axiom Relay bunker door — the family's manual, unpowered leaf. + * + * Every other door in the group opens because something drives it. This one + * opens because someone turns the wheel, and that single decision cascades: no + * pull bar (a wheel is the handle), no vision port (a bunker door that can be + * seen through is a window), a grab recess rather than a proud handle so + * nothing catches when the leaf is dogged, and armour rings instead of a raised + * panel so the face reads as plate rather than as pressed sheet. + * + * Its lamps are on the frame only. A leaf with no power on it is the point. + */ + +export function createModel(): DoorModel { + return createDoorModel({ + id: 'bunker-door', + condition: 0.66, + build: ({ m, bundle, part, root }) => { + const frame = part('frame') + const leaf = part('leaf') + const amber = signalLamp(bundle, 'AMBER-400', 7_020) + + buildPortal(frame, m, { signal: amber }) + + leafSkin(leaf, m, { skin: m.shell }) + leafRecess(leaf, m, [LEAF_HALF[0] * 0.6, DOOR_KIT.centreY - 0.56]) + leafRibs(leaf, m) + + // Armour ribs: three horizontal bands rather than one raised panel. They + // carry the leaf's load into the dog bolts at each end, so each band ends + // exactly where a bolt is. + for (let index = 0; index < 3; index += 1) { + const y = DOOR_KIT.centreY + (index - 1) * 0.58 + box(leaf, m.graphiteEdge, [LEAF_HALF[0] * 1.74, 0.13, 0.04], [0, y, PANEL_FRONT + 0.012], { + chamfer: 0.03, fillet: 0.01, bevel: 0.009, + }) + for (const sx of [-1, 1]) { + box(leaf, m.steel, [0.085, 0.16, 0.06], [sx * (LEAF_HALF[0] - 0.055), y, PANEL_FRONT + 0.006], { + chamfer: 0.02, fillet: 0.008, bevel: 0.007, + }) + } + } + + // Hand wheel: rim, hub, and four spokes, on a stub shaft. + // + // Built in its own group about its own axis, so the actuator can turn. + // A dogging wheel that stays still while the leaf swings is the clearest + // possible statement that nothing on this door is actually connected. + const hubY = DOOR_KIT.centreY + 0.08 + const hubX = LEAF_HALF[0] * 0.1 + leaf.add(cylinder(m.steel, 0.026, 0.14, [hubX, hubY, PANEL_FRONT + 0.075], AXIS_Z, 10)) + leaf.add(cylinder(m.graphite, 0.075, 0.05, [hubX, hubY, PANEL_FRONT + 0.03], AXIS_Z, 12)) + boltRun(leaf, m.steel, [hubX - 0.13, hubY - 0.19, PANEL_FRONT + 0.05], [hubX + 0.13, hubY - 0.19, PANEL_FRONT + 0.05], 3, 0.016, 'front') + + // The swing carries both the leaf and the wheel. Hung off the leaf + // instead — the obvious place, since that is what it is bolted to — the + // wheel is an assembly nested inside an assembly, and the batch merge + // deletes the group it would be re-attached to. + const swing = new Group() + swing.name = 'AXR_ARCH_BUNKER-DOOR_PART_SWING_CLOSED' + swing.position.set(-LEAF_HALF[0], 0, 0) + leaf.position.set(LEAF_HALF[0], 0, 0) + swing.add(leaf) + root.add(swing) + + const wheel = new Group() + wheel.name = 'AXR_ARCH_BUNKER-DOOR_PART_WHEEL_CLOSED' + wheel.position.set(hubX + LEAF_HALF[0], hubY, 0) + swing.add(wheel) + // Three spokes rather than four. Four boxes at 45-degree steps are only + // two distinct bars — a box rotated by pi is the same box — so the first + // pass built an X and called it a wheel. + const rim = 0.21 + for (let index = 0; index < 3; index += 1) { + const angle = (index / 3) * Math.PI + box(wheel, m.steel, [0.04, rim * 2, 0.034], [0, 0, PANEL_FRONT + 0.13], { + chamfer: 0.012, fillet: 0.005, bevel: 0.005, rotation: [0, 0, angle], + }) + } + // Rim as a tangent-segment polygon. Radial studs, which is what the first + // pass produced, are a hub with spikes rather than something to grip. + const segments = 14 + const chord = 2 * rim * Math.tan(Math.PI / segments) + 0.012 + for (let index = 0; index < segments; index += 1) { + const angle = (index / segments) * Math.PI * 2 + box(wheel, m.ironOxide, [chord, 0.042, 0.042], [ + Math.cos(angle) * rim, + Math.sin(angle) * rim, + PANEL_FRONT + 0.13, + ], { chamfer: 0.01, fillet: 0.005, bevel: 0.004, rotation: [0, 0, angle + Math.PI / 2] }) + } + + return { + assemblies: [swing, wheel], + cycleSeconds: 3.4, + apply: (blend) => { + // The wheel undogs through two full turns before the leaf starts to + // move, so the first third of the cycle is all actuator and the rest + // is all swing — which is how a dogged door actually opens. + wheel.rotation.z = -Math.min(1, blend / 0.34) * Math.PI * 4 + swing.rotation.y = Math.max(0, (blend - 0.34) / 0.66) * 1.6 + }, + sockets: { + door_wheel: [hubX, hubY, PANEL_FRONT + 0.13], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.6 + Math.sin(elapsed * 0.8) * 0.08 + }, + } + }, + }) +} + +export function createPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), options) +} diff --git a/assets/prototypes/damaged-door/model.ts b/assets/prototypes/damaged-door/model.ts new file mode 100644 index 00000000..ba2ee56d --- /dev/null +++ b/assets/prototypes/damaged-door/model.ts @@ -0,0 +1,122 @@ +import { Group } from 'three/webgpu' + +import { box, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + DOOR_KIT, + LEAF_HALF, + PANEL_FRONT, + buildPortal, + createDoorModel, + createDoorPreview, + leafRibs, + leafSkin, + signalLamp, + steppedSeam, + type DoorModel, + type DoorPreviewOptions, +} from '../axiom-door-kit/index.ts' + +/** + * Axiom Relay damaged door — the same leaf after something went through it. + * + * The brief's damage variant is not a separate prop, and building it as one is + * how a kit stops being a kit. This is the standard leaf and the standard + * portal; what changed is that the lower hinge has failed, so the leaf hangs on + * the upper knuckle alone, rests on the threshold, and cannot close. Everything + * else follows from that single cause: the meeting edge is sprung away from the + * jamb, the skin is buckled where it took the load, the seam has torn open at + * the jog, and the leaf-side lamp circuit is dead because its loom ran through + * the hinge. + * + * The frame's own signal still works and reads RED-500. A door that cannot + * close is a door reporting a fault, not a door with no power. + */ + +/** Leaf droop and sprung angles, from the failed lower knuckle. */ +const DROOP = -0.085 +const SPRUNG = 0.19 + +export function createModel(): DoorModel { + return createDoorModel({ + id: 'damaged-door', + condition: 0.94, + build: ({ m, bundle, part, root }) => { + const frame = part('frame') + const leaf = part('leaf') + const red = signalLamp(bundle, 'RED-500', 10_450) + + buildPortal(frame, m, { signal: red }) + + leafSkin(leaf, m) + steppedSeam(leaf, m) + leafRibs(leaf, m) + + // The failed knuckle, still on the leaf, sheared off its pin. + box(leaf, m.ironOxide, [0.1, 0.14, 0.08], [-LEAF_HALF[0] + 0.03, DOOR_KIT.centreY - 0.66, PANEL_FRONT - 0.09], { + chamfer: 0.024, fillet: 0.008, bevel: 0.007, rotation: [0, 0, 0.22], + }) + + // Buckles: three creases across the skin, each perpendicular to the load + // path from the surviving hinge to the corner that is now carrying the + // leaf's weight. Damage with one cause reads as damage; damage scattered + // for texture reads as noise. + const buckles: readonly [number, number, number, number][] = [ + [-0.16, DOOR_KIT.centreY - 0.34, 0.62, -0.72], + [0.06, DOOR_KIT.centreY - 0.68, 0.44, -0.66], + [-0.3, DOOR_KIT.centreY + 0.06, 0.3, -0.78], + ] + for (const [x, y, length, angle] of buckles) { + box(leaf, m.shellShade, [length, 0.05, 0.022], [x, y, PANEL_FRONT + 0.006], { + chamfer: 0.014, fillet: 0.006, bevel: 0.005, rotation: [0, 0, angle], + }) + box(leaf, m.ink, [length * 0.9, 0.018, 0.026], [x, y - 0.026, PANEL_FRONT + 0.008], { + chamfer: 0.006, fillet: 0.003, bevel: 0.003, rotation: [0, 0, angle], + }) + } + + // The torn seam. Same jog as the intact leaf, opened into a gap. + box(leaf, m.ink, [0.09, 0.3, 0.034], [LEAF_HALF[0] * 0.16, DOOR_KIT.centreY + LEAF_HALF[1] * 0.19, PANEL_FRONT - 0.002], { + chamfer: 0.012, fillet: 0.005, bevel: 0.004, rotation: [0, 0, 0.14], + }) + + // Dead lamp on the leaf: the fixture is intact, the circuit is not. + box(leaf, m.graphite, [0.19, 0.31, 0.05], [LEAF_HALF[0] * 0.66, DOOR_KIT.centreY - 0.02, PANEL_FRONT + 0.02], { + chamfer: 0.045, fillet: 0.012, bevel: 0.01, capChamfer: 0.018, + }) + box(leaf, m.amberDim, [0.1, 0.1, 0.03], [LEAF_HALF[0] * 0.66, DOOR_KIT.centreY + 0.042, PANEL_FRONT + 0.05], { + chamfer: 0.026, fillet: 0.006, bevel: 0.005, + }) + + // Hung from the upper knuckle only, so the leaf droops and springs open. + const swing = new Group() + swing.name = 'AXR_ARCH_DAMAGED-DOOR_PART_SWING_JAMMED' + swing.position.set(-LEAF_HALF[0], DOOR_KIT.centreY + 0.66, 0) + leaf.position.set(LEAF_HALF[0], -(DOOR_KIT.centreY + 0.66), 0) + swing.add(leaf) + swing.rotation.set(0, SPRUNG, DROOP) + root.add(swing) + + return { + assemblies: [swing], + cycleSeconds: 3.8, + // It does not close. `open` only widens the gap it is already stuck at, + // because a jammed leaf that animates shut on request is not damaged. + apply: (blend) => { + swing.rotation.set(0, SPRUNG + blend * 0.85, DROOP) + }, + sockets: { + door_failed_hinge: [-LEAF_HALF[0], DOOR_KIT.centreY - 0.66, PANEL_FRONT - 0.09], + fx_fault_gap: [LEAF_HALF[0] * 0.16, DOOR_KIT.centreY, PANEL_FRONT], + }, + tick: (elapsed) => { + // A fault flash, not the steady breathe the healthy doors use. + red.emissiveIntensity = 0.34 + (Math.sin(elapsed * 5.4) > 0.2 ? 0.62 : 0) + }, + } + }, + }) +} + +export function createPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), options) +} diff --git a/assets/prototypes/door-frame/model.ts b/assets/prototypes/door-frame/model.ts new file mode 100644 index 00000000..3b0d4ff1 --- /dev/null +++ b/assets/prototypes/door-frame/model.ts @@ -0,0 +1,47 @@ +import { + buildPortal, + createDoorModel, + createDoorPreview, + signalLamp, + type DoorModel, + type DoorPreviewOptions, +} from '../axiom-door-kit/index.ts' +import type { CargoPreview } from '../axiom-cargo-kit/index.ts' + +/** + * Axiom Relay door frame — the shared portal with no leaf in it. + * + * Every other module in the doors group hangs off this one, so it ships on its + * own: a level builder who wants an opening, a bulkhead pass-through, or a + * doorway that a scripted leaf will fill later should not have to delete a door + * to get one. It carries the whole interface — plate, reveal, threshold, jamb + * strips, head lamp, control plate, and the hinge stack a leaf will be hung on — + * which is what makes the group a kit rather than nine lookalikes. + */ + +export function createModel(): DoorModel { + return createDoorModel({ + id: 'door-frame', + condition: 0.38, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const amber = signalLamp(bundle, 'AMBER-400', 3_320) + buildPortal(frame, m, { signal: amber }) + return { + sockets: { + // The leaf interface, published because the frame's whole job is to + // be the thing other modules are authored against. + door_hinge_upper: [-0.6, 1.98, 0.03], + door_hinge_lower: [-0.6, 0.62, 0.03], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.72 + Math.sin(elapsed * 1.6) * 0.1 + }, + } + }, + }) +} + +export function createPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), options) +} diff --git a/assets/prototypes/double-sliding-door/model.ts b/assets/prototypes/double-sliding-door/model.ts new file mode 100644 index 00000000..2ba30c0b --- /dev/null +++ b/assets/prototypes/double-sliding-door/model.ts @@ -0,0 +1,104 @@ +import { Group } from 'three/webgpu' + +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Y, box, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + CLEAR_HALF, + DOOR_KIT, + LEAF_HALF, + PANEL_FRONT, + buildPortal, + createDoorModel, + createDoorPreview, + leafRibs, + leafSkin, + signalLamp, + type DoorModel, + type DoorPreviewOptions, +} from '../axiom-door-kit/index.ts' + +/** Each leaf covers half the clear opening, with a small overlap at the meeting stile. */ +const HALF_LEAF: readonly [number, number] = [CLEAR_HALF[0] * 0.5 - 0.006, LEAF_HALF[1]] + +/** + * Axiom Relay double sliding door — the powered bi-parting leaf pair. + * + * The interesting constraint is where the leaves *go*. A bi-parting door that + * slides into the wall needs pockets the kit's 1.6 m module does not have, so + * these run on an exposed head track and park across the jambs instead. That is + * a real building decision and it shows: the track, its carriages, and the + * drive belt are all on the outside of the module, and the leaves are thin + * because nothing structural passes through them. + */ + +export function createModel(): DoorModel { + return createDoorModel({ + id: 'double-sliding-door', + condition: 0.34, + build: ({ m, bundle, part, root }) => { + const frame = part('frame') + const left = part('leaf-left') + const right = part('leaf-right') + const cyan = signalLamp(bundle, 'CYAN-400', 8_260) + + // No hinge column: the leaves hang from the head, not from a jamb. + buildPortal(frame, m, { signal: cyan, hinges: false }) + + // Head track, carriages, and the belt that drives them. + const trackY = DOOR_KIT.centreY + CLEAR_HALF[1] - 0.06 + box(frame, m.graphite, [DOOR_KIT.width - 0.24, 0.1, 0.09], [0, trackY, PANEL_FRONT + 0.02], { + chamfer: 0.026, fillet: 0.01, bevel: 0.008, + }) + frame.add(cylinder(m.steel, 0.016, DOOR_KIT.width - 0.3, [0, trackY - 0.055, PANEL_FRONT + 0.03], AXIS_X, 8)) + for (const sx of [-1, 1]) { + frame.add(cylinder(m.steel, 0.038, 0.03, [sx * (DOOR_KIT.width * 0.5 - 0.19), trackY, PANEL_FRONT + 0.08], AXIS_Y, 10)) + } + + const buildLeaf = (parent: Group, centreX: number, side: -1 | 1): void => { + leafSkin(parent, m, { half: HALF_LEAF, centreX, clip: 0.1 }) + leafRibs(parent, m, centreX, HALF_LEAF) + // Meeting stile: the vertical the two leaves close against. Only the + // inboard edge gets one, because only that edge meets anything. + box(parent, m.graphiteEdge, [0.05, HALF_LEAF[1] * 1.9, 0.05], [ + centreX - side * (HALF_LEAF[0] - 0.025), DOOR_KIT.centreY, PANEL_FRONT - 0.004, + ], { chamfer: 0.014, fillet: 0.006, bevel: 0.005 }) + // Carriage hanger up to the track. + box(parent, m.steel, [0.07, 0.11, 0.045], [centreX, trackY - 0.075, PANEL_FRONT + 0.03], { + chamfer: 0.018, fillet: 0.007, bevel: 0.006, + }) + // A single lit band per leaf, low, where a hand reaches. + box(parent, cyan, [HALF_LEAF[0] * 1.3, 0.028, 0.026], [ + centreX, DOOR_KIT.centreY - 0.32, PANEL_FRONT + 0.008, + ], { chamfer: 0.008, fillet: 0.004, bevel: 0.003 }) + } + + const openBy = CLEAR_HALF[0] * 0.92 + buildLeaf(left, -HALF_LEAF[0], -1) + buildLeaf(right, HALF_LEAF[0], 1) + + return { + assemblies: [left, right], + cycleSeconds: 1.3, + apply: (blend) => { + left.position.x = -blend * openBy + right.position.x = blend * openBy + }, + sockets: { + rail_head: [0, trackY, PANEL_FRONT + 0.02], + door_meet: [0, DOOR_KIT.centreY, PANEL_FRONT], + }, + tick: (elapsed) => { + cyan.emissiveIntensity = 0.66 + Math.sin(elapsed * 2.6) * 0.16 + }, + } + }, + }) +} + +export function createPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), options) +} + +export function createOpenPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), { ...options, state: 'open' }) +} diff --git a/assets/prototypes/floor-hatch/model.ts b/assets/prototypes/floor-hatch/model.ts new file mode 100644 index 00000000..bdc74592 --- /dev/null +++ b/assets/prototypes/floor-hatch/model.ts @@ -0,0 +1,187 @@ + + +import { cylinder, extrudeProfile } from '../../../src/asset-forge/generator/index.ts' +import { + AXIS_X, + box, + boltRun, + recessedHandle, + slot, + statusLens, + type CargoPreview, +} from '../axiom-cargo-kit/index.ts' +import { + createDoorModel, + createDoorPreview, + signalLamp, + type DoorModel, + type DoorPreviewOptions, +} from '../axiom-door-kit/index.ts' + +/** + * Axiom Relay floor hatch — the doors group turned through ninety degrees. + * + * A hatch is not a small door lying down. Gravity now acts along the leaf's + * opening axis instead of across it, and every difference follows from that: the + * lid needs a gas strut because it has to be lifted rather than pushed, the + * handle is recessed because anything proud on a walked-on surface is a trip + * hazard, the frame has a raised kerb so water and swarf do not run into the + * void, and the signal is on the deck around the opening rather than on the lid + * — a lamp on a lid faces the ceiling when the hatch is open and the floor when + * it is shut, which is to say it is never facing anyone. + */ + +const HATCH = Object.freeze({ + width: 1.2, + depth: 1.2, + height: 0.22, + /** Clear opening, and the kerb that surrounds it. */ + clear: 0.92, + kerb: 0.06, +}) + +const CLEAR_HALF = HATCH.clear * 0.5 +const DECK_Y = 0.12 + +export function createModel(): DoorModel { + return createDoorModel({ + id: 'floor-hatch', + condition: 0.78, + envelope: { width: HATCH.width, depth: HATCH.depth, height: HATCH.height }, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const lid = part('lid') + const amber = signalLamp(bundle, 'AMBER-400', 13_800) + + // Deck plate with the octagonal opening, laid flat. Same profile language + // as the standing doors, rotated onto the XZ plane. + frame.add(extrudeProfile(m.graphite, slot(HATCH.width * 0.5, HATCH.depth * 0.5, 0.16), DECK_Y, [ + 0, DECK_Y * 0.5, 0, + ], { + fillet: 0.014, + bevel: 0.016, + holes: [slot(CLEAR_HALF, CLEAR_HALF, 0.12)], + rotation: [Math.PI / 2, 0, 0], + })) + // Raised kerb around the opening. + frame.add(extrudeProfile(m.graphiteEdge, slot(CLEAR_HALF + 0.075, CLEAR_HALF + 0.075, 0.13), HATCH.kerb, [ + 0, DECK_Y + HATCH.kerb * 0.5, 0, + ], { + fillet: 0.008, + bevel: 0.01, + holes: [slot(CLEAR_HALF, CLEAR_HALF, 0.12)], + rotation: [Math.PI / 2, 0, 0], + })) + // Void liner, so the opening bottoms out in black. + frame.add(extrudeProfile(m.ink, slot(CLEAR_HALF + 0.02, CLEAR_HALF + 0.02, 0.12), 0.26, [ + 0, -0.1, 0, + ], { + fillet: 0.006, + bevel: 0.006, + holes: [slot(CLEAR_HALF - 0.03, CLEAR_HALF - 0.03, 0.1)], + rotation: [Math.PI / 2, 0, 0], + })) + + // Deck signal: two lamps set into the plate at the approach edge, where a + // boot arrives. This is the module's whole state read. + for (const sx of [-1, 1]) { + box(frame, m.ink, [0.24, 0.04, 0.07], [sx * 0.3, DECK_Y - 0.012, HATCH.depth * 0.5 - 0.075], { + chamfer: 0.016, fillet: 0.006, bevel: 0.005, + }) + box(frame, amber, [0.2, 0.03, 0.04], [sx * 0.3, DECK_Y + 0.004, HATCH.depth * 0.5 - 0.075], { + chamfer: 0.01, fillet: 0.004, bevel: 0.003, + }) + } + boltRun(frame, m.steel, [-HATCH.width * 0.5 + 0.09, DECK_Y, -HATCH.depth * 0.5 + 0.09], [HATCH.width * 0.5 - 0.09, DECK_Y, -HATCH.depth * 0.5 + 0.09], 4, 0.016, 'top') + boltRun(frame, m.steel, [-HATCH.width * 0.5 + 0.09, DECK_Y, HATCH.depth * 0.5 - 0.09], [HATCH.width * 0.5 - 0.09, DECK_Y, HATCH.depth * 0.5 - 0.09], 4, 0.016, 'top') + + // Hinge barrels along the far edge, and the pin they share. + const hingeZ = -CLEAR_HALF - 0.04 + const hingeY = DECK_Y + HATCH.kerb + 0.03 + frame.add(cylinder(m.steel, 0.022, HATCH.clear * 0.7, [0, hingeY, hingeZ], AXIS_X, 10)) + for (const sx of [-1, 1]) { + frame.add(cylinder(m.graphiteEdge, 0.048, 0.1, [sx * 0.26, hingeY, hingeZ], AXIS_X, 12)) + } + + // The lid: tread plate, a stiffening rib pair, and a flush grab recess. + const lidY = DECK_Y + HATCH.kerb + 0.028 + lid.add(extrudeProfile(m.shell, slot(CLEAR_HALF - 0.012, CLEAR_HALF - 0.012, 0.11), 0.055, [ + 0, lidY, 0, + ], { fillet: 0.01, bevel: 0.014, capChamfer: [0.02, 0.015], rotation: [Math.PI / 2, 0, 0] })) + for (const sz of [-1, 1]) { + box(lid, m.graphiteEdge, [CLEAR_HALF * 1.6, 0.035, 0.07], [0, lidY - 0.045, sz * 0.24], { + chamfer: 0.018, fillet: 0.007, bevel: 0.006, + }) + } + // Tread ribs on the walking face. + for (let index = 0; index < 7; index += 1) { + box(lid, m.ink, [CLEAR_HALF * 1.5, 0.012, 0.026], [0, lidY + 0.03, (index - 3) * 0.11], { + chamfer: 0.005, fillet: 0.002, bevel: 0.002, + }) + } + recessedHandle(lid, m, [0.24, 0.11], [0, lidY + 0.028, CLEAR_HALF * 0.52], 'top') + statusLens(lid, m, [0.05, 0.05], [-CLEAR_HALF * 0.55, lidY + 0.028, CLEAR_HALF * 0.52], m.cyan, 'top') + + // Hinge the lid about the far edge, not its own centre. + lid.position.set(0, 0, hingeZ) + for (const child of lid.children) child.position.z -= hingeZ + + // Gas strut: the reason the lid stays up. + // + // Anchored to the deck and animated as its own assembly, because that is + // what it physically is — a strut carried by the lid swings *with* the + // lid and would drive itself through the deck. It is also a sibling of + // the lid rather than a child: a nested assembly is deleted by the batch + // merge, which is how the bunker door lost its hand wheel. + // + // A closed hatch's strut is folded almost flat, not stood upright. Drawn + // vertical it was a 440 mm pole rising out of a walked-on deck — a trip + // hazard modelled as a feature. + const strut = part('strut') + strut.position.set(0.3, hingeY, hingeZ) + strut.rotation.x = -1.42 + strut.add(cylinder(m.steel, 0.016, 0.34, [0, 0.17, 0], [0, 0, 0], 8)) + strut.add(cylinder(m.graphiteEdge, 0.024, 0.16, [0, 0.08, 0], [0, 0, 0], 10)) + + return { + assemblies: [lid, strut], + cycleSeconds: 2.4, + apply: (blend) => { + lid.rotation.x = -blend * 1.62 + // The strut extends and swings up out of its folded rest as the lid + // rises, so the two are never in contradiction about how far open it is. + strut.rotation.x = -1.42 + blend * 0.78 + strut.scale.y = 1 + blend * 0.55 + }, + sockets: { + door_threshold: [0, DECK_Y, HATCH.depth * 0.5], + door_head: [0, DECK_Y + HATCH.kerb, 0], + mount_left: [-HATCH.width * 0.5, DECK_Y * 0.5, 0], + mount_right: [HATCH.width * 0.5, DECK_Y * 0.5, 0], + power_head: [HATCH.width * 0.5 - 0.12, DECK_Y, -HATCH.depth * 0.5 + 0.1], + door_hinge_line: [0, hingeY, hingeZ], + cover_void: [0, -0.1, 0], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.6 + Math.sin(elapsed * 1.4) * 0.12 + }, + } + }, + }) +} + +/** Framed from above, because a floor hatch is only ever seen from above. */ +export function createPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), { + target: [0, 0.16, 0], + distance: 2.5, + yaw: -0.7, + pitch: 0.62, + fov: 34, + ...options, + }) +} + +export function createOpenPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createPreview({ ...options, state: 'open' }) +} diff --git a/assets/prototypes/glass-commercial-door/model.ts b/assets/prototypes/glass-commercial-door/model.ts new file mode 100644 index 00000000..08d8d04b --- /dev/null +++ b/assets/prototypes/glass-commercial-door/model.ts @@ -0,0 +1,119 @@ +import { Group } from 'three/webgpu' + +import { extrudeProfile } from '../../../src/asset-forge/generator/index.ts' +import { box, slot, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + DOOR_KIT, + LEAF_HALF, + LEAF_Z, + PANEL_FRONT, + buildPortal, + createDoorModel, + createDoorPreview, + signalLamp, + type DoorModel, + type DoorPreviewOptions, +} from '../axiom-door-kit/index.ts' + +/** + * Axiom Relay glass commercial door — the shopfront leaf. + * + * This is the module that proves the opening is shared rather than the leaf. It + * hangs in the same octagon as the blast door and uses the same jamb hardware, + * but almost none of the leaf is there: two slim stiles, a rail top and bottom, + * a push bar, and glass in between. Where the hardened doors put armour, this + * one puts nothing, and the frame around it is unchanged — which is exactly the + * argument for building a doors group instead of nine separate props. + * + * It carries no dominant signal on the leaf. A commercial door's state is + * whether you can see the shop through it. + */ + +export function createModel(): DoorModel { + return createDoorModel({ + id: 'glass-commercial-door', + condition: 0.24, + build: ({ m, bundle, part, root }) => { + const frame = part('frame') + const leaf = part('leaf') + const cyan = signalLamp(bundle, 'CYAN-400', 9_310) + + buildPortal(frame, m, { signal: cyan }) + + const [hx, hy] = LEAF_HALF + const stile = 0.085 + const rail = 0.13 + + // Glazed centre, cut as one pane and set behind the frame members so the + // members read as what holds it rather than as a grid drawn on it. + leaf.add(extrudeProfile(m.glass, slot(hx - stile * 0.6, hy - rail * 0.6, 0.08), 0.016, [ + 0, DOOR_KIT.centreY, LEAF_Z + 0.012, + ], { fillet: 0.004, bevel: 0.005 })) + + // Perimeter: two stiles and two rails, mitred by overlap at the corners. + for (const sx of [-1, 1]) { + box(leaf, m.shellLight, [stile, hy * 2, 0.062], [sx * (hx - stile * 0.5), DOOR_KIT.centreY, LEAF_Z + 0.03], { + chamfer: 0.02, fillet: 0.008, bevel: 0.009, + }) + } + for (const sy of [-1, 1]) { + // The bottom rail is deeper: it is the one a trolley hits. + const depth = sy < 0 ? rail * 1.7 : rail + box(leaf, m.shellLight, [hx * 2 - stile * 2, depth, 0.062], [ + 0, DOOR_KIT.centreY + sy * (hy - depth * 0.5), LEAF_Z + 0.03, + ], { chamfer: 0.02, fillet: 0.008, bevel: 0.009 }) + } + box(leaf, m.graphiteEdge, [hx * 2 - stile * 2, 0.03, 0.07], [ + 0, DOOR_KIT.centreY - hy + rail * 1.7, LEAF_Z + 0.032, + ], { chamfer: 0.009, fillet: 0.004, bevel: 0.004 }) + + // Push bar, on stand-offs, spanning most of the leaf width. + const barY = DOOR_KIT.centreY - 0.06 + box(leaf, m.steel, [hx * 1.55, 0.05, 0.045], [0, barY, PANEL_FRONT + 0.05], { + chamfer: 0.016, fillet: 0.007, bevel: 0.006, + }) + for (const sx of [-1, 1]) { + box(leaf, m.graphite, [0.045, 0.045, 0.05], [sx * hx * 0.62, barY, PANEL_FRONT + 0.014], { + chamfer: 0.012, fillet: 0.005, bevel: 0.005, + }) + } + + // Manifestation band — the strip that stops people walking into the glass. + box(leaf, m.shellShade, [hx * 1.72, 0.055, 0.006], [0, DOOR_KIT.centreY + 0.46, LEAF_Z + 0.024], { + chamfer: 0.012, fillet: 0.004, bevel: 0.002, + }) + + const swing = new Group() + swing.name = 'AXR_ARCH_GLASS-COMMERCIAL-DOOR_PART_SWING_CLOSED' + swing.position.set(-hx, 0, 0) + leaf.position.set(hx, 0, 0) + swing.add(leaf) + root.add(swing) + + return { + assemblies: [swing], + cycleSeconds: 1.1, + apply: (blend) => { + swing.rotation.y = blend * 1.5 + }, + sockets: { + door_push_bar: [0, barY, PANEL_FRONT + 0.05], + cover_glazing: [0, DOOR_KIT.centreY, LEAF_Z], + }, + tick: (elapsed) => { + cyan.emissiveIntensity = 0.6 + Math.sin(elapsed * 1.1) * 0.06 + }, + } + }, + }) +} + +export function createPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), options) +} + +/** Framing that shows the glazing reading against the reveal behind it. */ +export function createOpenPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), { ...options, state: 'open', yaw: -0.9 }) +} + diff --git a/assets/prototypes/hangar-door/model.ts b/assets/prototypes/hangar-door/model.ts new file mode 100644 index 00000000..f1ab5537 --- /dev/null +++ b/assets/prototypes/hangar-door/model.ts @@ -0,0 +1,176 @@ +import { Group } from 'three/webgpu' + +import { cylinder, extrudeProfile } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Y, box, boltRun, slot, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + createDoorModel, + createDoorPreview, + signalLamp, + type DoorModel, + type DoorPreviewOptions, +} from '../axiom-door-kit/index.ts' + +/** + * Axiom Relay hangar door — the vehicle-scale bi-parting closure. + * + * This is the one module in the doors group that leaves the shared 1.6 m + * envelope, because it has to: the brief's double variant widens to 2.8 m, and + * a hangar door narrower than the thing it admits is a wall. Everything else is + * deliberately inherited — the same clipped-corner language, the same shell over + * graphite over ink value order, the same amber-on-a-dark-housing signal — so a + * 2.8 m opening still reads as the same construction system as a 1.6 m one. + * + * The leaves are panelled horizontally rather than octagonally. At this width an + * octagonal leaf would need a 0.5 m corner clip to keep the same visual rhythm, + * and that much missing corner on a door meant to seal a hangar is a hole. + */ + +const HANGAR = Object.freeze({ + width: 2.8, + height: 2.6, + depth: 0.34, + clearWidth: 2.28, + clearHeight: 2.16, + centreY: 1.3, +}) + +const CLEAR_X = HANGAR.clearWidth * 0.5 +const CLEAR_Y = HANGAR.clearHeight * 0.5 +const FRONT = HANGAR.depth * 0.5 + +export function createModel(): DoorModel { + return createDoorModel({ + id: 'hangar-door', + condition: 0.68, + envelope: { width: HANGAR.width, depth: HANGAR.depth, height: HANGAR.height }, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const left = part('leaf-left') + const right = part('leaf-right') + const amber = signalLamp(bundle, 'AMBER-400', 12_700) + + // Portal plate, cut with the same nested octagons at hangar scale. + frame.add(extrudeProfile(m.shell, slot(HANGAR.width * 0.5, HANGAR.height * 0.5, 0.34), 0.26, [ + 0, HANGAR.centreY, 0, + ], { + fillet: 0.022, + bevel: 0.02, + capChamfer: [0.05, 0.03], + holes: [slot(CLEAR_X + 0.06, CLEAR_Y + 0.06, 0.24)], + })) + frame.add(extrudeProfile(m.graphite, slot(CLEAR_X + 0.065, CLEAR_Y + 0.065, 0.24), 0.24, [ + 0, HANGAR.centreY, FRONT - 0.09, + ], { + fillet: 0.012, + bevel: 0.016, + holes: [slot(CLEAR_X, CLEAR_Y, 0.22)], + })) + frame.add(extrudeProfile(m.ink, slot(CLEAR_X + 0.03, CLEAR_Y + 0.03, 0.22), 0.14, [ + 0, HANGAR.centreY, FRONT - 0.28, + ], { + fillet: 0.008, + bevel: 0.008, + holes: [slot(CLEAR_X - 0.014, CLEAR_Y - 0.014, 0.2)], + })) + + // Head track and its carriages. Exposed, because at this width nothing + // pockets into the jamb. + const trackY = HANGAR.centreY + CLEAR_Y + 0.13 + box(frame, m.graphite, [HANGAR.width - 0.24, 0.13, 0.12], [0, trackY, FRONT + 0.02], { + chamfer: 0.03, fillet: 0.012, bevel: 0.01, + }) + frame.add(cylinder(m.steel, 0.022, HANGAR.width - 0.34, [0, trackY - 0.07, FRONT + 0.04], AXIS_X, 8)) + for (const sx of [-1, 1]) { + for (const offset of [0.34, 0.86]) { + frame.add(cylinder(m.steel, 0.05, 0.035, [sx * offset, trackY - 0.07, FRONT + 0.09], AXIS_Y, 12)) + } + // Jamb tower, skirt block, and the ground anchor under it. + box(frame, m.graphiteEdge, [0.16, HANGAR.height - 0.5, 0.16], [ + sx * (HANGAR.width * 0.5 - 0.1), HANGAR.centreY, -HANGAR.depth * 0.5 + 0.09, + ], { chamfer: 0.03, fillet: 0.01, bevel: 0.009 }) + box(frame, m.graphite, [0.34, 0.3, HANGAR.depth + 0.04], [ + sx * (HANGAR.width * 0.5 - 0.17), 0.15, 0, + ], { chamfer: 0.04, fillet: 0.012, bevel: 0.01 }) + boltRun(frame, m.steel, [sx * (HANGAR.width * 0.5 - 0.29), 0.06, FRONT + 0.03], [sx * (HANGAR.width * 0.5 - 0.05), 0.06, FRONT + 0.03], 3, 0.017, 'front') + } + + // Threshold: a wide ribbed plate with the drive channel down its middle. + box(frame, m.graphiteEdge, [HANGAR.clearWidth + 0.22, 0.08, HANGAR.depth + 0.06], [0, 0.04, 0.01], { + chamfer: 0.02, fillet: 0.008, bevel: 0.007, capChamfer: [0.06, 0.02], + }) + for (let index = 0; index < 13; index += 1) { + box(frame, m.ink, [0.03, 0.014, HANGAR.depth], [(index - 6) * 0.17, 0.085, 0.01], { + chamfer: 0.006, fillet: 0.003, bevel: 0.002, + }) + } + + // Signal: one lamp bar per jamb tower, plus the head strip. + for (const sx of [-1, 1]) { + box(frame, m.ink, [0.07, 0.8, 0.05], [sx * (HANGAR.width * 0.5 - 0.1), HANGAR.centreY - 0.2, FRONT + 0.02], { + chamfer: 0.022, fillet: 0.008, bevel: 0.006, + }) + box(frame, amber, [0.034, 0.73, 0.032], [sx * (HANGAR.width * 0.5 - 0.1), HANGAR.centreY - 0.2, FRONT + 0.042], { + chamfer: 0.012, fillet: 0.005, bevel: 0.004, + }) + } + + // The leaves: horizontally panelled, four bands each, on a bottom guide. + const halfLeaf = HANGAR.clearWidth * 0.25 - 0.008 + const buildLeaf = (parent: Group, centreX: number, side: -1 | 1): void => { + parent.add(extrudeProfile(m.shellLight, slot(halfLeaf, CLEAR_Y - 0.02, 0.1), 0.1, [ + centreX, HANGAR.centreY, FRONT - 0.185, + ], { fillet: 0.012, bevel: 0.018, capChamfer: [0.03, 0.02] })) + for (let index = 0; index < 4; index += 1) { + const y = HANGAR.centreY + (index - 1.5) * (HANGAR.clearHeight - 0.28) / 4 + box(parent, m.shell, [halfLeaf * 1.78, (HANGAR.clearHeight - 0.34) / 4.4, 0.035], [ + centreX, y, FRONT - 0.12, + ], { chamfer: 0.03, fillet: 0.01, bevel: 0.01 }) + } + // Meeting stile on the inboard edge only, and a guide shoe at the foot. + box(parent, m.graphiteEdge, [0.06, CLEAR_Y * 1.94, 0.06], [ + centreX - side * (halfLeaf - 0.03), HANGAR.centreY, FRONT - 0.125, + ], { chamfer: 0.016, fillet: 0.007, bevel: 0.006 }) + box(parent, m.steel, [0.09, 0.12, 0.05], [centreX, HANGAR.centreY - CLEAR_Y + 0.06, FRONT - 0.125], { + chamfer: 0.02, fillet: 0.008, bevel: 0.007, + }) + box(parent, m.steel, [0.08, 0.13, 0.05], [centreX, trackY - 0.13, FRONT + 0.02], { + chamfer: 0.02, fillet: 0.008, bevel: 0.007, + }) + box(parent, m.amberPaint, [halfLeaf * 1.5, 0.05, 0.02], [ + centreX, HANGAR.centreY - CLEAR_Y + 0.22, FRONT - 0.1, + ], { chamfer: 0.012, fillet: 0.005, bevel: 0.004 }) + } + buildLeaf(left, -halfLeaf, -1) + buildLeaf(right, halfLeaf, 1) + + const travel = HANGAR.clearWidth * 0.46 + return { + assemblies: [left, right], + cycleSeconds: 4.2, + apply: (blend) => { + left.position.x = -blend * travel + right.position.x = blend * travel + }, + sockets: { + door_threshold: [0, 0.08, 0], + door_head: [0, HANGAR.centreY + CLEAR_Y, 0], + rail_head: [0, trackY, FRONT + 0.02], + mount_left: [-HANGAR.width * 0.5, HANGAR.centreY, 0], + mount_right: [HANGAR.width * 0.5, HANGAR.centreY, 0], + power_head: [HANGAR.width * 0.5 - 0.2, HANGAR.height - 0.18, -HANGAR.depth * 0.4], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.66 + Math.sin(elapsed * 2.1) * 0.18 + }, + } + }, + }) +} + +export function createPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), { distance: 7.4, target: [0, HANGAR.centreY - 0.1, 0], ...options }) +} + +export function createOpenPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createPreview({ ...options, state: 'open' }) +} diff --git a/assets/prototypes/industrial-shutter/model.ts b/assets/prototypes/industrial-shutter/model.ts new file mode 100644 index 00000000..71746b96 --- /dev/null +++ b/assets/prototypes/industrial-shutter/model.ts @@ -0,0 +1,117 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, box, boltRun, type CargoPreview } from '../axiom-cargo-kit/index.ts' +import { + CLEAR_HALF, + DOOR_KIT, + PANEL_FRONT, + buildPortal, + createDoorModel, + createDoorPreview, + signalLamp, + type DoorModel, + type DoorPreviewOptions, +} from '../axiom-door-kit/index.ts' + +/** Slat pitch, chosen so a whole number of slats fills the clear opening. */ +const SLAT_COUNT = 17 +const SLAT_PITCH = (DOOR_KIT.clearHeight - 0.04) / SLAT_COUNT + +/** + * Axiom Relay industrial shutter — the rolling closure. + * + * A shutter is the one module in the group with no leaf at all: it is a curtain + * of slats, a barrel to roll onto, and the guides that keep the curtain flat + * while it moves. That makes it the kit's honest answer to "what closes an + * opening you also need to drive a pallet jack through", and it is why it + * carries no hinges, no handle, and no vision port. + * + * The curtain rolls up rather than sliding away, so the module's whole open + * state lives in a 0.34 m barrel above the head — which is the reason the head + * band on this module is the only place the family spends depth. + */ + +export function createModel(): DoorModel { + return createDoorModel({ + id: 'industrial-shutter', + condition: 0.72, + build: ({ m, bundle, part }) => { + const frame = part('frame') + const curtain = part('curtain') + const amber = signalLamp(bundle, 'AMBER-400', 11_600) + + buildPortal(frame, m, { signal: amber, hinges: false }) + + // Barrel housing above the head, and the barrel itself inside it. + // Kept inside the module's 2.6 m envelope. Drawn 60 mm higher the housing + // stood proud of the plate's own top edge and read as a separate box + // balanced on the door rather than as the head of it. + const barrelY = DOOR_KIT.centreY + CLEAR_HALF[1] + 0.11 + box(frame, m.graphite, [DOOR_KIT.width - 0.18, 0.24, 0.26], [0, barrelY, PANEL_FRONT - 0.02], { + chamfer: 0.045, fillet: 0.014, bevel: 0.012, capChamfer: [0.04, 0.02], + }) + frame.add(cylinder(m.ironOxide, 0.115, DOOR_KIT.clearWidth + 0.08, [0, barrelY, PANEL_FRONT - 0.02], AXIS_X, 14)) + for (const sx of [-1, 1]) { + box(frame, m.graphiteEdge, [0.08, 0.21, 0.22], [sx * (DOOR_KIT.width * 0.5 - 0.1), barrelY, PANEL_FRONT - 0.02], { + chamfer: 0.03, fillet: 0.01, bevel: 0.009, + }) + boltRun(frame, m.steel, [sx * (DOOR_KIT.width * 0.5 - 0.1), barrelY - 0.08, PANEL_FRONT + 0.115], [sx * (DOOR_KIT.width * 0.5 - 0.1), barrelY + 0.08, PANEL_FRONT + 0.115], 2, 0.015, 'front') + } + + // Side guides: the channels the curtain's ends run in. + for (const sx of [-1, 1]) { + box(frame, m.graphiteEdge, [0.07, DOOR_KIT.clearHeight + 0.1, 0.11], [ + sx * (CLEAR_HALF[0] + 0.02), DOOR_KIT.centreY, PANEL_FRONT - 0.055, + ], { chamfer: 0.018, fillet: 0.007, bevel: 0.006 }) + } + + // The curtain. Each slat is a shallow box with a rolled lip, and every + // slat is identical — which is the whole reason a shutter is cheap and a + // blast door is not. + const slatWidth = DOOR_KIT.clearWidth - 0.03 + for (let index = 0; index < SLAT_COUNT; index += 1) { + const y = DOOR_KIT.centreY + CLEAR_HALF[1] - 0.02 - (index + 0.5) * SLAT_PITCH + box(curtain, index % 2 === 0 ? m.shell : m.shellShade, [slatWidth, SLAT_PITCH * 0.86, 0.05], [ + 0, y, PANEL_FRONT - 0.07, + ], { chamfer: SLAT_PITCH * 0.2, fillet: 0.005, bevel: 0.006 }) + box(curtain, m.ink, [slatWidth - 0.01, SLAT_PITCH * 0.12, 0.03], [ + 0, y - SLAT_PITCH * 0.47, PANEL_FRONT - 0.078, + ], { chamfer: 0.004, fillet: 0.002, bevel: 0.002 }) + } + // Bottom rail: the heavy section that lands on the threshold, with the + // hazard band the brief asks for as the module's caution read. + const railY = DOOR_KIT.centreY - CLEAR_HALF[1] + 0.04 + box(curtain, m.graphite, [slatWidth + 0.02, 0.11, 0.075], [0, railY, PANEL_FRONT - 0.07], { + chamfer: 0.026, fillet: 0.009, bevel: 0.008, + }) + box(curtain, m.amberPaint, [slatWidth - 0.04, 0.035, 0.024], [0, railY, PANEL_FRONT - 0.028], { + chamfer: 0.008, fillet: 0.004, bevel: 0.003, + }) + + return { + assemblies: [curtain], + cycleSeconds: 2.9, + apply: (blend) => { + // The curtain rises into the barrel and is clipped by the head + // housing, so no slat is ever visible above the opening. + curtain.position.y = blend * (DOOR_KIT.clearHeight - 0.06) + curtain.visible = blend < 0.985 + }, + sockets: { + rail_barrel: [0, barrelY, PANEL_FRONT - 0.02], + door_bottom_rail: [0, railY, PANEL_FRONT - 0.07], + }, + tick: (elapsed) => { + amber.emissiveIntensity = 0.68 + Math.sin(elapsed * 1.9) * 0.14 + }, + } + }, + }) +} + +export function createPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), options) +} + +export function createOpenPreview(options: DoorPreviewOptions = {}): CargoPreview { + return createDoorPreview(createModel(), { ...options, state: 'open' }) +} 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({