From 9eb1bca5157199ad2d34665240df2784f6217291 Mon Sep 17 00:00:00 2001 From: reposlayer Date: Mon, 17 Aug 2026 01:34:44 +0200 Subject: [PATCH 1/3] Start the street and district wave: axiom-street-kit and its first five props First tranche of the street-level wave. The kit is the bulk of the work here; the models on top of it are comparatively cheap. axiom-street-kit is keyed to pedestrian datums rather than the building grid: a 125 mm kerb because that is what a foot steps up, 2.3 m headroom because that is what a person walks under, 900 mm for hip-height furniture. Sharing those stops a street assembled from these props reading as a set of individually plausible objects that do not belong on the same pavement. Poles are the backbone, since almost everything on a street is bolted to a vertical tube. The tube, its base plate, holding-down bolts, grout pad and bracket arm are authored once, which is why the lamp, the signal and the utility pole share a silhouette at distance exactly as they do in life. Lit surfaces come in two tiers. A signal is a small lens read at distance and takes the cargo wave's brightness; a sign is a large panel read close up and clips to white at that level. The tier follows what the surface is rather than being chosen per model. Models: utility-pole, industrial-lamp, neon-street-lamp, pedestrian-signal, recycling-bin. Includes the same support-kit discovery change as the four architecture branches, textually identical, so this branch merges cleanly alongside them. Without it axiom-street-kit is never registered and the registry fails schema validation on five dangling dependencies. typecheck clean, bun test 49/0, registry builds 185 items from 179 models. --- assets/prototypes/axiom-street-kit/index.ts | 297 +++++++++++++++++++ assets/prototypes/industrial-lamp/model.ts | 58 ++++ assets/prototypes/neon-street-lamp/model.ts | 54 ++++ assets/prototypes/pedestrian-signal/model.ts | 56 ++++ assets/prototypes/recycling-bin/model.ts | 58 ++++ assets/prototypes/utility-pole/model.ts | 72 +++++ registries/scifi-kit/src/build.ts | 12 +- 7 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 assets/prototypes/axiom-street-kit/index.ts create mode 100644 assets/prototypes/industrial-lamp/model.ts create mode 100644 assets/prototypes/neon-street-lamp/model.ts create mode 100644 assets/prototypes/pedestrian-signal/model.ts create mode 100644 assets/prototypes/recycling-bin/model.ts create mode 100644 assets/prototypes/utility-pole/model.ts diff --git a/assets/prototypes/axiom-street-kit/index.ts b/assets/prototypes/axiom-street-kit/index.ts new file mode 100644 index 00000000..d0eadc91 --- /dev/null +++ b/assets/prototypes/axiom-street-kit/index.ts @@ -0,0 +1,297 @@ +import { Group, Object3D, type MeshPhysicalMaterial } from 'three/webgpu' + +import { MaterialLibrary, cylinder, tuneMaterial, type Vec3 } from '../../../src/asset-forge/generator/index.ts' +import { + AXIS_Y, + TOKEN, + acquireCargoMaterials, + box, + createCargoPreview, + finishModel, + shade, + socket, + type CargoMaterialBundle, + type CargoMaterials, + type CargoPreview, + type CargoPreviewOptions, +} from '../axiom-cargo-kit/index.ts' + +/** + * The street level: infrastructure, district dressing, and the debris both + * accumulate. + * + * The datums here are pedestrian datums, not building-grid ones. A kerb is + * 125 mm because that is what a foot steps up; a sign's underside is 2.3 m + * because that is what a person walks under; a bollard is 900 mm because that is + * what a hip meets. Sharing those three across a hundred props is what stops a + * street built from this kit reading as a collection of individually plausible + * objects that do not belong on the same pavement. + * + * Poles are the kit's real backbone. Almost everything on a street is something + * bolted to a vertical tube, so the tube, its base plate, its holding-down + * bolts, and the arm that brackets off it are authored once here — which is why + * a traffic signal, a street lamp and a sign post share a silhouette at a + * hundred metres, exactly as they do in life. + */ +export const STREET = Object.freeze({ + /** Kerb face height, and the pavement thickness behind it. */ + kerb: 0.125, + paving: 0.08, + /** Clear height under anything a pedestrian walks beneath. */ + headroom: 2.3, + /** Hip-height furniture: bollards, low barriers, planter rims. */ + hip: 0.9, + /** Standard pole diameters, small to large. */ + pole: { sign: 0.06, signal: 0.09, lamp: 0.11, power: 0.18 }, + /** Lamp and signal mounting heights. */ + lampHead: 5.2, + signalHead: 2.9, + /** Road and pavement module pitch, on the 1 m world grid. */ + module: 4, + front: '+Z', + pivot: 'ground-centre', +} as const) + +const library = new MaterialLibrary() + +export type StreetToken = 'CYAN-400' | 'AMBER-400' | 'RED-500' | 'LIME-400' | 'MAGENTA-400' | 'VIOLET-500' | 'ORANGE-500' + +const TOKEN_VALUE: Record = { + 'CYAN-400': TOKEN.CYAN_400, + 'AMBER-400': TOKEN.AMBER_400, + 'RED-500': TOKEN.RED_500, + 'LIME-400': TOKEN.LIME_400, + 'MAGENTA-400': TOKEN.MAGENTA_400, + 'VIOLET-500': TOKEN.VIOLET_500, + 'ORANGE-500': TOKEN.ORANGE_500, +} + +/** + * A lit street surface. + * + * Two tiers, because a street has two kinds of light: a *signal* is a small + * lens read at distance and takes the cargo wave's brightness, while a *sign* is + * a large panel read close up and would clip to white at that level. Passing the + * wrong one is the difference between neon that glows and neon that is a white + * rectangle, so the tier is chosen by what the surface is, not per model. + */ +export function streetLamp( + bundle: CargoMaterialBundle, + token: StreetToken, + seed = 7_700, + tier: 'signal' | 'sign' = 'signal', +): MeshPhysicalMaterial { + const handle = library.acquire({ recipeId: 'MAT-09', palette: token, condition: 'maintained', seed }) + bundle.handles.push(handle) + const emissive = tier === 'signal' ? 1.6 : 0.62 + const darken = tier === 'signal' ? -0.05 : -0.28 + return tuneMaterial(handle, shade(TOKEN_VALUE[token], darken), 0.22, 0.03, { emissive }) +} + +/** The kit's chamfered block, used for everything that is not a tube. */ +export function slab( + parent: Group, + material: MeshPhysicalMaterial, + size: Vec3, + position: Vec3, + rotation?: Vec3, +): void { + const smallest = Math.min(...size) + box(parent, material, size, position, { + chamfer: Math.min(0.05, smallest * 0.24), + fillet: Math.min(0.016, smallest * 0.1), + bevel: Math.min(0.014, smallest * 0.13), + ...(rotation ? { rotation } : {}), + }) +} + +/** + * A pole on its base: tube, base plate, holding-down bolts, and the grout pad. + * + * The base plate is the part that makes a pole read as *installed* rather than + * as pushed into the ground. Everything vertical on a street has one, and it is + * the cheapest possible detail that buys the most credibility. + */ +export function pole( + parent: Group, + m: CargoMaterials, + radius: number, + height: number, + centre: readonly [number, number] = [0, 0], + material: MeshPhysicalMaterial = m.graphite, +): void { + const [x, z] = centre + parent.add(cylinder(material, radius, height - 0.1, [x, 0.1 + (height - 0.1) * 0.5, z], AXIS_Y, 12)) + // Grout pad, base plate, and four bolts on the plate's corners. + slab(parent, m.ink, [radius * 4.6, 0.05, radius * 4.6], [x, 0.025, z]) + slab(parent, m.graphiteEdge, [radius * 4, 0.045, radius * 4], [x, 0.072, z]) + for (const sx of [-1, 1] as const) { + for (const sz of [-1, 1] as const) { + parent.add(cylinder(m.steel, radius * 0.28, 0.05, [ + x + sx * radius * 1.5, 0.11, z + sz * radius * 1.5, + ], AXIS_Y, 6)) + } + } + // Collar where the tube meets the plate; a tube landing flat on a plate is + // the one join that always reads as unwelded. + parent.add(cylinder(m.graphiteEdge, radius * 1.35, 0.06, [x, 0.12, z], AXIS_Y, 12)) +} + +/** A bracket arm off a pole, with the gusset that stops it drooping. */ +export function poleArm( + parent: Group, + m: CargoMaterials, + from: readonly [number, number, number], + reach: number, + radius = 0.05, +): void { + const [x, y, z] = from + parent.add(cylinder(m.graphite, radius, reach, [x + reach * 0.5, y, z], [0, 0, Math.PI / 2], 10)) + slab(parent, m.graphiteEdge, [reach * 0.42, 0.05, radius * 1.6], [x + reach * 0.24, y - 0.16, z], [0, 0, -0.62]) +} + +/** + * A sign or display panel on its frame: face, dark surround, and the rails that + * clamp it. The surround is what gives a panel an edge; without it a lit face is + * a rectangle floating in front of its own post. + */ +export function panel( + parent: Group, + m: CargoMaterials, + face: MeshPhysicalMaterial, + size: readonly [number, number], + position: Vec3, + rotation?: Vec3, +): void { + const [w, h] = size + slab(parent, m.graphite, [w + 0.07, h + 0.07, 0.05], position, rotation) + const lift = 0.032 + const seat: Vec3 = rotation + ? [ + position[0] + Math.sin(rotation[1] ?? 0) * lift, + position[1] - Math.sin(rotation[0] ?? 0) * lift, + position[2] + Math.cos(rotation[1] ?? 0) * Math.cos(rotation[0] ?? 0) * lift, + ] + : [position[0], position[1], position[2] + lift] + slab(parent, m.ink, [w + 0.02, h + 0.02, 0.02], seat, rotation) + slab(parent, face, [w, h, 0.012], seat, rotation) +} + +/** Kerb and pavement for a module-length run: the ground everything stands on. */ +export function pavement(parent: Group, m: CargoMaterials, width: number, depth: number): void { + slab(parent, m.graphiteEdge, [width, STREET.kerb, 0.16], [0, STREET.kerb * 0.5, depth * 0.5 - 0.08]) + slab(parent, m.shellShade, [width, STREET.paving, depth - 0.16], [0, STREET.kerb - STREET.paving * 0.5, -0.08]) + // Slab joints across the paving, on a pedestrian pitch rather than the + // building grid: paving is laid to what one person can carry. + const bays = Math.max(2, Math.round(width / 0.9)) + for (let index = 1; index < bays; index += 1) { + slab(parent, m.ink, [0.02, 0.012, depth - 0.2], [-width / 2 + (index / bays) * width, STREET.kerb + 0.002, -0.08]) + } +} + +export interface StreetEnvelope { + readonly width: number + readonly depth: number + readonly height: number +} + +export interface StreetModel { + readonly root: Group + readonly parts: Record + readonly sockets: Record + update(deltaSeconds: number): void + dispose(): void +} + +export interface StreetBuild { + readonly id: string + readonly condition?: number + readonly envelope: StreetEnvelope + build(context: { + root: Group + m: CargoMaterials + bundle: CargoMaterialBundle + part(name: string): Group + }): { + readonly sockets?: Readonly> + readonly tick?: (elapsed: number) => void + } | void +} + +export function createStreetModel(spec: StreetBuild): StreetModel { + const bundle = acquireCargoMaterials(41_300 + spec.id.length * 233, { condition: spec.condition ?? 0.62 }) + const m = bundle.materials + const tag = spec.id.toUpperCase() + + const root = new Group() + root.name = `AXR_STREET_${tag}_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_STREET_${tag}_PART_${name.toUpperCase()}_DEFAULT` + parts[name] = group + root.add(group) + return group + } + + const built = spec.build({ root, m, bundle, part }) + root.userData.streetKit = { + version: 1, + moduleId: spec.id, + pivot: STREET.pivot, + front: STREET.front, + envelope: spec.envelope, + } + + const sockets: Record = {} + const shared: Record = { + mount_ground: [0, 0, 0], + dressing_top: [0, spec.envelope.height, 0], + } + for (const [name, position] of Object.entries({ ...shared, ...(built?.sockets ?? {}) })) { + sockets[name] = socket(name, [...position] as [number, number, number]) + } + + const finished = finishModel(root, bundle, { name: spec.id, reach: 0.16, sockets: Object.values(sockets) }) + + let elapsed = 0 + return { + root, + parts, + sockets, + update: (deltaSeconds: number) => { + elapsed += Math.min(Math.max(deltaSeconds, 0), 0.05) + built?.tick?.(elapsed) + }, + dispose: finished.dispose, + } +} + +/** + * The wave's capture framing, pitched at standing eye height rather than at the + * object's centre. A street prop photographed from above is a plan; these are + * things you walk past. + */ +export function createStreetPreview( + model: StreetModel, + envelope: StreetEnvelope, + options: CargoPreviewOptions = {}, +): CargoPreview { + const span = Math.max(envelope.width, envelope.depth, envelope.height) + // Framed on the object's own centre, not on a fixed eye height. Clamping the + // target to 1.7 m suits a bin and decapitates a lamp column: an 8 m pole + // framed at hip height puts everything that distinguishes it — the arms, the + // head, the insulators — above the top of the plate. + return createCargoPreview(model, { + target: [0, envelope.height * 0.46, 0], + distance: span * 1.8 + 1.2, + yaw: -0.68, + pitch: 0.18, + fov: 32, + ...options, + }) +} + +export { box, type CargoMaterials, type CargoPreview, type CargoPreviewOptions } from '../axiom-cargo-kit/index.ts' diff --git a/assets/prototypes/industrial-lamp/model.ts b/assets/prototypes/industrial-lamp/model.ts new file mode 100644 index 00000000..430c0fc0 --- /dev/null +++ b/assets/prototypes/industrial-lamp/model.ts @@ -0,0 +1,58 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Y, type CargoPreview, type CargoPreviewOptions } from '../axiom-cargo-kit/index.ts' +import { + STREET, + createStreetModel, + createStreetPreview, + panel, + pole, + poleArm, + slab, + streetLamp, + type StreetModel, +} from '../axiom-street-kit/index.ts' + +/** + * Axiom Relay industrial lamp — the road luminaire. + * + * The plain one, and deliberately so: a single swan-neck arm, a shielded head + * raked down at the carriageway, and a door in the column base for the gear. The + * rake is the whole design — a head hung level throws half its light at the sky, + * and a lamp that does that is lighting nothing. + */ + +const ENVELOPE = { width: 2.2, depth: 0.6, height: STREET.lampHead + 0.2 } + +export function createModel(): StreetModel { + return createStreetModel({ + id: 'industrial-lamp', + condition: 0.72, + envelope: ENVELOPE, + build: ({ m, bundle, part }) => { + const p = part('lamp') + const lit = streetLamp(bundle, 'AMBER-400', 7_720, 'signal') + pole(p, m, STREET.pole.lamp, STREET.lampHead - 0.4) + // Swan neck, built as two raked sections rather than a curve. + p.add(cylinder(m.graphite, 0.075, 0.7, [0.24, STREET.lampHead - 0.2, 0], [0, 0, -0.6], 10)) + p.add(cylinder(m.graphite, 0.065, 0.9, [0.84, STREET.lampHead + 0.02, 0], [0, 0, -1.35], 10)) + // Shielded head, raked down at the road. + // + // Seated on the neck's computed end rather than at a guessed height. The + // second neck section runs at 1.35 rad from vertical over 0.9 m, so its + // tip is at x 1.28, y +0.12 — the head drawn 160 mm below that hung in + // clear air with the arm stopping short of it. + const HEAD: readonly [number, number] = [1.3, STREET.lampHead + 0.08] + slab(p, m.shellShade, [0.78, 0.14, 0.34], [HEAD[0], HEAD[1], 0], [0, 0, 0.16]) + slab(p, m.ink, [0.66, 0.04, 0.26], [HEAD[0], HEAD[1] - 0.08, 0], [0, 0, 0.16]) + slab(p, lit, [0.6, 0.025, 0.22], [HEAD[0], HEAD[1] - 0.1, 0], [0, 0, 0.16]) + // Gear door at the base, with its lock and the cable entry beside it. + slab(p, m.graphiteEdge, [0.16, 0.5, 0.03], [0, 0.72, STREET.pole.lamp]) + p.add(cylinder(m.steel, 0.014, 0.03, [0, 0.72, STREET.pole.lamp + 0.03], AXIS_X, 6)) + return { sockets: { fx_lamp_head: [1.3, STREET.lampHead - 0.02, 0], power_gear_door: [0, 0.72, STREET.pole.lamp] } } + }, + }) +} + +export function createPreview(options: CargoPreviewOptions = {}): CargoPreview { + return createStreetPreview(createModel(), ENVELOPE, { distance: 12, pitch: 0.1, ...options }) +} diff --git a/assets/prototypes/neon-street-lamp/model.ts b/assets/prototypes/neon-street-lamp/model.ts new file mode 100644 index 00000000..710c25b4 --- /dev/null +++ b/assets/prototypes/neon-street-lamp/model.ts @@ -0,0 +1,54 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Y, type CargoPreview, type CargoPreviewOptions } from '../axiom-cargo-kit/index.ts' +import { + STREET, + createStreetModel, + createStreetPreview, + panel, + pole, + poleArm, + slab, + streetLamp, + type StreetModel, +} from '../axiom-street-kit/index.ts' + +/** + * Axiom Relay neon street lamp — the district's own column. + * + * Where the industrial lamp throws light down at a road, this one is as much a + * sign as a luminaire: a tube of magenta running the full column, a horizontal + * head, and a bracket for the banner every one of them carries. It is the piece + * that makes a street read as the E-District rather than as a depot, and the + * colour is doing that work, not the geometry. + */ + +const ENVELOPE = { width: 1.5, depth: 0.5, height: STREET.lampHead + 0.4 } + +export function createModel(): StreetModel { + return createStreetModel({ + id: 'neon-street-lamp', + condition: 0.5, + envelope: ENVELOPE, + build: ({ m, bundle, part }) => { + const p = part('lamp') + const neon = streetLamp(bundle, 'MAGENTA-400', 7_710, 'sign') + const head = streetLamp(bundle, 'CYAN-400', 7_711, 'signal') + pole(p, m, STREET.pole.lamp, STREET.lampHead) + // Neon tube in a channel up the column: the channel is what stops it + // reading as a glowing stripe painted on a tube. + slab(p, m.ink, [0.07, STREET.lampHead - 1.1, 0.05], [0, STREET.lampHead * 0.55, STREET.pole.lamp]) + slab(p, neon, [0.035, STREET.lampHead - 1.3, 0.03], [0, STREET.lampHead * 0.55, STREET.pole.lamp + 0.03]) + // Head: a flat lantern on a short arm, and the banner bracket below it. + poleArm(p, m, [0, STREET.lampHead - 0.1, 0], 0.62, 0.045) + slab(p, m.graphite, [0.66, 0.12, 0.3], [0.6, STREET.lampHead - 0.22, 0]) + slab(p, head, [0.56, 0.03, 0.22], [0.6, STREET.lampHead - 0.29, 0]) + slab(p, m.graphiteEdge, [0.05, 0.42, 0.05], [0, 3.1, 0.16], [0.5, 0, 0]) + slab(p, m.shellShade, [0.03, 0.9, 0.34], [0, 2.72, 0.3]) + return { sockets: { dressing_banner: [0, 2.72, 0.3], fx_lamp_head: [0.6, STREET.lampHead - 0.29, 0] } } + }, + }) +} + +export function createPreview(options: CargoPreviewOptions = {}): CargoPreview { + return createStreetPreview(createModel(), ENVELOPE, { distance: 11, pitch: 0.12, ...options }) +} diff --git a/assets/prototypes/pedestrian-signal/model.ts b/assets/prototypes/pedestrian-signal/model.ts new file mode 100644 index 00000000..b269a287 --- /dev/null +++ b/assets/prototypes/pedestrian-signal/model.ts @@ -0,0 +1,56 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Y, type CargoPreview, type CargoPreviewOptions } from '../axiom-cargo-kit/index.ts' +import { + STREET, + createStreetModel, + createStreetPreview, + panel, + pole, + poleArm, + slab, + streetLamp, + type StreetModel, +} from '../axiom-street-kit/index.ts' + +/** + * Axiom Relay pedestrian signal — the crossing head and its push unit. + * + * Two things at two heights, and the gap between them is the design: the signal + * head sits where it is read from across the road, the push unit where a hand + * reaches without looking. Putting both on one plate — which is what a single + * box would be — loses the reason either is where it is. + */ + +const ENVELOPE = { width: 0.5, depth: 0.42, height: 2.9 } + +export function createModel(): StreetModel { + return createStreetModel({ + id: 'pedestrian-signal', + condition: 0.66, + envelope: ENVELOPE, + build: ({ m, bundle, part }) => { + const p = part('signal') + const go = streetLamp(bundle, 'LIME-400', 7_730, 'signal') + const stop = streetLamp(bundle, 'RED-500', 7_731, 'signal') + pole(p, m, STREET.pole.signal, 2.9) + // Head: two stacked lenses under a hood each, on the pole's road face. + slab(p, m.graphite, [0.3, 0.62, 0.24], [0, 2.42, 0.1]) + for (const [index, lit] of [stop, go].entries()) { + const y = 2.62 - index * 0.28 + slab(p, m.ink, [0.22, 0.22, 0.03], [0, y, 0.22]) + slab(p, lit, [0.17, 0.17, 0.02], [0, y, 0.235]) + // Hood, raked so low sun does not wash the lens out. + slab(p, m.graphiteEdge, [0.26, 0.05, 0.16], [0, y + 0.13, 0.27], [0.42, 0, 0]) + } + // Push unit at hand height, with its tactile arrow and confirm lamp. + slab(p, m.graphiteEdge, [0.16, 0.22, 0.12], [0, 1.05, 0.12]) + slab(p, m.amberPaint, [0.07, 0.07, 0.02], [0, 1.09, 0.19]) + slab(p, go, [0.05, 0.015, 0.015], [0, 0.98, 0.19]) + return { sockets: { fx_signal_head: [0, 2.48, 0.235], dressing_push_unit: [0, 1.05, 0.19] } } + }, + }) +} + +export function createPreview(options: CargoPreviewOptions = {}): CargoPreview { + return createStreetPreview(createModel(), ENVELOPE, { distance: 7, ...options }) +} diff --git a/assets/prototypes/recycling-bin/model.ts b/assets/prototypes/recycling-bin/model.ts new file mode 100644 index 00000000..3485aab8 --- /dev/null +++ b/assets/prototypes/recycling-bin/model.ts @@ -0,0 +1,58 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Y, type CargoPreview, type CargoPreviewOptions } from '../axiom-cargo-kit/index.ts' +import { + STREET, + createStreetModel, + createStreetPreview, + panel, + pole, + poleArm, + slab, + streetLamp, + type StreetModel, +} from '../axiom-street-kit/index.ts' + +/** + * Axiom Relay recycling bin — the segregated street bin. + * + * Three streams in one shell, and the aperture shape is what tells them apart: a + * slot for flat waste, a round hole for containers, a wide mouth for general. + * That is how real ones are read at a glance, and it survives at distances where + * a colour code or a label does not. + */ + +const ENVELOPE = { width: 1.1, depth: 0.5, height: 1.25 } + +export function createModel(): StreetModel { + return createStreetModel({ + id: 'recycling-bin', + condition: 0.8, + envelope: ENVELOPE, + build: ({ m, bundle, part }) => { + const p = part('bin') + const lit = streetLamp(bundle, 'CYAN-400', 7_740, 'signal') + // Shell on a plinth, with the back panel that carries all three liners. + slab(p, m.graphiteEdge, [1.04, 0.09, 0.46], [0, 0.045, 0]) + slab(p, m.shellShade, [1.0, 1.0, 0.42], [0, 0.6, 0]) + slab(p, m.graphite, [1.06, 0.1, 0.48], [0, 1.14, 0]) + for (const [index, sx] of [-1, 0, 1].entries()) { + const x = sx * 0.33 + // Divider ribs between streams, and the recessed aperture face. + if (index < 2) slab(p, m.graphite, [0.02, 0.9, 0.44], [x + 0.165, 0.62, 0]) + slab(p, m.ink, [0.26, 0.3, 0.06], [x, 0.92, 0.2]) + if (index === 0) slab(p, m.ink, [0.19, 0.045, 0.05], [x, 0.92, 0.24]) + else if (index === 1) p.add(cylinder(m.ink, 0.075, 0.05, [x, 0.92, 0.24], AXIS_X, 12)) + else slab(p, m.ink, [0.2, 0.15, 0.05], [x, 0.9, 0.24]) + slab(p, m.amberPaint, [0.24, 0.02, 0.012], [x, 0.74, 0.215]) + } + // Service door with its lock, and the fill indicator above it. + slab(p, m.graphiteEdge, [0.9, 0.5, 0.02], [0, 0.36, -0.215]) + slab(p, lit, [0.05, 0.02, 0.012], [0.4, 1.06, 0.215]) + return { sockets: { cover_service_door: [0, 0.36, -0.215], dressing_apertures: [0, 0.92, 0.22] } } + }, + }) +} + +export function createPreview(options: CargoPreviewOptions = {}): CargoPreview { + return createStreetPreview(createModel(), ENVELOPE, { distance: 3.4, ...options }) +} diff --git a/assets/prototypes/utility-pole/model.ts b/assets/prototypes/utility-pole/model.ts new file mode 100644 index 00000000..2d6c8f45 --- /dev/null +++ b/assets/prototypes/utility-pole/model.ts @@ -0,0 +1,72 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_Y, type CargoPreview, type CargoPreviewOptions } from '../axiom-cargo-kit/index.ts' +import { STREET, createStreetModel, createStreetPreview, pole, slab, type StreetModel } from '../axiom-street-kit/index.ts' + +/** + * Axiom Relay utility pole — the street's tallest ordinary object. + * + * A pole is only interesting for what hangs off it, so this one is authored as a + * carrier: a tapered mast, a stepped bolt ladder, two cross-arms with insulator + * banks, and a service cabinet at working height. The taper matters more than it + * looks — a parallel tube reads as scaffold, and the 15 % reduction over the run + * is what makes it read as a pole that carries its own weight. + */ + +const HEIGHT = 8.4 +const ENVELOPE = { width: 2.4, depth: 0.6, height: HEIGHT + 0.3 } + +export function createModel(): StreetModel { + return createStreetModel({ + id: 'utility-pole', + condition: 0.74, + envelope: ENVELOPE, + build: ({ m, part }) => { + const p = part('pole') + pole(p, m, STREET.pole.power, HEIGHT * 0.42) + // Upper sections, each stepped in: a taper built from real sections rather + // than a cone, so the joints are where the diameter changes. + p.add(cylinder(m.graphite, STREET.pole.power * 0.88, HEIGHT * 0.34, [0, HEIGHT * 0.59, 0], AXIS_Y, 12)) + p.add(cylinder(m.graphite, STREET.pole.power * 0.74, HEIGHT * 0.28, [0, HEIGHT * 0.87, 0], AXIS_Y, 12)) + for (const y of [HEIGHT * 0.42, HEIGHT * 0.76]) { + p.add(cylinder(m.graphiteEdge, STREET.pole.power * 1.06, 0.09, [0, y, 0], AXIS_Y, 12)) + } + + // Bolt-step ladder up one flank, alternating sides as real ones do. + for (let index = 0; index < 11; index += 1) { + const side = index % 2 === 0 ? 1 : -1 + p.add(cylinder(m.steel, 0.017, 0.26, [side * 0.12, 1.5 + index * 0.42, 0], [0, 0, Math.PI / 2], 6)) + } + + // Two cross-arms with insulator banks. + for (const [index, y] of [HEIGHT * 0.78, HEIGHT * 0.93].entries()) { + const reach = index === 0 ? 1.1 : 0.78 + slab(p, m.timber, [reach * 2, 0.12, 0.13], [0, y, 0]) + slab(p, m.steel, [0.05, 0.34, 0.05], [0, y - 0.2, 0], [0, 0, 0]) + for (let n = 0; n < (index === 0 ? 3 : 2); n += 1) { + const t = (n - ((index === 0 ? 3 : 2) - 1) / 2) / Math.max(1, index === 0 ? 1 : 1) + const x = t * reach * 0.78 + p.add(cylinder(m.steel, 0.016, 0.13, [x, y + 0.12, 0], AXIS_Y, 6)) + p.add(cylinder(m.glass, 0.055, 0.06, [x, y + 0.2, 0], AXIS_Y, 10)) + p.add(cylinder(m.glass, 0.045, 0.05, [x, y + 0.25, 0], AXIS_Y, 10)) + } + } + + // Service cabinet and its conduit down to the base. + slab(p, m.shellShade, [0.34, 0.46, 0.2], [0.2, 1.35, 0]) + slab(p, m.ink, [0.28, 0.38, 0.02], [0.2, 1.35, 0.11]) + p.add(cylinder(m.graphiteEdge, 0.045, 1.2, [0.2, 0.72, 0.09], AXIS_Y, 8)) + + return { + sockets: { + cable_arm_lower: [0, HEIGHT * 0.78, 0], + cable_arm_upper: [0, HEIGHT * 0.93, 0], + power_cabinet: [0.2, 1.35, 0.11], + }, + } + }, + }) +} + +export function createPreview(options: CargoPreviewOptions = {}): CargoPreview { + return createStreetPreview(createModel(), ENVELOPE, { distance: 17, pitch: 0.1, ...options }) +} diff --git a/registries/scifi-kit/src/build.ts b/registries/scifi-kit/src/build.ts index 154073b9..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,14 +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 Promise.all(supportIds.map((itemId) => buildSupportItem(itemId))), ] for (const modelId of modelIds) items.push(await buildModelItem(modelId)) items.push({ From 098607c7358a6bb2466a59940b9b95851832b3aa Mon Sep 17 00:00:00 2001 From: reposlayer Date: Mon, 17 Aug 2026 01:54:09 +0200 Subject: [PATCH 2/3] Complete the streets batch to ten, and require a kit to carry sources Five more street props: traffic-signal, plastic-barrier, tree-planter, sewer-opening, planter. Also fixes a hole I opened in the support-kit discovery. Absence of model.ts is true of a shared kit, but it is equally true of a directory that is simply empty - one left behind by an abandoned model. This branch had exactly that, and it published a support item with zero files: the schema accepts it and an install would silently resolve it to nothing. Discovery now requires at least one TypeScript source, which is the thing that actually distinguishes a kit from a leftover. The street kit gains a foliage material. The cargo palette has no vegetation tier because it was authored for a depot, so the first tree came out the colour of sacking; FIELD-500 is the colour system's own planting token. typecheck clean, bun test 49/0, registry builds 190 items from 184 models. --- assets/prototypes/axiom-street-kit/index.ts | 15 ++++ assets/prototypes/planter/model.ts | 78 +++++++++++++++++ assets/prototypes/plastic-barrier/model.ts | 61 +++++++++++++ assets/prototypes/sewer-opening/model.ts | 60 +++++++++++++ assets/prototypes/traffic-signal/model.ts | 68 +++++++++++++++ assets/prototypes/tree-planter/model.ts | 95 +++++++++++++++++++++ registries/scifi-kit/src/build.ts | 10 ++- 7 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 assets/prototypes/planter/model.ts create mode 100644 assets/prototypes/plastic-barrier/model.ts create mode 100644 assets/prototypes/sewer-opening/model.ts create mode 100644 assets/prototypes/traffic-signal/model.ts create mode 100644 assets/prototypes/tree-planter/model.ts diff --git a/assets/prototypes/axiom-street-kit/index.ts b/assets/prototypes/axiom-street-kit/index.ts index d0eadc91..e2dc4dbb 100644 --- a/assets/prototypes/axiom-street-kit/index.ts +++ b/assets/prototypes/axiom-street-kit/index.ts @@ -88,6 +88,21 @@ export function streetLamp( return tuneMaterial(handle, shade(TOKEN_VALUE[token], darken), 0.22, 0.03, { emissive }) } +/** + * Planting green. + * + * The cargo palette has no foliage tier — it was authored for a depot, where + * nothing is alive — so a street tree built from it comes out the colour of + * sacking. FIELD-500 is the colour system's own vegetation token; this binds it + * at a leaf's roughness with no clearcoat, because a leaf is the least + * specular surface on a street. + */ +export function foliage(bundle: CargoMaterialBundle, seed = 7_900, shade_ = -0.18): MeshPhysicalMaterial { + const handle = library.acquire({ recipeId: 'MAT-16', palette: 'FIELD-500', condition: 'worked', seed }) + bundle.handles.push(handle) + return tuneMaterial(handle, shade(TOKEN.FIELD_500, shade_), 0.88, 0.02) +} + /** The kit's chamfered block, used for everything that is not a tube. */ export function slab( parent: Group, diff --git a/assets/prototypes/planter/model.ts b/assets/prototypes/planter/model.ts new file mode 100644 index 00000000..ebf626cc --- /dev/null +++ b/assets/prototypes/planter/model.ts @@ -0,0 +1,78 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Y, type CargoPreview, type CargoPreviewOptions } from '../axiom-cargo-kit/index.ts' +import { + STREET, + createStreetModel, + createStreetPreview, + foliage, + slab, + type StreetModel, +} from '../axiom-street-kit/index.ts' + +/** + * Axiom Relay planter — the movable concrete tub. + * + * The street's most honest object: it is a heavy box that exists to stop + * vehicles and happens to have plants in it. Everything about it says so — the + * fork slots in the base, the lifting eyes at the corners, the drainage holes, + * and the fact that the walls are far thicker than a plant needs. + * + * The planting is deliberately low and dense rather than a shrub. A tub this + * size grows ground cover; anything taller would be leaning out of it. + */ + +const ENVELOPE = { width: 1.5, depth: 0.8, height: 0.92 } + +export function createModel(): StreetModel { + return createStreetModel({ + id: 'planter', + condition: 0.88, + envelope: ENVELOPE, + build: ({ m, bundle, part }) => { + const p = part('planter') + const leaf = foliage(bundle, 7_910) + const leafDark = foliage(bundle, 7_911, -0.4) + const W = 1.44 + const D = 0.74 + const H = 0.78 + + // Tub: four thick walls on a plinth, hollow rather than a solid block. + slab(p, m.shellShade, [W, 0.1, D], [0, 0.05, 0]) + for (const sx of [-1, 1]) slab(p, m.shellShade, [0.13, H, D], [sx * (W / 2 - 0.065), 0.1 + H / 2, 0]) + for (const sz of [-1, 1]) slab(p, m.shellShade, [W - 0.26, H, 0.13], [0, 0.1 + H / 2, sz * (D / 2 - 0.065)]) + // Rim capping, which is what a hand and a hip actually meet. + slab(p, m.graphiteEdge, [W + 0.06, 0.07, D + 0.06], [0, 0.1 + H + 0.02, 0]) + + // Fork slots in the plinth and lifting eyes at the corners: this is plant, + // not furniture, and both details say it gets moved. + for (const sx of [-1, 1]) slab(p, m.ink, [0.3, 0.07, 0.2], [sx * 0.32, 0.05, 0.28]) + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + p.add(cylinder(m.steel, 0.022, 0.05, [sx * (W / 2 - 0.09), 0.1 + H + 0.05, sz * (D / 2 - 0.09)], AXIS_Y, 8)) + } + } + // Drainage holes through the lower wall. + for (let index = 0; index < 4; index += 1) { + p.add(cylinder(m.ink, 0.02, 0.14, [-0.5 + index * 0.34, 0.2, D / 2 - 0.06], [Math.PI / 2, 0, 0], 8)) + } + + // Soil, then low dense planting: overlapping masses, none symmetrical. + slab(p, m.ink, [W - 0.28, 0.08, D - 0.28], [0, 0.1 + H - 0.02, 0]) + const clumps: readonly (readonly [number, number, number, number])[] = [ + [-0.42, 0.2, 0.34, 0.3], + [0.0, 0.26, 0.4, 0.9], + [0.44, 0.18, 0.3, 1.5], + [-0.2, 0.14, 0.24, 2.1], + [0.24, 0.15, 0.26, 2.7], + ] + for (const [index, [x, h, w, yaw]] of clumps.entries()) { + slab(p, index % 2 === 0 ? leaf : leafDark, [w, h, w * 0.8], [x, 0.1 + H + h * 0.4, (index % 2 ? 0.1 : -0.1)], [0, yaw, 0]) + } + return { sockets: { dressing_planting: [0, 0.1 + H + 0.1, 0], mount_fork_slots: [0, 0.05, 0.28] } } + }, + }) +} + +export function createPreview(options: CargoPreviewOptions = {}): CargoPreview { + return createStreetPreview(createModel(), ENVELOPE, { distance: 3.6, pitch: 0.3, ...options }) +} diff --git a/assets/prototypes/plastic-barrier/model.ts b/assets/prototypes/plastic-barrier/model.ts new file mode 100644 index 00000000..6100759c --- /dev/null +++ b/assets/prototypes/plastic-barrier/model.ts @@ -0,0 +1,61 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Y, type CargoPreview, type CargoPreviewOptions } from '../axiom-cargo-kit/index.ts' +import { + STREET, + createStreetModel, + createStreetPreview, + pole, + poleArm, + slab, + streetLamp, + type StreetModel, +} from '../axiom-street-kit/index.ts' + +/** + * Axiom Relay plastic barrier — the water-ballasted lane divider. + * + * Hollow, moulded, and useless until filled — which is the whole point of the + * type. The cap on top is the fill port, the recess at the base is the drain, + * and the interlocking pin-and-socket ends are what turn a line of them into a + * barrier rather than a row of obstacles. + * + * The taper is structural rather than styling: a mould has to release, so every + * face draws. Drawn with parallel sides it stops reading as plastic. + */ + +const ENVELOPE = { width: 1.6, depth: 0.55, height: 0.82 } + +export function createModel(): StreetModel { + return createStreetModel({ + id: 'plastic-barrier', + condition: 0.86, + envelope: ENVELOPE, + build: ({ m, bundle, part }) => { + const p = part('barrier') + const body = m.orangePaint + // Splayed foot, tapered body, and the rounded top rail. + slab(p, body, [1.5, 0.16, 0.5], [0, 0.08, 0]) + slab(p, body, [1.44, 0.34, 0.36], [0, 0.32, 0]) + slab(p, body, [1.38, 0.24, 0.26], [0, 0.6, 0]) + p.add(cylinder(body, 0.09, 1.36, [0, 0.74, 0], [0, 0, Math.PI / 2], 10)) + // Interlock: a pin one end, a socket the other. + p.add(cylinder(m.graphiteEdge, 0.05, 0.22, [0.79, 0.44, 0], [0, 0, Math.PI / 2], 8)) + slab(p, m.ink, [0.1, 0.3, 0.2], [-0.76, 0.44, 0]) + // Fill port on top, drain recess at the foot. + p.add(cylinder(m.graphiteEdge, 0.07, 0.05, [0.42, 0.8, 0], AXIS_Y, 10)) + slab(p, m.ink, [0.12, 0.07, 0.1], [-0.42, 0.06, 0.2]) + // Reflective bands, and the moulded rib pattern between them. + for (const sx of [-1, 1]) { + slab(p, m.shellLight, [0.3, 0.14, 0.02], [sx * 0.36, 0.44, 0.14]) + } + for (let index = 0; index < 5; index += 1) { + slab(p, body, [0.04, 0.5, 0.03], [-0.6 + index * 0.3, 0.42, 0.15]) + } + return { sockets: { mount_interlock_pin: [0.9, 0.44, 0], mount_interlock_socket: [-0.8, 0.44, 0] } } + }, + }) +} + +export function createPreview(options: CargoPreviewOptions = {}): CargoPreview { + return createStreetPreview(createModel(), ENVELOPE, { distance: 3.6, ...options }) +} diff --git a/assets/prototypes/sewer-opening/model.ts b/assets/prototypes/sewer-opening/model.ts new file mode 100644 index 00000000..00a154c5 --- /dev/null +++ b/assets/prototypes/sewer-opening/model.ts @@ -0,0 +1,60 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Y, type CargoPreview, type CargoPreviewOptions } from '../axiom-cargo-kit/index.ts' +import { + STREET, + createStreetModel, + createStreetPreview, + foliage, + slab, + type StreetModel, +} from '../axiom-street-kit/index.ts' + +/** + * Axiom Relay sewer opening — the kerbside gully and its cover. + * + * Two openings, not one, because that is how a real gully works: the kerb inlet + * takes the water running along the channel, and the grating on the pavement + * takes what falls on it. A gully drawn with only the top grating is a drain + * that the road cannot actually reach. + * + * The frame sits proud of the paving by the thickness of one bedding course. + * A grating flush with its surround is a grating that has never been lifted. + */ + +const ENVELOPE = { width: 1.0, depth: 0.9, height: STREET.kerb + 0.06 } + +export function createModel(): StreetModel { + return createStreetModel({ + id: 'sewer-opening', + condition: 0.92, + envelope: ENVELOPE, + build: ({ m, part }) => { + const g = part('gully') + // Kerb block with the inlet cut into its face. + slab(g, m.graphiteEdge, [0.98, STREET.kerb, 0.18], [0, STREET.kerb * 0.5, 0.34]) + slab(g, m.ink, [0.62, 0.07, 0.1], [0, 0.05, 0.4]) + slab(g, m.ink, [0.58, 0.05, 0.06], [0, 0.05, 0.44]) + + // Grating frame, proud of the paving by one bedding course. + slab(g, m.ironOxide, [0.66, 0.06, 0.56], [0, 0.03, 0]) + slab(g, m.ink, [0.56, 0.05, 0.46], [0, 0.045, 0]) + // Bars across the short way, so a wheel crosses them rather than drops in. + for (let index = 0; index < 9; index += 1) { + slab(g, m.ironOxide, [0.5, 0.035, 0.026], [0, 0.062, -0.2 + index * 0.05]) + } + // Lifting slots at both ends, and the hinge pin down one side. + for (const sz of [-1, 1]) { + slab(g, m.ink, [0.1, 0.03, 0.03], [0, 0.075, sz * 0.21]) + } + g.add(cylinder(m.steel, 0.014, 0.5, [-0.29, 0.05, 0], AXIS_X, 6)) + + // Silt bucket visible through the bars: the thing that is actually serviced. + slab(g, m.ink, [0.44, 0.24, 0.36], [0, -0.12, 0]) + return { sockets: { pipe_outfall: [0, -0.2, -0.28], cover_grating: [0, 0.06, 0] } } + }, + }) +} + +export function createPreview(options: CargoPreviewOptions = {}): CargoPreview { + return createStreetPreview(createModel(), ENVELOPE, { distance: 2.6, pitch: 0.5, ...options }) +} diff --git a/assets/prototypes/traffic-signal/model.ts b/assets/prototypes/traffic-signal/model.ts new file mode 100644 index 00000000..c5360df3 --- /dev/null +++ b/assets/prototypes/traffic-signal/model.ts @@ -0,0 +1,68 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Y, type CargoPreview, type CargoPreviewOptions } from '../axiom-cargo-kit/index.ts' +import { + STREET, + createStreetModel, + createStreetPreview, + pole, + poleArm, + slab, + streetLamp, + type StreetModel, +} from '../axiom-street-kit/index.ts' + +/** + * Axiom Relay traffic signal — the junction head on its mast arm. + * + * The pedestrian signal is read from across a road; this one is read from a + * vehicle a hundred metres back, and every difference follows from that. The + * head hangs out over the carriageway on a mast arm rather than sitting on the + * post, the lenses are twice the diameter, and each gets a deep hood — a signal + * washed out by low sun is a signal that is not there. + * + * The backboard behind the head is the detail that does the most work. It is + * what gives three small lights something to read against when the background + * is a lit city rather than sky. + */ + +const ENVELOPE = { width: 3.4, depth: 0.6, height: STREET.signalHead + 2.4 } + +export function createModel(): StreetModel { + return createStreetModel({ + id: 'traffic-signal', + condition: 0.68, + envelope: ENVELOPE, + build: ({ m, bundle, part }) => { + const p = part('signal') + const stop = streetLamp(bundle, 'RED-500', 7_750, 'signal') + const wait = streetLamp(bundle, 'AMBER-400', 7_751, 'signal') + const go = streetLamp(bundle, 'LIME-400', 7_752, 'signal') + const MAST = STREET.signalHead + 2.1 + pole(p, m, STREET.pole.signal * 1.4, MAST) + poleArm(p, m, [0, MAST - 0.24, 0], 2.5, 0.07) + + // Head hung off the arm's end, on a backboard. + const HX = 2.32 + const HY = MAST - 0.62 + slab(p, m.graphiteEdge, [0.1, 0.4, 0.1], [HX, MAST - 0.4, 0]) + slab(p, m.ink, [0.52, 1.18, 0.03], [HX, HY - 0.3, -0.02]) + slab(p, m.graphite, [0.38, 1.06, 0.26], [HX, HY - 0.3, 0.08]) + for (const [index, lit] of [stop, wait, go].entries()) { + const y = HY - 0.02 - index * 0.32 + slab(p, m.ink, [0.28, 0.28, 0.03], [HX, y, 0.22]) + p.add(cylinder(lit, 0.115, 0.03, [HX, y, 0.235], AXIS_X, 14)) + // Deep hood over each lens, raked down. + slab(p, m.graphiteEdge, [0.32, 0.06, 0.2], [HX, y + 0.16, 0.29], [0.5, 0, 0]) + } + // Controller cabinet at the base, and the duct that feeds the arm. + slab(p, m.shellShade, [0.42, 0.72, 0.28], [0.34, 0.44, 0]) + slab(p, m.ink, [0.34, 0.6, 0.02], [0.34, 0.44, 0.15]) + p.add(cylinder(m.graphiteEdge, 0.04, MAST - 1.1, [0, 0.9, 0.1], AXIS_Y, 8)) + return { sockets: { fx_signal_head: [HX, HY - 0.02, 0.235], power_controller: [0.34, 0.44, 0.15] } } + }, + }) +} + +export function createPreview(options: CargoPreviewOptions = {}): CargoPreview { + return createStreetPreview(createModel(), ENVELOPE, { distance: 11, pitch: 0.12, ...options }) +} diff --git a/assets/prototypes/tree-planter/model.ts b/assets/prototypes/tree-planter/model.ts new file mode 100644 index 00000000..dbfcca96 --- /dev/null +++ b/assets/prototypes/tree-planter/model.ts @@ -0,0 +1,95 @@ +import { cylinder } from '../../../src/asset-forge/generator/index.ts' +import { AXIS_X, AXIS_Y, type CargoPreview, type CargoPreviewOptions } from '../axiom-cargo-kit/index.ts' +import { + STREET, + createStreetModel, + createStreetPreview, + pole, + poleArm, + foliage, + slab, + streetLamp, + type StreetModel, +} from '../axiom-street-kit/index.ts' + +/** + * Axiom Relay tree planter — the street tree and its pit. + * + * The tree is the least of it. What makes a street tree survive is the grating + * over the pit, the guard around the trunk, and the irrigation tube down the + * side — all three visible, all three the reason the tree is not dead. A planted + * trunk with no pit furniture reads as scenery dropped on a pavement. + * + * The canopy is three overlapping masses rather than one blob or a spray of + * flat cards: masses at different yaws keep a silhouette when the camera moves, + * which is the least a tree has to do and the thing cards never manage. + */ + +const ENVELOPE = { width: 1.6, depth: 1.6, height: 4.2 } + +export function createModel(): StreetModel { + return createStreetModel({ + id: 'tree-planter', + condition: 0.7, + envelope: ENVELOPE, + build: ({ m, bundle, part }) => { + const p = part('planter') + // Pit kerb and the two-piece cast grating over it. + slab(p, m.graphiteEdge, [1.5, 0.14, 1.5], [0, 0.07, 0]) + for (const sz of [-1, 1]) { + slab(p, m.ironOxide, [1.36, 0.05, 0.62], [0, 0.16, sz * 0.34]) + for (let index = 0; index < 7; index += 1) { + slab(p, m.ink, [0.03, 0.02, 0.56], [-0.54 + index * 0.18, 0.19, sz * 0.34]) + } + } + slab(p, m.ink, [0.3, 0.04, 0.3], [0, 0.17, 0]) + // Trunk: three stepped sections, each thinner and slightly offset. + p.add(cylinder(m.timber, 0.11, 1.5, [0, 0.9, 0], AXIS_Y, 10)) + p.add(cylinder(m.timber, 0.085, 1.1, [0.05, 2.1, 0.03], AXIS_Y, 10)) + // Canopy: three overlapping masses at falling widths, each rotated off the + // one below, on a short branch armature. + // + // The first pass laid flat slabs at one height and called it sparse. It + // read as a mushroom on scaffolding — flat plates have no volume from any + // angle, and a canopy is volume before it is anything else. Overlapping + // masses at different yaws give a silhouette that survives rotation, which + // is the least a tree has to do. + const leaf = foliage(bundle, 7_901) + const leafDark = foliage(bundle, 7_902, -0.42) + for (let index = 0; index < 4; index += 1) { + const a = (index / 4) * Math.PI * 2 + 0.5 + p.add(cylinder(m.timber, 0.038, 0.72, [ + 0.05 + Math.cos(a) * 0.22, 2.78, 0.03 + Math.sin(a) * 0.22, + ], [Math.sin(a) * 0.85, 0, -Math.cos(a) * 0.85], 6)) + } + const tiers: readonly (readonly [number, number, number, number])[] = [ + [1.5, 0.52, 3.06, 0.0], + [1.24, 0.46, 3.42, 0.62], + [0.84, 0.38, 3.74, 1.15], + ] + for (const [index, [w, h, y, yaw]] of tiers.entries()) { + slab(p, index === 1 ? leafDark : leaf, [w, h, w * 0.9], [0.05, y, 0.03], [0, yaw, 0]) + // A smaller mass offset off each tier, so no silhouette is symmetrical. + slab(p, index === 1 ? leaf : leafDark, [w * 0.52, h * 0.72, w * 0.5], [ + 0.05 + Math.cos(yaw + 1.1) * w * 0.36, y + h * 0.22, 0.03 + Math.sin(yaw + 1.1) * w * 0.36, + ], [0, yaw + 0.5, 0]) + } + + // Trunk guard and the irrigation tube that keeps it alive. + for (let index = 0; index < 4; index += 1) { + const a = (index / 4) * Math.PI * 2 + 0.4 + p.add(cylinder(m.steel, 0.018, 1.1, [Math.cos(a) * 0.3, 0.72, Math.sin(a) * 0.3], AXIS_Y, 6)) + } + for (const y of [0.5, 1.15]) { + p.add(cylinder(m.steel, 0.012, 0.6, [0, y, 0], AXIS_X, 6)) + p.add(cylinder(m.steel, 0.012, 0.6, [0, y, 0], [Math.PI / 2, 0, 0], 6)) + } + p.add(cylinder(m.ink, 0.035, 0.6, [0.46, 0.32, 0.3], AXIS_Y, 8)) + return { sockets: { dressing_canopy: [0.05, 3.3, 0.03], pipe_irrigation: [0.46, 0.62, 0.3] } } + }, + }) +} + +export function createPreview(options: CargoPreviewOptions = {}): CargoPreview { + return createStreetPreview(createModel(), ENVELOPE, { distance: 8.4, pitch: 0.16, ...options }) +} 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 77da5fe34d9cbb0489198318e9e0d9e5f7f00b73 Mon Sep 17 00:00:00 2001 From: reposlayer Date: Mon, 17 Aug 2026 02:09:22 +0200 Subject: [PATCH 3/3] Fix six defects across the street batch, and swell the pole bases Review of the batch as a contact sheet rather than one render at a time, which is what surfaced most of these. Two were geometry leaving its own envelope. traffic-signal's duct was drawn as a length centred on a guess and ran 1.05 m below the pavement; it is now solved from the two heights it spans. sewer-opening's hinge pin was laid on AXIS_X, so it ran across the gully and stuck 210 mm past a frame 660 mm wide; a pin runs with the side it hinges, which is Z. utility-pole's insulator spacing divided by `Math.max(1, index === 0 ? 1 : 1)` - always 1 - which collapsed the spread and left the lower arm bare. plastic-barrier's interlock socket was a dark block stood proud of the end wall, reading as glued on. A socket is a hole, so it is recessed now. pedestrian-signal's head was sized against itself rather than against the 180 mm pole and read as a matchbox on a broomstick. planter's clumps rested on the rim; they are now seated a third of their height into the soil line so they emerge from it. Poles gain a swollen base over the bottom fifth. A column of constant diameter reads as a rod whatever its thickness, and at 1:24 slenderness the lamps came out as broomsticks; real columns are thickest where the bending moment is. tree-planter's canopy is unchanged and still stylised. Scattering it into fourteen small masses was tried and reverted - it lost its silhouette entirely. Foliage wants a primitive this kit does not have, which is a kit change rather than a model change. typecheck clean, bun test 49/0, registry builds 190 items from 184 models. --- assets/prototypes/axiom-street-kit/index.ts | 9 +++++ assets/prototypes/pedestrian-signal/model.ts | 12 ++++--- assets/prototypes/planter/model.ts | 8 ++++- assets/prototypes/plastic-barrier/model.ts | 5 ++- assets/prototypes/sewer-opening/model.ts | 5 ++- assets/prototypes/traffic-signal/model.ts | 7 +++- assets/prototypes/tree-planter/model.ts | 38 ++++++++++++++------ assets/prototypes/utility-pole/model.ts | 10 ++++-- 8 files changed, 71 insertions(+), 23 deletions(-) diff --git a/assets/prototypes/axiom-street-kit/index.ts b/assets/prototypes/axiom-street-kit/index.ts index e2dc4dbb..a49d699d 100644 --- a/assets/prototypes/axiom-street-kit/index.ts +++ b/assets/prototypes/axiom-street-kit/index.ts @@ -137,6 +137,15 @@ export function pole( ): void { const [x, z] = centre parent.add(cylinder(material, radius, height - 0.1, [x, 0.1 + (height - 0.1) * 0.5, z], AXIS_Y, 12)) + // Swollen base over the bottom fifth. + // + // A column of constant diameter reads as a rod whatever its thickness — at + // 1:24 slenderness the lamps came out as broomsticks. Real columns are + // thickest where the bending moment is, which is at the ground, and that + // swell is most of what makes the eye accept the height. + const swell = Math.min(height * 0.2, 1.1) + parent.add(cylinder(material, radius * 1.32, swell, [x, 0.1 + swell * 0.5, z], AXIS_Y, 12)) + parent.add(cylinder(material, radius * 1.12, swell * 0.34, [x, 0.1 + swell, z], AXIS_Y, 12)) // Grout pad, base plate, and four bolts on the plate's corners. slab(parent, m.ink, [radius * 4.6, 0.05, radius * 4.6], [x, 0.025, z]) slab(parent, m.graphiteEdge, [radius * 4, 0.045, radius * 4], [x, 0.072, z]) diff --git a/assets/prototypes/pedestrian-signal/model.ts b/assets/prototypes/pedestrian-signal/model.ts index b269a287..a106a14e 100644 --- a/assets/prototypes/pedestrian-signal/model.ts +++ b/assets/prototypes/pedestrian-signal/model.ts @@ -34,13 +34,15 @@ export function createModel(): StreetModel { const stop = streetLamp(bundle, 'RED-500', 7_731, 'signal') pole(p, m, STREET.pole.signal, 2.9) // Head: two stacked lenses under a hood each, on the pole's road face. - slab(p, m.graphite, [0.3, 0.62, 0.24], [0, 2.42, 0.1]) + slab(p, m.graphite, [0.4, 0.8, 0.3], [0, 2.36, 0.12]) for (const [index, lit] of [stop, go].entries()) { - const y = 2.62 - index * 0.28 - slab(p, m.ink, [0.22, 0.22, 0.03], [0, y, 0.22]) - slab(p, lit, [0.17, 0.17, 0.02], [0, y, 0.235]) + // Head sized against the 180 mm pole rather than against itself: at + // 300 mm wide it read as a matchbox taped to a broomstick. + const y = 2.6 - index * 0.36 + slab(p, m.ink, [0.3, 0.3, 0.035], [0, y, 0.27]) + slab(p, lit, [0.24, 0.24, 0.025], [0, y, 0.29]) // Hood, raked so low sun does not wash the lens out. - slab(p, m.graphiteEdge, [0.26, 0.05, 0.16], [0, y + 0.13, 0.27], [0.42, 0, 0]) + slab(p, m.graphiteEdge, [0.34, 0.06, 0.2], [0, y + 0.17, 0.33], [0.42, 0, 0]) } // Push unit at hand height, with its tactile arrow and confirm lamp. slab(p, m.graphiteEdge, [0.16, 0.22, 0.12], [0, 1.05, 0.12]) diff --git a/assets/prototypes/planter/model.ts b/assets/prototypes/planter/model.ts index ebf626cc..f32f2bed 100644 --- a/assets/prototypes/planter/model.ts +++ b/assets/prototypes/planter/model.ts @@ -66,7 +66,13 @@ export function createModel(): StreetModel { [0.24, 0.15, 0.26, 2.7], ] for (const [index, [x, h, w, yaw]] of clumps.entries()) { - slab(p, index % 2 === 0 ? leaf : leafDark, [w, h, w * 0.8], [x, 0.1 + H + h * 0.4, (index % 2 ? 0.1 : -0.1)], [0, yaw, 0]) + // Seated so each clump emerges *from* the soil line rather than resting + // on the rim: soil tops out at 0.1 + H - 0.02, and a clump buried by a + // third of its height reads as planted instead of placed. + const soil = 0.1 + H - 0.02 + slab(p, index % 2 === 0 ? leaf : leafDark, [w, h, w * 0.8], [ + x, soil + h * 0.34, (index % 2 ? 0.1 : -0.1), + ], [0, yaw, 0]) } return { sockets: { dressing_planting: [0, 0.1 + H + 0.1, 0], mount_fork_slots: [0, 0.05, 0.28] } } }, diff --git a/assets/prototypes/plastic-barrier/model.ts b/assets/prototypes/plastic-barrier/model.ts index 6100759c..4426617c 100644 --- a/assets/prototypes/plastic-barrier/model.ts +++ b/assets/prototypes/plastic-barrier/model.ts @@ -40,7 +40,10 @@ export function createModel(): StreetModel { p.add(cylinder(body, 0.09, 1.36, [0, 0.74, 0], [0, 0, Math.PI / 2], 10)) // Interlock: a pin one end, a socket the other. p.add(cylinder(m.graphiteEdge, 0.05, 0.22, [0.79, 0.44, 0], [0, 0, Math.PI / 2], 8)) - slab(p, m.ink, [0.1, 0.3, 0.2], [-0.76, 0.44, 0]) + // Socket recessed into the end wall rather than stood proud of it. A dark + // block on the outside reads as something glued on; a socket is a hole. + slab(p, body, [0.08, 0.34, 0.24], [-0.73, 0.44, 0]) + slab(p, m.ink, [0.05, 0.22, 0.15], [-0.7, 0.44, 0]) // Fill port on top, drain recess at the foot. p.add(cylinder(m.graphiteEdge, 0.07, 0.05, [0.42, 0.8, 0], AXIS_Y, 10)) slab(p, m.ink, [0.12, 0.07, 0.1], [-0.42, 0.06, 0.2]) diff --git a/assets/prototypes/sewer-opening/model.ts b/assets/prototypes/sewer-opening/model.ts index 00a154c5..bce70085 100644 --- a/assets/prototypes/sewer-opening/model.ts +++ b/assets/prototypes/sewer-opening/model.ts @@ -46,7 +46,10 @@ export function createModel(): StreetModel { for (const sz of [-1, 1]) { slab(g, m.ink, [0.1, 0.03, 0.03], [0, 0.075, sz * 0.21]) } - g.add(cylinder(m.steel, 0.014, 0.5, [-0.29, 0.05, 0], AXIS_X, 6)) + // Hinge pin down the long side. Laid on AXIS_X it ran *across* the gully + // and stuck 210 mm out past a frame only 660 mm wide; the pin runs with + // the side it hinges, which is Z. + g.add(cylinder(m.steel, 0.014, 0.46, [-0.29, 0.05, 0], [Math.PI / 2, 0, 0], 6)) // Silt bucket visible through the bars: the thing that is actually serviced. slab(g, m.ink, [0.44, 0.24, 0.36], [0, -0.12, 0]) diff --git a/assets/prototypes/traffic-signal/model.ts b/assets/prototypes/traffic-signal/model.ts index c5360df3..9f04932f 100644 --- a/assets/prototypes/traffic-signal/model.ts +++ b/assets/prototypes/traffic-signal/model.ts @@ -57,7 +57,12 @@ export function createModel(): StreetModel { // Controller cabinet at the base, and the duct that feeds the arm. slab(p, m.shellShade, [0.42, 0.72, 0.28], [0.34, 0.44, 0]) slab(p, m.ink, [0.34, 0.6, 0.02], [0.34, 0.44, 0.15]) - p.add(cylinder(m.graphiteEdge, 0.04, MAST - 1.1, [0, 0.9, 0.1], AXIS_Y, 8)) + // Duct from the cabinet up to the arm. Its length is solved from the two + // heights it actually spans; drawn as `MAST - 1.1` centred at 0.9 it ran + // from 1.05 m *below* the pavement to well short of the arm. + const DUCT_LO = 0.3 + const DUCT_HI = MAST - 0.4 + p.add(cylinder(m.graphiteEdge, 0.04, DUCT_HI - DUCT_LO, [0, (DUCT_LO + DUCT_HI) / 2, 0.1], AXIS_Y, 8)) return { sockets: { fx_signal_head: [HX, HY - 0.02, 0.235], power_controller: [0.34, 0.44, 0.15] } } }, }) diff --git a/assets/prototypes/tree-planter/model.ts b/assets/prototypes/tree-planter/model.ts index dbfcca96..eaa02903 100644 --- a/assets/prototypes/tree-planter/model.ts +++ b/assets/prototypes/tree-planter/model.ts @@ -43,9 +43,17 @@ export function createModel(): StreetModel { } } slab(p, m.ink, [0.3, 0.04, 0.3], [0, 0.17, 0]) - // Trunk: three stepped sections, each thinner and slightly offset. + // Trunk: two concentric sections, the upper one running up *into* the + // canopy rather than stopping below it. + // + // The first pass offset the upper section 58 mm sideways to suggest a + // natural lean. Concentricity is what makes a taper read as one trunk, so + // the offset just put a visible knee at the joint — and the upper section, + // narrower but shifted, broke the silhouette of the wider one below it. + // It also stopped at 2.65 m while the canopy began at 2.80 m, leaving the + // whole crown hanging on four thin raked twigs. p.add(cylinder(m.timber, 0.11, 1.5, [0, 0.9, 0], AXIS_Y, 10)) - p.add(cylinder(m.timber, 0.085, 1.1, [0.05, 2.1, 0.03], AXIS_Y, 10)) + p.add(cylinder(m.timber, 0.085, 1.75, [0, 2.4, 0], AXIS_Y, 10)) // Canopy: three overlapping masses at falling widths, each rotated off the // one below, on a short branch armature. // @@ -58,20 +66,28 @@ export function createModel(): StreetModel { const leafDark = foliage(bundle, 7_902, -0.42) for (let index = 0; index < 4; index += 1) { const a = (index / 4) * Math.PI * 2 + 0.5 - p.add(cylinder(m.timber, 0.038, 0.72, [ - 0.05 + Math.cos(a) * 0.22, 2.78, 0.03 + Math.sin(a) * 0.22, + p.add(cylinder(m.timber, 0.042, 0.78, [ + Math.cos(a) * 0.2, 2.92, Math.sin(a) * 0.2, ], [Math.sin(a) * 0.85, 0, -Math.cos(a) * 0.85], 6)) } + // First tier overlaps the trunk's top rather than clearing it. + // Canopy as three overlapping tiers, each rotated off the one below. + // + // Tried scattering fourteen small lumps instead, on the theory that big + // boxes read as crates. They read worse: at that size nothing overlaps + // enough to form a crown, so the silhouette dissolved into loose confetti. + // Fewer, larger, overlapping masses at least hold an outline. This is + // stylised rather than convincing either way — foliage wants a primitive + // this kit does not have, and that is a kit change, not a model change. const tiers: readonly (readonly [number, number, number, number])[] = [ - [1.5, 0.52, 3.06, 0.0], - [1.24, 0.46, 3.42, 0.62], - [0.84, 0.38, 3.74, 1.15], + [1.5, 0.56, 3.02, 0.0], + [1.24, 0.46, 3.4, 0.62], + [0.84, 0.38, 3.72, 1.15], ] for (const [index, [w, h, y, yaw]] of tiers.entries()) { - slab(p, index === 1 ? leafDark : leaf, [w, h, w * 0.9], [0.05, y, 0.03], [0, yaw, 0]) - // A smaller mass offset off each tier, so no silhouette is symmetrical. + slab(p, index === 1 ? leafDark : leaf, [w, h, w * 0.9], [0, y, 0], [0, yaw, 0]) slab(p, index === 1 ? leaf : leafDark, [w * 0.52, h * 0.72, w * 0.5], [ - 0.05 + Math.cos(yaw + 1.1) * w * 0.36, y + h * 0.22, 0.03 + Math.sin(yaw + 1.1) * w * 0.36, + Math.cos(yaw + 1.1) * w * 0.36, y + h * 0.22, Math.sin(yaw + 1.1) * w * 0.36, ], [0, yaw + 0.5, 0]) } @@ -85,7 +101,7 @@ export function createModel(): StreetModel { p.add(cylinder(m.steel, 0.012, 0.6, [0, y, 0], [Math.PI / 2, 0, 0], 6)) } p.add(cylinder(m.ink, 0.035, 0.6, [0.46, 0.32, 0.3], AXIS_Y, 8)) - return { sockets: { dressing_canopy: [0.05, 3.3, 0.03], pipe_irrigation: [0.46, 0.62, 0.3] } } + return { sockets: { dressing_canopy: [0, 3.3, 0], pipe_irrigation: [0.46, 0.62, 0.3] } } }, }) } diff --git a/assets/prototypes/utility-pole/model.ts b/assets/prototypes/utility-pole/model.ts index 2d6c8f45..ee9295e7 100644 --- a/assets/prototypes/utility-pole/model.ts +++ b/assets/prototypes/utility-pole/model.ts @@ -42,9 +42,13 @@ export function createModel(): StreetModel { const reach = index === 0 ? 1.1 : 0.78 slab(p, m.timber, [reach * 2, 0.12, 0.13], [0, y, 0]) slab(p, m.steel, [0.05, 0.34, 0.05], [0, y - 0.2, 0], [0, 0, 0]) - for (let n = 0; n < (index === 0 ? 3 : 2); n += 1) { - const t = (n - ((index === 0 ? 3 : 2) - 1) / 2) / Math.max(1, index === 0 ? 1 : 1) - const x = t * reach * 0.78 + // Spread across the arm from its own count. The previous divisor was + // `Math.max(1, index === 0 ? 1 : 1)` — always 1, which collapsed the + // spacing and left the lower arm bare. + const count = index === 0 ? 3 : 2 + for (let n = 0; n < count; n += 1) { + const t = count === 1 ? 0 : (n / (count - 1)) * 2 - 1 + const x = t * reach * 0.72 p.add(cylinder(m.steel, 0.016, 0.13, [x, y + 0.12, 0], AXIS_Y, 6)) p.add(cylinder(m.glass, 0.055, 0.06, [x, y + 0.2, 0], AXIS_Y, 10)) p.add(cylinder(m.glass, 0.045, 0.05, [x, y + 0.25, 0], AXIS_Y, 10))