From 6f2c1a983c7ce76e01df3cd1e02d1d9028b7d5fb Mon Sep 17 00:00:00 2001 From: reposlayer Date: Sun, 16 Aug 2026 06:04:27 +0200 Subject: [PATCH 1/4] Add the Axiom Relay doors group and its shared door kit Ten of the sixteen briefs under docs/assets/reusable/architecture/doors had no model source. This adds all ten, built on one new support item rather than as ten independent props. axiom-door-kit fixes the group's public interface: a 1.06 x 2.00 m clear opening centred on the brief's 1.6 x 0.30 x 2.6 m envelope, so every leaf in the family hangs in the same doorway. It carries the shared portal plate and reveal ring, threshold, jamb strips, head lamp, control plate, hinge stack, and leaf construction, plus the lit RED-500 and CYAN-400 lamps the doors briefs ask for at a lower emissive tier than the cargo wave's. Modules: door-frame (the portal alone), blast-door, airlock-door, bunker-door, damaged-door, double-sliding-door, glass-commercial-door, hangar-door, industrial-shutter, floor-hatch. hangar-door takes the brief's 2.8 m double envelope; 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. 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-doors-wave.md | 19 ++ assets/prototypes/airlock-door/model.ts | 105 +++++++ assets/prototypes/axiom-door-kit/contract.ts | 70 +++++ assets/prototypes/axiom-door-kit/frame.ts | 280 ++++++++++++++++++ assets/prototypes/axiom-door-kit/index.ts | 214 +++++++++++++ assets/prototypes/axiom-door-kit/leaf.ts | 187 ++++++++++++ assets/prototypes/axiom-door-kit/signal.ts | 56 ++++ assets/prototypes/blast-door/model.ts | 98 ++++++ assets/prototypes/bunker-door/model.ts | 135 +++++++++ assets/prototypes/damaged-door/model.ts | 122 ++++++++ assets/prototypes/door-frame/model.ts | 47 +++ .../prototypes/double-sliding-door/model.ts | 104 +++++++ assets/prototypes/floor-hatch/model.ts | 187 ++++++++++++ .../prototypes/glass-commercial-door/model.ts | 119 ++++++++ assets/prototypes/hangar-door/model.ts | 176 +++++++++++ assets/prototypes/industrial-shutter/model.ts | 119 ++++++++ registries/scifi-kit/src/build.ts | 1 + 17 files changed, 2039 insertions(+) create mode 100644 .changeset/architecture-doors-wave.md create mode 100644 assets/prototypes/airlock-door/model.ts create mode 100644 assets/prototypes/axiom-door-kit/contract.ts create mode 100644 assets/prototypes/axiom-door-kit/frame.ts create mode 100644 assets/prototypes/axiom-door-kit/index.ts create mode 100644 assets/prototypes/axiom-door-kit/leaf.ts create mode 100644 assets/prototypes/axiom-door-kit/signal.ts create mode 100644 assets/prototypes/blast-door/model.ts create mode 100644 assets/prototypes/bunker-door/model.ts create mode 100644 assets/prototypes/damaged-door/model.ts create mode 100644 assets/prototypes/door-frame/model.ts create mode 100644 assets/prototypes/double-sliding-door/model.ts create mode 100644 assets/prototypes/floor-hatch/model.ts create mode 100644 assets/prototypes/glass-commercial-door/model.ts create mode 100644 assets/prototypes/hangar-door/model.ts create mode 100644 assets/prototypes/industrial-shutter/model.ts 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..86c05e98 --- /dev/null +++ b/assets/prototypes/axiom-door-kit/index.ts @@ -0,0 +1,214 @@ +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 + built.apply?.(0) + + return { + root, + parts, + sockets, + get state() { + return state + }, + setState: (next: DoorState) => { + 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 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..ae2583df --- /dev/null +++ b/assets/prototypes/industrial-shutter/model.ts @@ -0,0 +1,119 @@ +import { Group } from 'three/webgpu' + +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 d69b1601..56ad3212 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-door-kit'), ] for (const modelId of modelIds) items.push(await buildModelItem(modelId)) items.push({ From 210d68c5de136b571ee7f9dcc6dfad0238394b76 Mon Sep 17 00:00:00 2001 From: reposlayer Date: Sun, 16 Aug 2026 06:40:45 +0200 Subject: [PATCH 2/4] Keep door part names in step with state, and drop an unused import Review feedback on the doors wave. createDoorModel renamed only the root on a state change, so an exported or previewed open door was full of parts still claiming to be _CLOSED. Every animated model already in the library keeps its moving parts in step - a crate's lid becomes _LID_OPEN - so this follows that convention: the articulated groups are retagged on setState and on the first frame of travel, not only at the far end. Only a trailing _CLOSED or _OPEN is rewritten. The damaged door's leaf is _JAMMED and stays that way, because it is not a state this controller drives and overwriting it would claim the door closes. Also removes the unused Group import from industrial-shutter, which builds its curtain through the kit's part() helper and never constructs one. Co-Authored-By: Claude Opus 5 --- assets/prototypes/axiom-door-kit/index.ts | 26 +++++++++++++++++++ assets/prototypes/industrial-shutter/model.ts | 2 -- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/assets/prototypes/axiom-door-kit/index.ts b/assets/prototypes/axiom-door-kit/index.ts index 86c05e98..2b374622 100644 --- a/assets/prototypes/axiom-door-kit/index.ts +++ b/assets/prototypes/axiom-door-kit/index.ts @@ -159,6 +159,27 @@ export function createDoorModel(spec: DoorBuild): DoorModel { 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 { @@ -171,6 +192,7 @@ export function createDoorModel(spec: DoorBuild): DoorModel { 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 @@ -182,6 +204,10 @@ export function createDoorModel(spec: DoorBuild): DoorModel { 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/industrial-shutter/model.ts b/assets/prototypes/industrial-shutter/model.ts index ae2583df..71746b96 100644 --- a/assets/prototypes/industrial-shutter/model.ts +++ b/assets/prototypes/industrial-shutter/model.ts @@ -1,5 +1,3 @@ -import { Group } from 'three/webgpu' - import { cylinder } from '../../../src/asset-forge/generator/index.ts' import { AXIS_X, box, boltRun, type CargoPreview } from '../axiom-cargo-kit/index.ts' import { From 4af612621b0490bce66578e8215bb62dfd505da6 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 a980ca55..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-door-kit'), + ...await Promise.all(supportIds.map((itemId) => buildSupportItem(itemId))), ] for (const modelId of modelIds) items.push(await buildModelItem(modelId)) items.push({ From a1a430a35c158b89c377051ffc59ca290c8b9b1c 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()