Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/architecture-roof-wave.md
Original file line number Diff line number Diff line change
@@ -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.
106 changes: 106 additions & 0 deletions assets/prototypes/axiom-roof-kit/deck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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.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])
}

/**
* 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.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])
}
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]])
// 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])
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])
}
232 changes: 232 additions & 0 deletions assets/prototypes/axiom-roof-kit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
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<string, Group>
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<MeshPhysicalMaterial, WearProfile> => new Map([
// A roof is the one surface nobody maintains for looks, so its grime tier runs
// 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 {
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<string, Group> = {}
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<typeof mergeStaticByMaterial>[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'
Loading