From 15597113a704a4a91a502c82948544a75470c5e1 Mon Sep 17 00:00:00 2001 From: reposlayer Date: Sun, 16 Aug 2026 06:31:44 +0200 Subject: [PATCH 1/5] Add the Axiom Relay roof group and its shared roof kit All ten briefs under docs/assets/reusable/architecture/roof-pieces had no model source. This adds all ten, on one new support item. axiom-roof-kit fixes the group's convention: every module is authored about the deck's top surface at y = 0, with structure hanging below into negative Y. A roof fitting is positioned by where it sits, not by how thick the deck under it happens to be, so a pad dropped onto a thicker deck later does not need re-authoring. The kit carries the shared deck, curb, outlet and parapet builders, and a wear tier with grime above the rest of the kit's and rub below it, because nothing rubs a roof and nobody cleans one. The recurring detail across the group is that nothing sits flat on the deck. Fittings stand on curbs with the membrane turned up against them; the railing and the antenna mast are ballasted rather than bolted, because drilling a roof puts penetrations through the one layer keeping water out. Modules: flat-roof, roof-parapet, rooftop-railing, roof-access-box, roof-machinery-pad, rooftop-hvac-foundation, roof-skylight, roof-antenna-mount, vented-roof-panel, sloped-sci-fi-roof. typecheck, build, and the 49 existing tests pass; the registry builds 179 items from 174 models. Co-Authored-By: Claude Opus 5 --- .changeset/architecture-roof-wave.md | 16 ++ assets/prototypes/axiom-roof-kit/deck.ts | 99 ++++++++ assets/prototypes/axiom-roof-kit/index.ts | 227 ++++++++++++++++++ assets/prototypes/flat-roof/model.ts | 47 ++++ assets/prototypes/roof-access-box/model.ts | 67 ++++++ assets/prototypes/roof-antenna-mount/model.ts | 82 +++++++ assets/prototypes/roof-machinery-pad/model.ts | 61 +++++ assets/prototypes/roof-parapet/model.ts | 47 ++++ assets/prototypes/roof-skylight/model.ts | 70 ++++++ .../rooftop-hvac-foundation/model.ts | 66 +++++ assets/prototypes/rooftop-railing/model.ts | 55 +++++ assets/prototypes/sloped-sci-fi-roof/model.ts | 74 ++++++ assets/prototypes/vented-roof-panel/model.ts | 71 ++++++ registries/scifi-kit/src/build.ts | 1 + 14 files changed, 983 insertions(+) create mode 100644 .changeset/architecture-roof-wave.md create mode 100644 assets/prototypes/axiom-roof-kit/deck.ts create mode 100644 assets/prototypes/axiom-roof-kit/index.ts create mode 100644 assets/prototypes/flat-roof/model.ts create mode 100644 assets/prototypes/roof-access-box/model.ts create mode 100644 assets/prototypes/roof-antenna-mount/model.ts create mode 100644 assets/prototypes/roof-machinery-pad/model.ts create mode 100644 assets/prototypes/roof-parapet/model.ts create mode 100644 assets/prototypes/roof-skylight/model.ts create mode 100644 assets/prototypes/rooftop-hvac-foundation/model.ts create mode 100644 assets/prototypes/rooftop-railing/model.ts create mode 100644 assets/prototypes/sloped-sci-fi-roof/model.ts create mode 100644 assets/prototypes/vented-roof-panel/model.ts diff --git a/.changeset/architecture-roof-wave.md b/.changeset/architecture-roof-wave.md new file mode 100644 index 00000000..515b6bb5 --- /dev/null +++ b/.changeset/architecture-roof-wave.md @@ -0,0 +1,16 @@ +--- +"@scifi-kit/registry": patch +--- + +Add the Axiom Relay roof group: all ten procedural architecture modules from the +roof-pieces inventory, covering the finished deck, the edge upstand and railing, +the stair head, the plant bases, a skylight, a vented bay, and a shed slope. + +They are built on a new `axiom-roof-kit` support item that fixes the group's one +convention: every module is authored about the deck's *top* surface at y = 0, +with structure hanging below into negative Y. A roof fitting is positioned by +where it sits, not by how thick the deck under it happens to be, so a pad +dropped onto a thicker deck later needs no re-authoring. The kit also carries the +shared deck, curb, outlet and parapet builders — nothing on an Axiom roof is +bolted flat to the deck, because the deck is the one layer keeping water out of +the building. diff --git a/assets/prototypes/axiom-roof-kit/deck.ts b/assets/prototypes/axiom-roof-kit/deck.ts new file mode 100644 index 00000000..bb5353ec --- /dev/null +++ b/assets/prototypes/axiom-roof-kit/deck.ts @@ -0,0 +1,99 @@ +import type { Group, Mesh, MeshPhysicalMaterial } from 'three/webgpu' + +import { cylinder, prism, type Vec3 } from '../../../src/asset-forge/generator/index.ts' +import { ROOF } from './index.ts' +import type { KitMaterials } from '../axiom-modular-kit/parts.ts' + +/** The one primitive the group builds everything from. */ +export function block( + parent: Group, + material: MeshPhysicalMaterial, + size: Vec3, + position: Vec3, + rotation?: Vec3, +): Mesh { + const smallest = Math.min(...size) + const mesh = prism(material, size, position, { + chamfer: Math.min(0.06, smallest * 0.24), + fillet: Math.min(0.018, smallest * 0.1), + bevel: Math.min(0.016, smallest * 0.13), + ...(rotation ? { rotation } : {}), + }) + parent.add(mesh) + return mesh +} + +/** + * A roof deck: the slab, its membrane, and the bay joints in it. + * + * The membrane is a separate 12 mm layer rather than a colour on the slab, + * because every other module in the group has to *penetrate* it — a curb, a + * post base, an outlet — and a penetration through a painted-on membrane has + * nothing to flash against. + */ +export function deck(parent: Group, m: KitMaterials, width: number, depth: number): void { + block(parent, m.graphite, [width, ROOF.deck, depth], [0, -ROOF.deck * 0.5, 0]) + block(parent, m.deck, [width - 0.04, 0.012, depth - 0.04], [0, -0.006, 0]) + // Bay joints on the structural grid, in both directions. + for (const [along, across] of [[width, depth], [depth, width]] as const) { + const bays = Math.max(1, Math.round(along / ROOF.grid)) + for (let index = 1; index < bays; index += 1) { + const offset = -along / 2 + (index / bays) * along + const size: Vec3 = along === width ? [0.05, 0.016, across - 0.08] : [across - 0.08, 0.016, 0.05] + const position: Vec3 = along === width ? [offset, -0.004, 0] : [0, -0.004, offset] + block(parent, m.ink, size, position) + } + } +} + +/** + * A curb: the raised upstand every roof penetration sits on. + * + * Nothing on an Axiom roof is bolted flat to the deck. A curb lifts the fitting + * clear of standing water and gives the membrane something to turn up against, + * and the counter-flashing over it is what actually keeps the water out. Fittings + * drawn sitting directly on the deck are the single clearest sign a roof was + * modelled rather than built. + */ +export function curb(parent: Group, m: KitMaterials, width: number, depth: number, height = 0.28, centre: Vec3 = [0, 0, 0]): void { + const [cx, , cz] = centre + block(parent, m.graphite, [width, height, depth], [cx, height * 0.5, cz]) + block(parent, m.deck, [width + 0.06, 0.05, depth + 0.06], [cx, height + 0.02, cz]) + // Membrane turn-up: the reason the curb exists, drawn as the thing it is. + block(parent, m.ink, [width + 0.09, 0.09, depth + 0.09], [cx, 0.045, cz]) +} + +/** + * A rainwater outlet: sump, grating, and the fall lines that feed it. The one + * part of a roof that explains where the water goes. + */ +export function outlet(parent: Group, m: KitMaterials, centre: Vec3): void { + const [cx, , cz] = centre + block(parent, m.ink, [0.42, 0.09, 0.42], [cx, -0.04, cz]) + block(parent, m.steel, [0.3, 0.03, 0.3], [cx, -0.005, cz]) + for (let index = 0; index < 4; index += 1) { + block(parent, m.ink, [0.26, 0.02, 0.028], [cx, 0.006, cz - 0.09 + index * 0.06]) + } + parent.add(cylinder(m.graphite, 0.13, 0.34, [cx, -ROOF.deck - 0.1, cz], [0, 0, 0], 12)) +} + +/** Parapet: the upstand around a deck edge, with its coping and drip. */ +export function parapet( + parent: Group, + m: KitMaterials, + width: number, + depth: number, + height = ROOF.parapet, + sides: readonly ('front' | 'back' | 'left' | 'right')[] = ['front', 'back', 'left', 'right'], +): void { + const t = 0.18 + const run = (size: Vec3, position: Vec3): void => { + block(parent, m.graphite, size, [position[0], height * 0.5, position[2]]) + block(parent, m.deck, [size[0] + 0.1, 0.09, size[2] + 0.1], [position[0], height + 0.045, position[2]]) + block(parent, m.ink, [size[0] + 0.04, 0.1, size[2] + 0.04], [position[0], 0.05, position[2]]) + } + if (sides.includes('front')) run([width, height, t], [0, 0, depth / 2 - t / 2]) + if (sides.includes('back')) run([width, height, t], [0, 0, -depth / 2 + t / 2]) + if (sides.includes('left')) run([t, height, depth - t * 2], [-width / 2 + t / 2, 0, 0]) + if (sides.includes('right')) run([t, height, depth - t * 2], [width / 2 - t / 2, 0, 0]) +} diff --git a/assets/prototypes/axiom-roof-kit/index.ts b/assets/prototypes/axiom-roof-kit/index.ts new file mode 100644 index 00000000..4136461e --- /dev/null +++ b/assets/prototypes/axiom-roof-kit/index.ts @@ -0,0 +1,227 @@ +import { + Color, + DirectionalLight, + Group, + HemisphereLight, + Mesh, + MeshPhysicalMaterial, + PerspectiveCamera, + Scene, + Vector3, + type BufferGeometry, +} from 'three/webgpu' + +import { + WEAR_ATTRIBUTES, + bakeOcclusion, + bakeSurfaceAttributes, + createWearMaterial, + mergeStaticByMaterial, + prism, + type Vec3, + type WearProfile, +} from '../../../src/asset-forge/generator/index.ts' +import { AXIOM_KIT } from '../axiom-modular-kit/contract.ts' +import { KIT_BUILD, createKitMaterials, type KitMaterials } from '../axiom-modular-kit/parts.ts' + +/** + * The roof group: everything that happens on top of an Axiom shell. + * + * The kit's roof datum is not invented here. `axiom-modular-kit` already fixes + * the ring beam at 3.30 m and ships a roof/floor edge module, so a roof deck's + * underside is a known height and its edge already has a section. What was + * missing is the deck itself and the things that stand on it. + * + * Every module in the group is authored about the deck's *top* surface at y=0, + * with the structure hanging below into negative Y. That is deliberate: a roof + * fitting is positioned by where it sits, not by how thick the deck under it + * happens to be, so a machinery pad dropped onto a thicker deck later does not + * need re-authoring. + */ + +export const ROOF = Object.freeze({ + /** Structural bay, shared with the wall and shell grid. */ + grid: AXIOM_KIT.grid, + increment: AXIOM_KIT.structuralIncrement, + /** Deck slab thickness, matching the kit's floor slab. */ + deck: KIT_BUILD.floorThickness ?? 0.24, + /** Height of the ring beam this deck lands on, for assembly reference. */ + beamTop: KIT_BUILD.beamTop, + /** Standard parapet height above the deck surface. */ + parapet: 1, + /** Deck falls this much per metre toward its outlets. */ + fall: 0.02, + front: '+Z', + pivot: 'deck-centre', +} as const) + +export interface RoofEnvelope { + readonly width: number + readonly depth: number + readonly height: number +} + +export interface RoofModel { + readonly root: Group + readonly parts: Record + update(deltaSeconds: number): void + dispose(): void +} + +export interface RoofBuild { + readonly id: string + readonly envelope: RoofEnvelope + build(context: { + root: Group + m: KitMaterials + part(name: string): Group + /** A chamfered block in module space; the group's only primitive. */ + block(parent: Group, material: MeshPhysicalMaterial, size: Vec3, position: Vec3, rotation?: Vec3): Mesh + }): { readonly tick?: (elapsed: number) => void } | void +} + +const PROFILES = (m: KitMaterials): Map => new Map([ + // A roof is the one surface nobody maintains for looks, so its grime tier runs + // above the rest of the kit's while its rub stays low — nothing rubs a roof. + [m.shell, { rub: 0.008, grime: 0.05, scratch: 0 }], + [m.porcelain, { rub: 0.006, grime: 0.04, scratch: 0 }], + [m.graphite, { rub: 0.012, grime: 0.06, scratch: 0.002 }], + [m.deck, { rub: 0.014, grime: 0.09, scratch: 0.004 }], + [m.ink, { rub: 0.01, grime: 0.11, scratch: 0.004 }], +]) + +export function createRoofModel(spec: RoofBuild): RoofModel { + const { materials, handles } = createKitMaterials(8_400 + spec.id.length * 191) + const root = new Group() + root.name = `AXR_ARCH_${spec.id.toUpperCase()}_ROOT_DEFAULT` + + const parts: Record = {} + const part = (name: string): Group => { + const existing = parts[name] + if (existing) return existing + const group = new Group() + group.name = `AXR_ARCH_${spec.id.toUpperCase()}_PART_${name.toUpperCase()}_DEFAULT` + parts[name] = group + root.add(group) + return group + } + + const block = (parent: Group, material: MeshPhysicalMaterial, size: Vec3, position: Vec3, rotation?: Vec3): Mesh => { + const smallest = Math.min(...size) + const mesh = prism(material, size, position, { + chamfer: Math.min(0.06, smallest * 0.24), + fillet: Math.min(0.018, smallest * 0.1), + bevel: Math.min(0.016, smallest * 0.13), + ...(rotation ? { rotation } : {}), + }) + parent.add(mesh) + return mesh + } + + const built = spec.build({ root, m: materials, part, block }) + + root.userData.roofKit = { + version: 1, + moduleId: spec.id, + pivot: ROOF.pivot, + front: ROOF.front, + envelope: spec.envelope, + grid: ROOF.grid, + deck: ROOF.deck, + } + + bakeOcclusion(root, { reach: 0.2 }) + const profiles = PROFILES(materials) + bakeSurfaceAttributes(root, profiles) + const wearMaterial = createWearMaterial({ + name: `${spec.id} / weathered roof surfaces`, + clearcoat: 0.06, + clearcoatRoughness: 0.62, + }) + const worn = new Set(profiles.keys()) + const geometries: BufferGeometry[] = [...mergeStaticByMaterial(root, { + resolveMaterial: (source: MeshPhysicalMaterial) => worn.has(source) ? wearMaterial : source, + retainedAttributes: (resolved: unknown) => resolved === wearMaterial ? WEAR_ATTRIBUTES : [], + meshName: (material: { name?: string }) => `${spec.id} / ${material.name || 'batch'}`, + } as Parameters[1])] + + let elapsed = 0 + return { + root, + parts, + update: (deltaSeconds: number) => { + elapsed += Math.min(Math.max(deltaSeconds, 0), 0.05) + built?.tick?.(elapsed) + }, + dispose: () => { + for (const geometry of geometries) geometry.dispose() + wearMaterial.dispose() + for (const handle of handles) handle.release() + root.traverse((object) => { + if (object instanceof Mesh) object.geometry.dispose() + }) + }, + } +} + +export interface RoofPreview { + readonly scene: Scene + readonly root: Group + readonly camera: PerspectiveCamera + update(deltaSeconds: number): void + dispose(): void +} + +/** + * The group's shared capture rig, matching the modular kit's lighting exactly, + * but pitched down: a roof piece read from eye level is a silhouette, and the + * whole point of these modules is what is on the deck. + */ +export function createRoofPreview( + model: RoofModel, + envelope: RoofEnvelope, + options: { aspect?: number; distance?: number; yaw?: number; pitch?: number } = {}, +): RoofPreview { + const scene = new Scene() + scene.background = new Color(0x04070a) + scene.add(model.root) + scene.add(new HemisphereLight(0xa8bbc5, 0x07090c, 0.86)) + const key = new DirectionalLight(0xffedd6, 2.5) + key.position.set(-7, 10, 11) + scene.add(key) + const fill = new DirectionalLight(0x758bd0, 0.9) + fill.position.set(9, 5, 6) + scene.add(fill) + const rim = new DirectionalLight(0x82aabd, 0.72) + rim.position.set(6, 8, -9) + scene.add(rim) + + const span = Math.max(envelope.width, envelope.depth, envelope.height) + const target = new Vector3(0, envelope.height * 0.28 - ROOF.deck * 0.5, 0) + const distance = options.distance ?? span * 1.5 + 2 + const yaw = options.yaw ?? -0.66 + const pitch = options.pitch ?? 0.46 + const aspect = Number.isFinite(options.aspect) && (options.aspect ?? 0) > 0 ? options.aspect! : 1 + + const camera = new PerspectiveCamera(34, aspect, 0.08, 200) + camera.position.set( + target.x + Math.sin(yaw) * Math.cos(pitch) * distance, + target.y + Math.sin(pitch) * distance, + target.z + Math.cos(yaw) * Math.cos(pitch) * distance, + ) + camera.lookAt(target) + scene.add(camera) + + return { + scene, + root: model.root, + camera, + update: (deltaSeconds: number) => model.update(deltaSeconds), + dispose: () => { + scene.remove(model.root) + model.dispose() + }, + } +} + +export { type KitMaterials } from '../axiom-modular-kit/parts.ts' diff --git a/assets/prototypes/flat-roof/model.ts b/assets/prototypes/flat-roof/model.ts new file mode 100644 index 00000000..3fe181cf --- /dev/null +++ b/assets/prototypes/flat-roof/model.ts @@ -0,0 +1,47 @@ +import { createRoofModel, createRoofPreview, ROOF, type RoofModel, type RoofPreview } from '../axiom-roof-kit/index.ts' +import { block, deck, outlet, parapet } from '../axiom-roof-kit/deck.ts' + +/** + * Axiom Relay flat roof — one bay of finished deck. + * + * The group's reference piece: a 4 m structural bay of slab, membrane, bay + * joints, a parapet on all four sides, and the outlet the whole surface falls + * toward. Everything else in the roof group either stands on this or replaces + * part of it. + * + * "Flat" is a trade term, not a geometric one. The deck falls toward its outlet, + * and the fall is the reason the outlet is believable — a genuinely level roof + * with a drain in it is a roof with a puddle in it. + */ + +const SIZE = ROOF.grid +const ENVELOPE = { width: SIZE, depth: SIZE, height: ROOF.parapet + ROOF.deck + 0.1 } + +export function createModel(): RoofModel { + return createRoofModel({ + id: 'flat-roof', + envelope: ENVELOPE, + build: ({ m, part }) => { + const roof = part('deck') + deck(roof, m, SIZE, SIZE) + parapet(roof, m, SIZE, SIZE) + + // Fall: shallow wedges laid toward the outlet corner, so the surface + // slopes rather than the slab being modelled as a tilted plate. + for (let index = 0; index < 4; index += 1) { + const inset = 0.4 + index * 0.42 + block(roof, m.deck, [SIZE - inset, 0.014 * (4 - index), SIZE - inset], [0.18, 0.007 * (4 - index), 0.18]) + } + outlet(roof, m, [-SIZE / 2 + 0.62, 0, -SIZE / 2 + 0.62]) + }, + }) +} + +/** + * Pitched steeper than the group default. A 1 m parapet around a 4 m bay hides + * the deck completely from a normal three-quarter angle, and the deck — its + * fall, its joints, its outlet — is the entire module. + */ +export function createPreview(options: { aspect?: number } = {}): RoofPreview { + return createRoofPreview(createModel(), ENVELOPE, { distance: 9.6, pitch: 0.72, ...options }) +} diff --git a/assets/prototypes/roof-access-box/model.ts b/assets/prototypes/roof-access-box/model.ts new file mode 100644 index 00000000..1a356e8e --- /dev/null +++ b/assets/prototypes/roof-access-box/model.ts @@ -0,0 +1,67 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { createRoofModel, createRoofPreview, type RoofModel, type RoofPreview } from '../axiom-roof-kit/index.ts' +import { block, curb } from '../axiom-roof-kit/deck.ts' + +/** + * Axiom Relay roof access box — the stair head. + * + * The way onto a roof, and the reason a roof has a door at all. It is a small + * building in its own right: a curb, a shell, a single outward-opening leaf, and + * a canopy over it — outward because a door that opens inward at the top of a + * stair opens onto the stair. + * + * The grab rail beside the door and the tread plate in front of it are the parts + * that give the module its scale. Without them it is a box; with them it is + * obviously something a person steps out of. + */ + +const W = 1.6 +const D = 1.9 +const H = 2.35 +const ENVELOPE = { width: W + 0.5, depth: D + 0.6, height: H + 0.28 } + +export function createModel(): RoofModel { + return createRoofModel({ + id: 'roof-access-box', + envelope: ENVELOPE, + build: ({ m, part }) => { + const box = part('box') + curb(box, m, W + 0.3, D + 0.3, 0.3) + + // Shell: shoulders in above the curb, so the curb reads as what carries it. + block(box, m.shell, [W, H - 0.32, D], [0, 0.32 + (H - 0.32) / 2, 0]) + block(box, m.graphite, [W + 0.1, 0.12, D + 0.1], [0, H - 0.06, 0]) + // Slight fall on the lid, draining to the back. + block(box, m.deck, [W - 0.06, 0.05, D - 0.06], [0, H + 0.03, -0.02], [0.03, 0, 0]) + + // Door: a recessed leaf in the front face, with its own frame and threshold. + block(box, m.ink, [0.96, 2.02, 0.09], [0, 1.31, D / 2 - 0.02]) + block(box, m.porcelain, [0.86, 1.94, 0.06], [0, 1.31, D / 2 + 0.015]) + for (const side of [-1, 1] as const) block(box, m.graphite, [0.05, 1.98, 0.1], [side * 0.46, 1.31, D / 2 + 0.01]) + block(box, m.steel, [0.05, 0.4, 0.06], [0.3, 1.24, D / 2 + 0.055]) + block(box, m.cyan, [0.22, 0.03, 0.02], [0, 2.32, D / 2 + 0.05]) + // Tread plate on the deck in front of the door. + block(box, m.steel, [1.1, 0.03, 0.5], [0, 0.02, D / 2 + 0.32]) + + // Canopy over the door, on two brackets. + block(box, m.graphite, [W + 0.24, 0.07, 0.44], [0, H - 0.42, D / 2 + 0.18]) + for (const side of [-1, 1] as const) { + block(box, m.steel, [0.05, 0.3, 0.3], [side * (W / 2 - 0.14), H - 0.6, D / 2 + 0.1], [-0.78, 0, 0]) + } + + // Grab rail beside the door, and the vent that keeps the stair dry. + box.add(cylinder(m.steel, 0.024, 1.1, [0.62, 1.3, D / 2 + 0.04], [0, 0, 0], 8)) + for (const y of [0.78, 1.82]) { + box.add(cylinder(m.steel, 0.018, 0.14, [0.62, y, D / 2 - 0.02], [Math.PI / 2, 0, 0], 8)) + } + block(box, m.ink, [0.5, 0.34, 0.05], [0, 1.9, -D / 2 - 0.01]) + for (let index = 0; index < 4; index += 1) { + block(box, m.steel, [0.46, 0.05, 0.04], [0, 1.78 + index * 0.08, -D / 2 - 0.03], [-0.5, 0, 0]) + } + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): RoofPreview { + return createRoofPreview(createModel(), ENVELOPE, { distance: 7.4, pitch: 0.24, yaw: -0.72, ...options }) +} diff --git a/assets/prototypes/roof-antenna-mount/model.ts b/assets/prototypes/roof-antenna-mount/model.ts new file mode 100644 index 00000000..a6594610 --- /dev/null +++ b/assets/prototypes/roof-antenna-mount/model.ts @@ -0,0 +1,82 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { createRoofModel, createRoofPreview, type RoofModel, type RoofPreview } from '../axiom-roof-kit/index.ts' +import { block, curb, deck } from '../axiom-roof-kit/deck.ts' + +/** + * Axiom Relay roof antenna mount — the ballasted mast base. + * + * A mast on a roof is a lever: wind load at the top is resisted at the bottom, + * and the bottom is a waterproof membrane that must not be drilled. So this is + * the same counterweight logic as the rooftop railing, scaled up — a ballast + * frame on rubber pads, a stub mast in a hinged base plate so it can be lowered + * for service, and three guys taken back to their own ballast blocks. + * + * The hinge is the detail worth keeping. A mast that cannot be lowered has to be + * serviced at height, and nothing else on this roof needs a crane. + */ + +const ENVELOPE = { width: 2.6, depth: 2.6, height: 3.2 } + +export function createModel(): RoofModel { + return createRoofModel({ + id: 'roof-antenna-mount', + envelope: ENVELOPE, + build: ({ m, part }) => { + const mount = part('mount') + deck(mount, m, 2.6, 2.6) + + // Ballast frame on pads, with the blocks that make it heavy. + block(mount, m.ink, [1.5, 0.02, 1.5], [0, 0.01, 0]) + block(mount, m.graphite, [1.4, 0.14, 1.4], [0, 0.09, 0]) + for (const [sx, sz] of [[1, 1], [1, -1], [-1, 1], [-1, -1]] as const) { + block(mount, m.deck, [0.5, 0.16, 0.5], [sx * 0.42, 0.24, sz * 0.42]) + block(mount, m.steel, [0.06, 0.05, 0.44], [sx * 0.42, 0.34, sz * 0.42]) + } + + // Hinged base plate and the stub mast standing in it. + block(mount, m.graphite, [0.34, 0.1, 0.4], [0, 0.21, 0]) + for (const side of [-1, 1] as const) { + block(mount, m.steel, [0.03, 0.22, 0.18], [side * 0.13, 0.36, 0]) + } + mount.add(cylinder(m.steel, 0.026, 0.3, [0, 0.34, 0], [0, 0, Math.PI / 2], 10)) + mount.add(cylinder(m.graphite, 0.07, 2.5, [0, 0.34 + 1.25, 0], [0, 0, 0], 12)) + mount.add(cylinder(m.steel, 0.045, 0.5, [0, 0.34 + 2.65, 0], [0, 0, 0], 10)) + + // Guy collar and three stays down to their own ballast blocks. + // + // Each stay is oriented from the geometry rather than eyeballed: a + // cylinder's own axis is +Y, so tilting it by the stay's angle from + // vertical and yawing that tilt to the block's bearing lands both ends + // exactly on the collar and the block. Guessed Euler triples put stays + // near their anchors, which reads as a mast held up by nothing. + const COLLAR_Y = 2.1 + const BLOCK_Y = 0.16 + const RADIUS = 0.95 + const drop = COLLAR_Y - BLOCK_Y + const tilt = Math.atan2(RADIUS, drop) + const stay = Math.hypot(RADIUS, drop) + mount.add(cylinder(m.steel, 0.09, 0.07, [0, COLLAR_Y, 0], [0, 0, 0], 12)) + for (let index = 0; index < 3; index += 1) { + const bearing = (index / 3) * Math.PI * 2 + const x = Math.cos(bearing) * RADIUS + const z = Math.sin(bearing) * RADIUS + block(mount, m.deck, [0.3, 0.16, 0.3], [x, 0.08, z]) + block(mount, m.steel, [0.1, 0.05, 0.1], [x, 0.17, z]) + mount.add(cylinder(m.steel, 0.014, stay, [x / 2, (COLLAR_Y + BLOCK_Y) / 2, z / 2], [ + 0, + Math.PI - bearing, + tilt, + ], 6)) + } + + // Cable tray leaving the base, and its gland into the deck. + block(mount, m.graphite, [0.22, 0.07, 0.9], [0.5, 0.28, -0.85]) + block(mount, m.ink, [0.18, 0.03, 0.86], [0.5, 0.32, -0.85]) + curb(mount, m, 0.3, 0.28, 0.2, [0.5, 0, -1.2]) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): RoofPreview { + return createRoofPreview(createModel(), ENVELOPE, { distance: 8.6, pitch: 0.22, ...options }) +} diff --git a/assets/prototypes/roof-machinery-pad/model.ts b/assets/prototypes/roof-machinery-pad/model.ts new file mode 100644 index 00000000..9d0abc3a --- /dev/null +++ b/assets/prototypes/roof-machinery-pad/model.ts @@ -0,0 +1,61 @@ +import { createRoofModel, createRoofPreview, ROOF, type RoofModel, type RoofPreview } from '../axiom-roof-kit/index.ts' +import { block, curb, deck } from '../axiom-roof-kit/deck.ts' + +/** + * Axiom Relay roof machinery pad — the mounting field. + * + * Not a plant item: the *ground* a plant item stands on. Rooftop equipment is + * never fixed to the deck itself, it stands on rails carried by curbs, with the + * gap under it kept clear so the membrane can be inspected and replaced without + * moving the machine. This module is exactly that arrangement and nothing else, + * so any piece of plant in the library can be dropped onto it. + * + * The anti-vibration mounts on top of the rails are what make it read as a + * machine base rather than as a low wall. Plant that is bolted rigid to a + * building transmits everything it does into the building. + */ + +const W = 3.2 +const D = 2.4 +const ENVELOPE = { width: W + 0.6, depth: D + 0.6, height: 0.62 } + +export function createModel(): RoofModel { + return createRoofModel({ + id: 'roof-machinery-pad', + envelope: ENVELOPE, + build: ({ m, part }) => { + const pad = part('pad') + deck(pad, m, W + 0.6, D + 0.6) + + // Two curbs running the short way, so condensate can cross under the plant. + for (const side of [-1, 1] as const) { + curb(pad, m, 0.36, D, 0.3, [side * (W / 2 - 0.3), 0, 0]) + // Bearing rail on top of the curb, and the anti-vibration mounts on it. + block(pad, m.steel, [0.28, 0.06, D - 0.1], [side * (W / 2 - 0.3), 0.38, 0]) + for (let index = 0; index < 3; index += 1) { + const z = -D / 2 + 0.4 + index * ((D - 0.8) / 2) + block(pad, m.ink, [0.2, 0.07, 0.2], [side * (W / 2 - 0.3), 0.44, z]) + block(pad, m.graphite, [0.24, 0.05, 0.24], [side * (W / 2 - 0.3), 0.5, z]) + } + } + + // Service walkway between the curbs: the clear route the layout exists for. + block(pad, m.deck, [W - 0.9, 0.02, D - 0.4], [0, 0.011, 0]) + for (let index = 0; index < 5; index += 1) { + block(pad, m.ink, [0.03, 0.016, D - 0.5], [-W / 2 + 0.7 + index * ((W - 1.4) / 4), 0.02, 0]) + } + // Hazard edging where the walkway meets the curbs. + for (const side of [-1, 1] as const) { + block(pad, m.amber, [0.05, 0.014, D - 0.4], [side * (W / 2 - 0.52), 0.022, 0]) + } + // Service isolator on its own small curb at the pad's edge. + curb(pad, m, 0.34, 0.28, 0.22, [0, 0, -D / 2 - 0.1]) + block(pad, m.graphite, [0.28, 0.36, 0.2], [0, 0.42, -D / 2 - 0.1]) + block(pad, m.cyan, [0.1, 0.03, 0.02], [0, 0.52, -D / 2 - 0.21]) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): RoofPreview { + return createRoofPreview(createModel(), ENVELOPE, { distance: 7.6, pitch: 0.42, ...options }) +} diff --git a/assets/prototypes/roof-parapet/model.ts b/assets/prototypes/roof-parapet/model.ts new file mode 100644 index 00000000..4b629f35 --- /dev/null +++ b/assets/prototypes/roof-parapet/model.ts @@ -0,0 +1,47 @@ +import { createRoofModel, createRoofPreview, ROOF, type RoofModel, type RoofPreview } from '../axiom-roof-kit/index.ts' +import { block, parapet } from '../axiom-roof-kit/deck.ts' + +/** + * Axiom Relay roof parapet — the edge upstand as a loose 4 m run. + * + * Shipped separately from the flat roof because a parapet is what a level + * builder actually places: decks get generated to fit a footprint, but the edge + * has to be walked around a plan that is rarely a single bay. This is one 4 m + * length of it, with its coping, its membrane turn-up, and the cap-flashing + * joint at each end that lets two runs meet without a visible seam. + * + * It carries no deck. A parapet module that brings its own slab cannot be placed + * on a deck that already exists, which is the only place a parapet ever goes. + */ + +const LENGTH = ROOF.grid +const HEIGHT = ROOF.parapet +const ENVELOPE = { width: LENGTH, depth: 0.38, height: HEIGHT + 0.09 } + +export function createModel(): RoofModel { + return createRoofModel({ + id: 'roof-parapet', + envelope: ENVELOPE, + build: ({ m, part }) => { + const run = part('parapet') + parapet(run, m, LENGTH, 0.18 * 2, HEIGHT, ['front']) + + // Cap-flashing joints at both ends: a lapped plate over the coping, so two + // runs butt into a covered joint instead of an open butt. + for (const side of [-1, 1] as const) { + block(run, m.steel, [0.16, 0.02, 0.34], [side * (LENGTH / 2 - 0.08), HEIGHT + 0.1, 0.09]) + } + // Counter-flashing along the outboard face, and the drip under the coping. + block(run, m.steel, [LENGTH - 0.06, 0.05, 0.02], [0, HEIGHT - 0.14, 0.19]) + block(run, m.ink, [LENGTH + 0.06, 0.03, 0.03], [0, HEIGHT - 0.02, 0.21]) + // Restraint straps back into the deck, one per 2 m. + for (const side of [-1, 1] as const) { + block(run, m.steel, [0.06, 0.3, 0.16], [side * LENGTH * 0.25, 0.15, -0.06], [0.5, 0, 0]) + } + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): RoofPreview { + return createRoofPreview(createModel(), ENVELOPE, { distance: 6.6, pitch: 0.3, ...options }) +} diff --git a/assets/prototypes/roof-skylight/model.ts b/assets/prototypes/roof-skylight/model.ts new file mode 100644 index 00000000..5ef26e75 --- /dev/null +++ b/assets/prototypes/roof-skylight/model.ts @@ -0,0 +1,70 @@ +import { createRoofModel, createRoofPreview, type RoofModel, type RoofPreview } from '../axiom-roof-kit/index.ts' +import { block, curb, deck } from '../axiom-roof-kit/deck.ts' + +/** + * Axiom Relay roof skylight — a glazed opening in the deck. + * + * Everything about it follows from the fact that it is a hole in the one surface + * keeping weather out. It sits on a tall curb — taller than the group's default, + * because a skylight is the penetration most likely to be sitting in standing + * water. The glazing is raked so it sheds rather than pools. A fall-arrest grille + * sits under the glass, since a person stepping on a rooflight in the dark is the + * classic roof accident. And the light well below the deck is lined, so looking + * into it from above bottoms out in a real space instead of in the deck's own + * back faces. + */ + +const OPENING = 1.6 +const CURB_H = 0.42 +const ENVELOPE = { width: 2.8, depth: 2.8, height: CURB_H + 0.5 } + +export function createModel(): RoofModel { + return createRoofModel({ + id: 'roof-skylight', + envelope: ENVELOPE, + build: ({ m, part }) => { + const light = part('skylight') + // The deck around the opening, drawn as four bands so the hole is real. + const outer = 2.8 + const half = OPENING / 2 + 0.28 + for (const [sx, sz] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { + const w = sx !== 0 ? (outer / 2 - half) : outer + const d = sz !== 0 ? (outer / 2 - half) : half * 2 + block(light, m.graphite, [w, 0.24, d], [sx * (outer / 2 - w / 2), -0.12, sz * (outer / 2 - d / 2)]) + block(light, m.deck, [w - 0.02, 0.012, d - 0.02], [sx * (outer / 2 - w / 2), -0.006, sz * (outer / 2 - d / 2)]) + } + + curb(light, m, OPENING + 0.34, OPENING + 0.34, CURB_H) + // Lined light well below the deck. + for (const [sx, sz] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { + const w = sx !== 0 ? 0.04 : OPENING + const d = sz !== 0 ? 0.04 : OPENING + block(light, m.ink, [w, 0.7, d], [sx * OPENING / 2, -0.6, sz * OPENING / 2]) + } + block(light, m.ink, [OPENING, 0.02, OPENING], [0, -0.94, 0]) + + // Fall-arrest grille inside the curb, under the glass. + for (let index = 0; index < 6; index += 1) { + const t = -OPENING / 2 + 0.12 + index * ((OPENING - 0.24) / 5) + block(light, m.steel, [0.03, 0.03, OPENING - 0.06], [t, CURB_H - 0.06, 0]) + block(light, m.steel, [OPENING - 0.06, 0.03, 0.03], [0, CURB_H - 0.06, t]) + } + + // Raked glazing on its frame: a low pyramid rather than a flat pane. + const rake = 0.18 + for (const [sx, sz] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { + const w = sx !== 0 ? OPENING * 0.52 : OPENING + 0.1 + const d = sz !== 0 ? OPENING * 0.52 : OPENING + 0.1 + block(light, m.ink, [w, 0.03, d], [sx * OPENING * 0.24, CURB_H + 0.1 + rake * 0.5, sz * OPENING * 0.24], + [sz !== 0 ? -sz * 0.34 : 0, 0, sx !== 0 ? sx * 0.34 : 0]) + } + block(light, m.graphite, [OPENING + 0.24, 0.07, OPENING + 0.24], [0, CURB_H + 0.06, 0]) + block(light, m.steel, [0.1, 0.09, OPENING + 0.2], [0, CURB_H + 0.3, 0]) + block(light, m.steel, [OPENING + 0.2, 0.09, 0.1], [0, CURB_H + 0.3, 0]) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): RoofPreview { + return createRoofPreview(createModel(), ENVELOPE, { distance: 6.4, pitch: 0.44, ...options }) +} diff --git a/assets/prototypes/rooftop-hvac-foundation/model.ts b/assets/prototypes/rooftop-hvac-foundation/model.ts new file mode 100644 index 00000000..5548af90 --- /dev/null +++ b/assets/prototypes/rooftop-hvac-foundation/model.ts @@ -0,0 +1,66 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { createRoofModel, createRoofPreview, type RoofModel, type RoofPreview } from '../axiom-roof-kit/index.ts' +import { block, curb, deck } from '../axiom-roof-kit/deck.ts' + +/** + * Axiom Relay rooftop HVAC foundation — the serviced plant base. + * + * Where `roof-machinery-pad` is bare structure, this is the base with its + * services already brought up: a flow and return pair, a condensate drain, a + * power gland, and the isolator they all land on. Those five things are what + * turn a place to stand a machine into a place a machine can actually run, and + * they are also what a level builder cannot easily add afterwards, because each + * one is a penetration through the deck and every penetration needs its own + * sealed sleeve. + * + * The pipes rise on a stand rather than turning straight down into the deck. + * A pipe entering a roof vertically has nowhere for its sleeve to shed water. + */ + +const W = 2.6 +const D = 2 +const ENVELOPE = { width: W + 0.8, depth: D + 0.8, height: 1.5 } + +export function createModel(): RoofModel { + return createRoofModel({ + id: 'rooftop-hvac-foundation', + envelope: ENVELOPE, + build: ({ m, part }) => { + const base = part('foundation') + deck(base, m, W + 0.8, D + 0.8) + curb(base, m, W, D, 0.34) + block(base, m.deck, [W - 0.12, 0.05, D - 0.12], [0, 0.4, 0]) + + // Bearer rails and anti-vibration mounts across the curb. + for (const side of [-1, 1] as const) { + block(base, m.steel, [W - 0.3, 0.06, 0.2], [0, 0.45, side * (D / 2 - 0.28)]) + for (let index = 0; index < 3; index += 1) { + const x = -W / 2 + 0.4 + index * ((W - 0.8) / 2) + block(base, m.ink, [0.18, 0.07, 0.18], [x, 0.51, side * (D / 2 - 0.28)]) + } + } + + // Pipe stand: flow and return rising to a machine's connection height. + const stand = W / 2 + 0.24 + curb(base, m, 0.4, 0.5, 0.24, [stand, 0, -0.3]) + for (const [dz, material] of [[-0.12, m.graphite], [0.12, m.steel]] as const) { + base.add(cylinder(material, 0.055, 0.86, [stand, 0.24 + 0.43, -0.3 + dz], [0, 0, 0], 12)) + base.add(cylinder(material, 0.055, 0.34, [stand - 0.17, 1.1, -0.3 + dz], [0, 0, Math.PI / 2], 12)) + block(base, m.ink, [0.13, 0.06, 0.13], [stand, 0.3, -0.3 + dz]) + } + // Condensate drain: a trapped fall back to the deck outlet. + base.add(cylinder(m.ink, 0.03, 0.5, [stand - 0.14, 0.28, -0.3 + 0.3], [0, 0, 0.9], 8)) + + // Power gland and the isolator it feeds. + curb(base, m, 0.34, 0.3, 0.22, [-stand, 0, 0.4]) + block(base, m.graphite, [0.3, 0.42, 0.22], [-stand, 0.43, 0.4]) + block(base, m.steel, [0.1, 0.12, 0.06], [-stand, 0.7, 0.52]) + block(base, m.cyan, [0.12, 0.03, 0.02], [-stand, 0.56, 0.52]) + base.add(cylinder(m.ink, 0.045, 0.4, [-stand + 0.02, 0.36, 0.28], [0.7, 0, 0], 10)) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): RoofPreview { + return createRoofPreview(createModel(), ENVELOPE, { distance: 7.2, pitch: 0.34, ...options }) +} diff --git a/assets/prototypes/rooftop-railing/model.ts b/assets/prototypes/rooftop-railing/model.ts new file mode 100644 index 00000000..4fc8db6e --- /dev/null +++ b/assets/prototypes/rooftop-railing/model.ts @@ -0,0 +1,55 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Z } from '../axiom-cargo-kit/index.ts' +import { createRoofModel, createRoofPreview, ROOF, type RoofModel, type RoofPreview } from '../axiom-roof-kit/index.ts' +import { block } from '../axiom-roof-kit/deck.ts' + +/** + * Axiom Relay rooftop railing — free-standing fall protection. + * + * Counterweighted rather than bolted, which is the entire design. Drilling a + * roof to fix a handrail puts a hundred penetrations through the one layer + * keeping water out of the building, so real roof railings stand on weighted + * feet on rubber pads and rely on mass. That is why this module has no fixings, + * why the feet are as heavy as they are, and why it can be placed anywhere on a + * deck without the deck needing to know. + */ + +const LENGTH = ROOF.grid +const HEIGHT = 1.1 +const POSTS = 3 +const ENVELOPE = { width: LENGTH, depth: 0.9, height: HEIGHT + 0.06 } + +export function createModel(): RoofModel { + return createRoofModel({ + id: 'rooftop-railing', + envelope: ENVELOPE, + build: ({ m, part }) => { + const rail = part('railing') + + for (let index = 0; index < POSTS; index += 1) { + const x = -LENGTH / 2 + (index / (POSTS - 1)) * LENGTH + // Counterweight foot on its rubber pad, and the raking strut that stops + // the post folding over under load. + block(rail, m.ink, [0.44, 0.02, 0.72], [x, 0.01, 0]) + block(rail, m.graphite, [0.4, 0.11, 0.66], [x, 0.075, 0]) + block(rail, m.deck, [0.3, 0.06, 0.5], [x, 0.16, 0]) + rail.add(cylinder(m.steel, 0.032, HEIGHT - 0.19, [x, 0.19 + (HEIGHT - 0.19) / 2, 0.22], [0, 0, 0], 10)) + rail.add(cylinder(m.steel, 0.022, 0.62, [x, 0.5, -0.02], [0.62, 0, 0], 8)) + } + + // Top rail, knee rail, and the toe board at deck level. + for (const y of [HEIGHT, HEIGHT * 0.58]) { + rail.add(cylinder(m.steel, 0.028, LENGTH + 0.16, [0, y, 0.22], AXIS_X, 10)) + } + block(rail, m.graphite, [LENGTH + 0.1, 0.14, 0.03], [0, 0.28, 0.29]) + // End returns, so the run does not terminate in an open pipe. + for (const side of [-1, 1] as const) { + rail.add(cylinder(m.steel, 0.028, 0.42, [side * (LENGTH / 2 + 0.08), HEIGHT, 0.02], AXIS_Z, 10)) + } + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): RoofPreview { + return createRoofPreview(createModel(), ENVELOPE, { distance: 7.2, pitch: 0.26, ...options }) +} diff --git a/assets/prototypes/sloped-sci-fi-roof/model.ts b/assets/prototypes/sloped-sci-fi-roof/model.ts new file mode 100644 index 00000000..2ff40467 --- /dev/null +++ b/assets/prototypes/sloped-sci-fi-roof/model.ts @@ -0,0 +1,74 @@ +import { createRoofModel, createRoofPreview, ROOF, type RoofModel, type RoofPreview } from '../axiom-roof-kit/index.ts' +import { block, deck } from '../axiom-roof-kit/deck.ts' + +/** + * Axiom Relay sloped roof — the shed bay. + * + * The kit's only pitched deck, and it is a single-direction shed rather than a + * ridge, because a ridge implies a gable and the Axiom shell has no gable to + * give it. A shed slope reads as *drainage* on a flat-roofed building — which is + * what it is — rather than as a house roof pasted onto a facility. + * + * It is built as stepped bays on a raised frame, not as a tilted slab. The + * cassettes are flat pressings in this system; a genuinely raked plate would be + * a second fabrication method the kit does not have, and the steps are what let + * each pressing stay flat while the surface as a whole falls. + */ + +const SIZE = ROOF.grid +const RISE = 0.9 +const STEPS = 6 +const ENVELOPE = { width: SIZE, depth: SIZE, height: RISE + 0.4 } + +export function createModel(): RoofModel { + return createRoofModel({ + id: 'sloped-sci-fi-roof', + envelope: ENVELOPE, + build: ({ m, part }) => { + const roof = part('roof') + deck(roof, m, SIZE, SIZE) + + // Raking frame: the trusses the stepped panels sit on. + for (const sx of [-1, 0, 1] as const) { + block(roof, m.graphite, [0.11, 0.14, SIZE - 0.1], [sx * (SIZE / 2 - 0.3), RISE * 0.5, 0], [-Math.atan2(RISE, SIZE), 0, 0]) + } + for (let index = 0; index <= STEPS; index += 1) { + const z = -SIZE / 2 + (index / STEPS) * SIZE + const y = (index / STEPS) * RISE + block(roof, m.graphite, [SIZE - 0.2, 0.1, 0.1], [0, Math.max(0.02, y - 0.03), z]) + } + + // Stepped panels: each flat, each lapped over the one below it. + // + // The run starts at the deck rather than at half a step above it. Lifted + // clear the whole slope floated over its own deck with daylight under the + // eaves, which read as two separate objects rather than as a roof. + for (let index = 0; index < STEPS; index += 1) { + const z = -SIZE / 2 + ((index + 0.5) / STEPS) * SIZE + const y = (index / STEPS) * RISE + block(roof, m.shell, [SIZE - 0.26, 0.06, SIZE / STEPS + 0.06], [0, y + 0.09, z]) + // The lap: a raised standing seam at every step, which is where the + // water crosses from one panel to the next. + block(roof, m.steel, [SIZE - 0.24, 0.05, 0.05], [0, y + 0.14, z + SIZE / STEPS * 0.5]) + // Riser closing the step's open end, so each lap is a solid face. + block(roof, m.graphite, [SIZE - 0.26, RISE / STEPS + 0.04, 0.05], [0, y + 0.09 + RISE / STEPS * 0.5, z + SIZE / STEPS * 0.5]) + } + + // Verge trims down both rakes, and the eaves gutter at the low end. + for (const sx of [-1, 1] as const) { + block(roof, m.graphite, [0.12, 0.16, SIZE + 0.1], [sx * (SIZE / 2 - 0.05), RISE * 0.5 + 0.12, 0], [-Math.atan2(RISE, SIZE), 0, 0]) + } + block(roof, m.graphite, [SIZE + 0.1, 0.16, 0.24], [0, 0.06, -SIZE / 2 - 0.06]) + block(roof, m.ink, [SIZE - 0.06, 0.08, 0.16], [0, 0.11, -SIZE / 2 - 0.06]) + // Ridge closure at the high end, capping the top step. + block(roof, m.graphite, [SIZE + 0.06, 0.2, 0.2], [0, RISE + 0.12, SIZE / 2 - 0.02]) + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): RoofPreview { + // Pitched well above the group default: a 0.9 m rise across a 4 m bay is a + // shallow slope, and from a low angle the module reads as a flat plate seen + // edge-on rather than as a fall. + return createRoofPreview(createModel(), ENVELOPE, { distance: 11.2, pitch: 0.5, yaw: -0.8, ...options }) +} diff --git a/assets/prototypes/vented-roof-panel/model.ts b/assets/prototypes/vented-roof-panel/model.ts new file mode 100644 index 00000000..444d8d75 --- /dev/null +++ b/assets/prototypes/vented-roof-panel/model.ts @@ -0,0 +1,71 @@ +import { createRoofModel, createRoofPreview, ROOF, type RoofModel, type RoofPreview } from '../axiom-roof-kit/index.ts' +import { block, curb, deck } from '../axiom-roof-kit/deck.ts' + +/** + * Axiom Relay vented roof panel — a bay of deck that breathes. + * + * The counterpart to the flat roof: same 4 m bay, same slab and membrane, but + * the middle of it is a weathered air path instead of a sealed surface. It is + * what sits over a plant room, a stair pressurisation shaft, or anything that + * has to move air without a machine standing on the roof to do it. + * + * The blades face *down and outward* on all four sides of a raised hood rather + * than lying flat in the deck. A louvre lying in a horizontal surface is a drain + * with a grille over it: rain lands straight in it. Raising the opening onto a + * hood and turning the blades to the side is the only arrangement that both + * passes air and sheds water. + */ + +const SIZE = ROOF.grid +const HOOD = 1.9 +const HOOD_H = 0.62 +const BLADES = 5 +const ENVELOPE = { width: SIZE, depth: SIZE, height: HOOD_H + 0.32 } + +export function createModel(): RoofModel { + return createRoofModel({ + id: 'vented-roof-panel', + envelope: ENVELOPE, + build: ({ m, part }) => { + const panel = part('panel') + deck(panel, m, SIZE, SIZE) + curb(panel, m, HOOD + 0.3, HOOD + 0.3, 0.3) + + // The shaft below, so the hood opens onto something. + for (const [sx, sz] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { + const w = sx !== 0 ? 0.04 : HOOD - 0.2 + const d = sz !== 0 ? 0.04 : HOOD - 0.2 + block(panel, m.ink, [w, 0.6, d], [sx * (HOOD - 0.2) / 2, -0.54, sz * (HOOD - 0.2) / 2]) + } + block(panel, m.ink, [HOOD - 0.2, 0.02, HOOD - 0.2], [0, -0.84, 0]) + + // Hood: corner posts, a lid, and blades on all four faces. + for (const [sx, sz] of [[1, 1], [1, -1], [-1, 1], [-1, -1]] as const) { + block(panel, m.graphite, [0.1, HOOD_H, 0.1], [sx * HOOD / 2, 0.3 + HOOD_H / 2, sz * HOOD / 2]) + } + block(panel, m.graphite, [HOOD + 0.26, 0.08, HOOD + 0.26], [0, 0.3 + HOOD_H + 0.04, 0]) + block(panel, m.deck, [HOOD + 0.16, 0.04, HOOD + 0.16], [0, 0.3 + HOOD_H + 0.1, 0]) + + for (let index = 0; index < BLADES; index += 1) { + const y = 0.36 + ((index + 0.5) / BLADES) * (HOOD_H - 0.12) + for (const [sx, sz] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { + const w = sx !== 0 ? 0.05 : HOOD - 0.12 + const d = sz !== 0 ? 0.05 : HOOD - 0.12 + block(panel, m.steel, [w, (HOOD_H - 0.12) / BLADES * 0.7, d], [ + sx * HOOD / 2, y, sz * HOOD / 2, + ], [sz !== 0 ? -sz * 0.55 : 0, 0, sx !== 0 ? sx * 0.55 : 0]) + } + } + // Bird mesh inside the blades: the dark plane they read against. + for (const [sx, sz] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { + const w = sx !== 0 ? 0.02 : HOOD - 0.2 + const d = sz !== 0 ? 0.02 : HOOD - 0.2 + block(panel, m.ink, [w, HOOD_H - 0.14, d], [sx * (HOOD / 2 - 0.09), 0.3 + HOOD_H / 2, sz * (HOOD / 2 - 0.09)]) + } + }, + }) +} + +export function createPreview(options: { aspect?: number } = {}): RoofPreview { + return createRoofPreview(createModel(), ENVELOPE, { distance: 8.2, pitch: 0.36, ...options }) +} diff --git a/registries/scifi-kit/src/build.ts b/registries/scifi-kit/src/build.ts index d69b1601..cd05b3ab 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-roof-kit'), ] for (const modelId of modelIds) items.push(await buildModelItem(modelId)) items.push({ From b7094b74a977b6dc9a874d8281431895ebb579ee Mon Sep 17 00:00:00 2001 From: reposlayer Date: Mon, 17 Aug 2026 01:15:38 +0200 Subject: [PATCH 2/5] 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 3531f84e..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-roof-kit'), + ...await Promise.all(supportIds.map((itemId) => buildSupportItem(itemId))), ] for (const modelId of modelIds) items.push(await buildModelItem(modelId)) items.push({ From 1650da612dd0df6ac4ad264d45db718577317c2e Mon Sep 17 00:00:00 2001 From: reposlayer Date: Mon, 17 Aug 2026 01:54:12 +0200 Subject: [PATCH 3/5] 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() From 9851076c7efd545a3c60cf8fd6ec447b0b4db082 Mon Sep 17 00:00:00 2001 From: reposlayer Date: Tue, 18 Aug 2026 21:41:51 +0200 Subject: [PATCH 4/5] Give the roof group a light tier and its edges back Review called the batch low quality and was right. The cause is countable: across the ten models and the kit there are 97 uses of the dark tier (graphite, ink, steel, deck) against 5 of the light one, and none of accent or cobalt. Ten models built from one value read as ten grey slabs, which is what the contact sheet shows. Copings and curb counter-flashings move to the light tier, which is also what they are - capping metal is genuinely lighter than the upstand it sits on. That gives every parapet, curb and skylight in the group a horizontal highlight to read against, and it is a one-place change in the kit rather than ten model edits. Wear rub was set near zero on the theory that nothing rubs a roof. True of a membrane, false of every edge a boot, a ladder or a service trolley crosses, and suppressing it removed the edge definition that separates one plate from the next. Rub now lands on the parts that are actually walked on, and steel gets a profile at all, which it did not have. typecheck clean, bun test 49/0, registry unchanged in count. --- assets/prototypes/axiom-roof-kit/deck.ts | 13 ++++++++++--- assets/prototypes/axiom-roof-kit/index.ts | 17 +++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/assets/prototypes/axiom-roof-kit/deck.ts b/assets/prototypes/axiom-roof-kit/deck.ts index bb5353ec..6783e6f9 100644 --- a/assets/prototypes/axiom-roof-kit/deck.ts +++ b/assets/prototypes/axiom-roof-kit/deck.ts @@ -58,7 +58,7 @@ export function deck(parent: Group, m: KitMaterials, width: number, depth: numbe export function curb(parent: Group, m: KitMaterials, width: number, depth: number, height = 0.28, centre: Vec3 = [0, 0, 0]): void { const [cx, , cz] = centre block(parent, m.graphite, [width, height, depth], [cx, height * 0.5, cz]) - block(parent, m.deck, [width + 0.06, 0.05, depth + 0.06], [cx, height + 0.02, cz]) + block(parent, m.shell, [width + 0.06, 0.05, depth + 0.06], [cx, height + 0.02, cz]) // Membrane turn-up: the reason the curb exists, drawn as the thing it is. block(parent, m.ink, [width + 0.09, 0.09, depth + 0.09], [cx, 0.045, cz]) } @@ -70,7 +70,7 @@ export function curb(parent: Group, m: KitMaterials, width: number, depth: numbe export function outlet(parent: Group, m: KitMaterials, centre: Vec3): void { const [cx, , cz] = centre block(parent, m.ink, [0.42, 0.09, 0.42], [cx, -0.04, cz]) - block(parent, m.steel, [0.3, 0.03, 0.3], [cx, -0.005, cz]) + block(parent, m.porcelain, [0.3, 0.03, 0.3], [cx, -0.005, cz]) for (let index = 0; index < 4; index += 1) { block(parent, m.ink, [0.26, 0.02, 0.028], [cx, 0.006, cz - 0.09 + index * 0.06]) } @@ -89,7 +89,14 @@ export function parapet( const t = 0.18 const run = (size: Vec3, position: Vec3): void => { block(parent, m.graphite, size, [position[0], height * 0.5, position[2]]) - block(parent, m.deck, [size[0] + 0.1, 0.09, size[2] + 0.1], [position[0], height + 0.045, position[2]]) + // Coping in the light tier, not another shade of the wall under it. + // + // The whole roof group was built from graphite, ink, steel and deck — 97 + // uses against 5 from the light tier — so ten models came out as one flat + // value and the batch read as grey slabs. A coping is capping metal and is + // genuinely lighter than the upstand; making it so gives every parapet in + // the group a horizontal highlight to read against the sky. + block(parent, m.porcelain, [size[0] + 0.1, 0.09, size[2] + 0.1], [position[0], height + 0.045, position[2]]) block(parent, m.ink, [size[0] + 0.04, 0.1, size[2] + 0.04], [position[0], 0.05, position[2]]) } if (sides.includes('front')) run([width, height, t], [0, 0, depth / 2 - t / 2]) diff --git a/assets/prototypes/axiom-roof-kit/index.ts b/assets/prototypes/axiom-roof-kit/index.ts index 4136461e..6a0d5873 100644 --- a/assets/prototypes/axiom-roof-kit/index.ts +++ b/assets/prototypes/axiom-roof-kit/index.ts @@ -82,12 +82,17 @@ export interface RoofBuild { const PROFILES = (m: KitMaterials): Map => new Map([ // A roof is the one surface nobody maintains for looks, so its grime tier runs - // above the rest of the kit's while its rub stays low — nothing rubs a roof. - [m.shell, { rub: 0.008, grime: 0.05, scratch: 0 }], - [m.porcelain, { rub: 0.006, grime: 0.04, scratch: 0 }], - [m.graphite, { rub: 0.012, grime: 0.06, scratch: 0.002 }], - [m.deck, { rub: 0.014, grime: 0.09, scratch: 0.004 }], - [m.ink, { rub: 0.01, grime: 0.11, scratch: 0.004 }], + // above the rest of the kit's. Rub was set almost to zero on the theory that + // nothing rubs a roof — true of a membrane, false of every edge a boot, a + // ladder or a service trolley crosses, and suppressing it left the whole + // group with no edge definition at all. Rub now lands on the parts that are + // actually walked on and stepped over. + [m.shell, { rub: 0.05, grime: 0.05, scratch: 0.01 }], + [m.porcelain, { rub: 0.06, grime: 0.045, scratch: 0.012 }], + [m.graphite, { rub: 0.045, grime: 0.06, scratch: 0.02 }], + [m.deck, { rub: 0.03, grime: 0.09, scratch: 0.012 }], + [m.ink, { rub: 0.02, grime: 0.11, scratch: 0.008 }], + [m.steel, { rub: 0.11, grime: 0.05, scratch: 0.06 }], ]) export function createRoofModel(spec: RoofBuild): RoofModel { From fa1bca6311a7649fe4d57cfbc8db421282c556ac Mon Sep 17 00:00:00 2001 From: reposlayer Date: Tue, 18 Aug 2026 21:46:07 +0200 Subject: [PATCH 5/5] Rebuild the antenna mast and the sloped roof The two models flagged as still weak after the palette pass. Both needed geometry, not colour. roof-antenna-mount was a 70 mm tube on a slab and read as a flagpole. A mast on a roof is a lever resisted by ballast, and it is a lattice for a real reason: a triangulated frame carries the same moment at a fraction of the wind area. It is now a three-leg lattice on hinged base plates, with a ring and a diagonal at every bay. The equipment is modelled rather than implied - headframe, three sector panels, an offset dish, a lightning finial above everything it protects, and the cable bundle running the full height into a gland on its own curb. sloped-sci-fi-roof ran a 0.9 m rise across a 4 m bay in six 60 mm steps, so each riser was shorter than the panel was thick and the whole thing read as a flat card on a slab. The rise is now 1.7 m in five steps, which makes each riser a real vertical face - and that face is the entire silhouette. Risers and the ridge closure sit in the light tier so the slope reads as a stair of highlights rather than one grey wedge. typecheck clean, bun test 49/0. --- assets/prototypes/roof-antenna-mount/model.ts | 132 +++++++++++------- assets/prototypes/sloped-sci-fi-roof/model.ts | 99 +++++++------ 2 files changed, 140 insertions(+), 91 deletions(-) diff --git a/assets/prototypes/roof-antenna-mount/model.ts b/assets/prototypes/roof-antenna-mount/model.ts index a6594610..ab8f4525 100644 --- a/assets/prototypes/roof-antenna-mount/model.ts +++ b/assets/prototypes/roof-antenna-mount/model.ts @@ -3,80 +3,112 @@ import { createRoofModel, createRoofPreview, type RoofModel, type RoofPreview } import { block, curb, deck } from '../axiom-roof-kit/deck.ts' /** - * Axiom Relay roof antenna mount — the ballasted mast base. + * Axiom Relay roof antenna mount — the ballasted lattice mast. * * A mast on a roof is a lever: wind load at the top is resisted at the bottom, - * and the bottom is a waterproof membrane that must not be drilled. So this is - * the same counterweight logic as the rooftop railing, scaled up — a ballast - * frame on rubber pads, a stub mast in a hinged base plate so it can be lowered - * for service, and three guys taken back to their own ballast blocks. + * and the bottom is a waterproof membrane that must not be drilled. So the whole + * thing stands on ballast, and the mast itself is a lattice rather than a tube — + * a triangulated frame carries the same moment at a fraction of the wind area, + * which is exactly why real masts are built this way and why a plain pole reads + * as a flagpole instead. * - * The hinge is the detail worth keeping. A mast that cannot be lowered has to be - * serviced at height, and nothing else on this roof needs a crane. + * The equipment is the point of the structure, so it is modelled rather than + * implied: three sector panels on a headframe, a dish on its own offset arm, a + * lightning finial above everything, and the cable bundle that runs the whole + * height down into a gland. */ -const ENVELOPE = { width: 2.6, depth: 2.6, height: 3.2 } +const LEG = 0.34 +const HEIGHT = 4.6 +const BAYS = 7 +const ENVELOPE = { width: 3.0, depth: 3.0, height: HEIGHT + 1.5 } export function createModel(): RoofModel { return createRoofModel({ id: 'roof-antenna-mount', envelope: ENVELOPE, build: ({ m, part }) => { - const mount = part('mount') - deck(mount, m, 2.6, 2.6) + const mt = part('mount') + deck(mt, m, 3.0, 3.0) // Ballast frame on pads, with the blocks that make it heavy. - block(mount, m.ink, [1.5, 0.02, 1.5], [0, 0.01, 0]) - block(mount, m.graphite, [1.4, 0.14, 1.4], [0, 0.09, 0]) + block(mt, m.ink, [1.9, 0.02, 1.9], [0, 0.01, 0]) + block(mt, m.graphite, [1.8, 0.16, 1.8], [0, 0.1, 0]) for (const [sx, sz] of [[1, 1], [1, -1], [-1, 1], [-1, -1]] as const) { - block(mount, m.deck, [0.5, 0.16, 0.5], [sx * 0.42, 0.24, sz * 0.42]) - block(mount, m.steel, [0.06, 0.05, 0.44], [sx * 0.42, 0.34, sz * 0.42]) + block(mt, m.deck, [0.62, 0.2, 0.62], [sx * 0.52, 0.28, sz * 0.52]) + block(mt, m.porcelain, [0.66, 0.04, 0.66], [sx * 0.52, 0.4, sz * 0.52]) } - // Hinged base plate and the stub mast standing in it. - block(mount, m.graphite, [0.34, 0.1, 0.4], [0, 0.21, 0]) - for (const side of [-1, 1] as const) { - block(mount, m.steel, [0.03, 0.22, 0.18], [side * 0.13, 0.36, 0]) + // Three legs on an equilateral plan, each on its own hinged base plate so + // the mast can be laid down for service. + const legAt = (index: number): readonly [number, number] => { + const a = (index / 3) * Math.PI * 2 + Math.PI / 6 + return [Math.cos(a) * LEG, Math.sin(a) * LEG] + } + for (let index = 0; index < 3; index += 1) { + const [x, z] = legAt(index) + block(mt, m.porcelain, [0.2, 0.05, 0.2], [x, 0.42, z]) + block(mt, m.steel, [0.1, 0.16, 0.06], [x, 0.5, z]) + mt.add(cylinder(m.steel, 0.035, HEIGHT, [x, 0.44 + HEIGHT * 0.5, z], [0, 0, 0], 8)) + } + + // Lattice: a horizontal ring and a diagonal at every bay, which is what + // makes it a mast rather than three poles standing near each other. + for (let bay = 0; bay <= BAYS; bay += 1) { + const y = 0.56 + (bay / BAYS) * (HEIGHT - 0.3) + for (let index = 0; index < 3; index += 1) { + const [x0, z0] = legAt(index) + const [x1, z1] = legAt((index + 1) % 3) + const span = Math.hypot(x1 - x0, z1 - z0) + const yaw = Math.atan2(z1 - z0, x1 - x0) + block(mt, m.steel, [span, 0.028, 0.028], [(x0 + x1) / 2, y, (z0 + z1) / 2], [0, -yaw, 0]) + if (bay < BAYS) { + const rise = (HEIGHT - 0.3) / BAYS + const diag = Math.hypot(span, rise) + block(mt, m.steel, [diag, 0.022, 0.022], [ + (x0 + x1) / 2, y + rise * 0.5, (z0 + z1) / 2, + ], [0, -yaw, Math.atan2(rise, span)]) + } + } } - mount.add(cylinder(m.steel, 0.026, 0.3, [0, 0.34, 0], [0, 0, Math.PI / 2], 10)) - mount.add(cylinder(m.graphite, 0.07, 2.5, [0, 0.34 + 1.25, 0], [0, 0, 0], 12)) - mount.add(cylinder(m.steel, 0.045, 0.5, [0, 0.34 + 2.65, 0], [0, 0, 0], 10)) - // Guy collar and three stays down to their own ballast blocks. - // - // Each stay is oriented from the geometry rather than eyeballed: a - // cylinder's own axis is +Y, so tilting it by the stay's angle from - // vertical and yawing that tilt to the block's bearing lands both ends - // exactly on the collar and the block. Guessed Euler triples put stays - // near their anchors, which reads as a mast held up by nothing. - const COLLAR_Y = 2.1 - const BLOCK_Y = 0.16 - const RADIUS = 0.95 - const drop = COLLAR_Y - BLOCK_Y - const tilt = Math.atan2(RADIUS, drop) - const stay = Math.hypot(RADIUS, drop) - mount.add(cylinder(m.steel, 0.09, 0.07, [0, COLLAR_Y, 0], [0, 0, 0], 12)) + // Headframe, three sector panels, and the dish on its offset arm. + const HEAD = 0.44 + HEIGHT + block(mt, m.graphite, [0.9, 0.07, 0.9], [0, HEAD, 0]) for (let index = 0; index < 3; index += 1) { - const bearing = (index / 3) * Math.PI * 2 - const x = Math.cos(bearing) * RADIUS - const z = Math.sin(bearing) * RADIUS - block(mount, m.deck, [0.3, 0.16, 0.3], [x, 0.08, z]) - block(mount, m.steel, [0.1, 0.05, 0.1], [x, 0.17, z]) - mount.add(cylinder(m.steel, 0.014, stay, [x / 2, (COLLAR_Y + BLOCK_Y) / 2, z / 2], [ - 0, - Math.PI - bearing, - tilt, - ], 6)) + const a = (index / 3) * Math.PI * 2 + const r = 0.46 + block(mt, m.porcelain, [0.14, 0.72, 0.3], [ + Math.cos(a) * r, HEAD + 0.42, Math.sin(a) * r, + ], [0, -a, 0]) + block(mt, m.graphite, [0.08, 0.14, 0.1], [Math.cos(a) * r * 0.7, HEAD + 0.12, Math.sin(a) * r * 0.7], [0, -a, 0]) } + mt.add(cylinder(m.steel, 0.028, 0.5, [0.42, HEAD + 0.2, -0.42], [0, 0, Math.PI / 2], 8)) + mt.add(cylinder(m.porcelain, 0.3, 0.07, [0.72, HEAD + 0.2, -0.42], [Math.PI / 2, 0.7, 0], 14)) + mt.add(cylinder(m.steel, 0.02, 0.22, [0.72, HEAD + 0.2, -0.42], [Math.PI / 2, 0.7, 0], 6)) + // Lightning finial above everything it protects. + mt.add(cylinder(m.steel, 0.018, 0.9, [0, HEAD + 0.95, 0], [0, 0, 0], 6)) - // Cable tray leaving the base, and its gland into the deck. - block(mount, m.graphite, [0.22, 0.07, 0.9], [0.5, 0.28, -0.85]) - block(mount, m.ink, [0.18, 0.03, 0.86], [0.5, 0.32, -0.85]) - curb(mount, m, 0.3, 0.28, 0.2, [0.5, 0, -1.2]) + // Cable bundle down one face, into a gland on its own curb. + for (const dx of [-0.02, 0.02, 0.06]) { + mt.add(cylinder(m.ink, 0.022, HEIGHT - 0.2, [LEG + dx, 0.6 + (HEIGHT - 0.2) * 0.5, 0.1], [0, 0, 0], 6)) + } + block(mt, m.steel, [0.16, 0.05, 0.16], [LEG + 0.02, 2.4, 0.1]) + curb(mt, m, 0.34, 0.32, 0.22, [0.95, 0, 0.62]) + block(mt, m.graphite, [0.3, 0.34, 0.26], [0.95, 0.4, 0.62]) + block(mt, m.cyan, [0.1, 0.03, 0.02], [0.95, 0.5, 0.75]) + + return { + sockets: { + mount_headframe: [0, HEAD, 0], + fx_finial: [0, HEAD + 1.4, 0], + cable_gland: [0.95, 0.4, 0.75], + }, + } }, }) } export function createPreview(options: { aspect?: number } = {}): RoofPreview { - return createRoofPreview(createModel(), ENVELOPE, { distance: 8.6, pitch: 0.22, ...options }) + return createRoofPreview(createModel(), ENVELOPE, { distance: 12, pitch: 0.2, ...options }) } diff --git a/assets/prototypes/sloped-sci-fi-roof/model.ts b/assets/prototypes/sloped-sci-fi-roof/model.ts index 2ff40467..bf67a7ea 100644 --- a/assets/prototypes/sloped-sci-fi-roof/model.ts +++ b/assets/prototypes/sloped-sci-fi-roof/model.ts @@ -4,21 +4,25 @@ import { block, deck } from '../axiom-roof-kit/deck.ts' /** * Axiom Relay sloped roof — the shed bay. * - * The kit's only pitched deck, and it is a single-direction shed rather than a - * ridge, because a ridge implies a gable and the Axiom shell has no gable to - * give it. A shed slope reads as *drainage* on a flat-roofed building — which is - * what it is — rather than as a house roof pasted onto a facility. + * A single-direction shed rather than a ridge, because a ridge implies a gable + * and the Axiom shell has no gable to give it. A shed slope reads as *drainage* + * on a flat-roofed building, which is what it is, rather than as a house roof + * pasted onto a facility. * - * It is built as stepped bays on a raised frame, not as a tilted slab. The - * cassettes are flat pressings in this system; a genuinely raked plate would be - * a second fabrication method the kit does not have, and the steps are what let - * each pressing stay flat while the surface as a whole falls. + * It is built as stepped bays on a raking frame, not as a tilted slab: the + * cassettes in this system are flat pressings, and a genuinely raked plate would + * be a second fabrication method the kit does not have. Each step's riser is a + * real vertical face — that riser is the whole silhouette. The first pass ran a + * 0.9 m rise across a 4 m bay in six 60 mm steps, and at that ratio the risers + * were shorter than the panels were thick, so the thing read as a flat card + * lying on a slab from every angle that mattered. */ const SIZE = ROOF.grid -const RISE = 0.9 -const STEPS = 6 -const ENVELOPE = { width: SIZE, depth: SIZE, height: RISE + 0.4 } +const RISE = 1.7 +const STEPS = 5 +const PANEL = 0.09 +const ENVELOPE = { width: SIZE, depth: SIZE, height: RISE + 0.5 } export function createModel(): RoofModel { return createRoofModel({ @@ -28,47 +32,60 @@ export function createModel(): RoofModel { const roof = part('roof') deck(roof, m, SIZE, SIZE) - // Raking frame: the trusses the stepped panels sit on. + const run = SIZE / STEPS + const lift = RISE / STEPS + + // Raking frame: three trusses carrying the steps, stepped rather than + // tilted so each panel lands flat on something. for (const sx of [-1, 0, 1] as const) { - block(roof, m.graphite, [0.11, 0.14, SIZE - 0.1], [sx * (SIZE / 2 - 0.3), RISE * 0.5, 0], [-Math.atan2(RISE, SIZE), 0, 0]) - } - for (let index = 0; index <= STEPS; index += 1) { - const z = -SIZE / 2 + (index / STEPS) * SIZE - const y = (index / STEPS) * RISE - block(roof, m.graphite, [SIZE - 0.2, 0.1, 0.1], [0, Math.max(0.02, y - 0.03), z]) + for (let index = 0; index < STEPS; index += 1) { + const y = index * lift + block(roof, m.graphite, [0.12, y + 0.16, run], [ + sx * (SIZE / 2 - 0.32), (y + 0.16) * 0.5, -SIZE / 2 + (index + 0.5) * run, + ]) + } } - // Stepped panels: each flat, each lapped over the one below it. - // - // The run starts at the deck rather than at half a step above it. Lifted - // clear the whole slope floated over its own deck with daylight under the - // eaves, which read as two separate objects rather than as a roof. + // Steps: a flat tread and a real riser at each change of level. The riser + // is in the light tier so the slope reads as a stair of highlights. for (let index = 0; index < STEPS; index += 1) { - const z = -SIZE / 2 + ((index + 0.5) / STEPS) * SIZE - const y = (index / STEPS) * RISE - block(roof, m.shell, [SIZE - 0.26, 0.06, SIZE / STEPS + 0.06], [0, y + 0.09, z]) - // The lap: a raised standing seam at every step, which is where the - // water crosses from one panel to the next. - block(roof, m.steel, [SIZE - 0.24, 0.05, 0.05], [0, y + 0.14, z + SIZE / STEPS * 0.5]) - // Riser closing the step's open end, so each lap is a solid face. - block(roof, m.graphite, [SIZE - 0.26, RISE / STEPS + 0.04, 0.05], [0, y + 0.09 + RISE / STEPS * 0.5, z + SIZE / STEPS * 0.5]) + const y = index * lift + const z = -SIZE / 2 + (index + 0.5) * run + block(roof, m.shell, [SIZE - 0.2, PANEL, run + 0.05], [0, y + 0.2, z]) + block(roof, m.porcelain, [SIZE - 0.16, lift, 0.07], [0, y + 0.2 + lift * 0.5, z + run * 0.5]) + // Standing seam where the water crosses from one panel to the next. + block(roof, m.steel, [SIZE - 0.18, 0.06, 0.06], [0, y + 0.26, z + run * 0.5]) + // Fixing clips along each tread. + for (let n = 0; n < 4; n += 1) { + block(roof, m.steel, [0.07, 0.03, 0.07], [-1.4 + n * 0.93, y + 0.25, z]) + } } - // Verge trims down both rakes, and the eaves gutter at the low end. + // Verge trims down both rakes, stepped with the panels they close. for (const sx of [-1, 1] as const) { - block(roof, m.graphite, [0.12, 0.16, SIZE + 0.1], [sx * (SIZE / 2 - 0.05), RISE * 0.5 + 0.12, 0], [-Math.atan2(RISE, SIZE), 0, 0]) + for (let index = 0; index < STEPS; index += 1) { + const y = index * lift + block(roof, m.graphite, [0.1, PANEL + lift * 0.6, run + 0.05], [ + sx * (SIZE / 2 - 0.04), y + 0.22, -SIZE / 2 + (index + 0.5) * run, + ]) + } + } + // Eaves gutter at the low end, ridge closure at the high end. + block(roof, m.graphite, [SIZE + 0.1, 0.2, 0.26], [0, 0.16, -SIZE / 2 - 0.08]) + block(roof, m.ink, [SIZE - 0.06, 0.1, 0.16], [0, 0.22, -SIZE / 2 - 0.08]) + block(roof, m.porcelain, [SIZE + 0.06, 0.09, 0.24], [0, RISE + 0.28, SIZE / 2 - 0.06]) + block(roof, m.graphite, [SIZE + 0.02, 0.28, 0.14], [0, RISE + 0.1, SIZE / 2 - 0.02]) + + return { + sockets: { + roof_eaves: [0, 0.16, -SIZE / 2], + roof_ridge: [0, RISE + 0.28, SIZE / 2], + }, } - block(roof, m.graphite, [SIZE + 0.1, 0.16, 0.24], [0, 0.06, -SIZE / 2 - 0.06]) - block(roof, m.ink, [SIZE - 0.06, 0.08, 0.16], [0, 0.11, -SIZE / 2 - 0.06]) - // Ridge closure at the high end, capping the top step. - block(roof, m.graphite, [SIZE + 0.06, 0.2, 0.2], [0, RISE + 0.12, SIZE / 2 - 0.02]) }, }) } export function createPreview(options: { aspect?: number } = {}): RoofPreview { - // Pitched well above the group default: a 0.9 m rise across a 4 m bay is a - // shallow slope, and from a low angle the module reads as a flat plate seen - // edge-on rather than as a fall. - return createRoofPreview(createModel(), ENVELOPE, { distance: 11.2, pitch: 0.5, yaw: -0.8, ...options }) + return createRoofPreview(createModel(), ENVELOPE, { distance: 9.6, pitch: 0.26, yaw: -0.82, ...options }) }