Skip to content
Kiln1.5Get Kiln

A fluted iron post with a warm glazed lantern.

A fluted iron post with a warm glazed lantern.

Drag to orbit after opening. The model starts stationary.

For your engine. The download is a standard glTF 2.0 binary (GLB) with PBR metallic-roughness materials. glTF stores materials, not lighting or tone mapping, so your engine decides how they read. This 3D view tone-maps with Review Neutral, the Khronos PBR Neutral construction with a smaller glare offset (0.015 instead of 0.04), at exposure 0.9. Its Tone mapping control also shows ACES and Linear, for comparison.

Triangles
3,112
Meshes
2
Materials
2
Dimensions (m)
0.42 × 3.43 × 0.57

Runtime assets

Models, textures and animations prepared for use in a scene or application.

89,315 bytes (0.09 MB)

Download runtime assets

Full Kiln assets (remixable)

Models plus Kiln source, editing metadata, materials and included revisions, so you can reopen and continue editing.

109,554 bytes (0.11 MB)

Download full kiln assets (remixable)

Runtime includes the GLB and its Kiln metadata sidecar. The editable ZIP is this final saved revision: model, exact source, editing manifest and preview. Earlier revisions are left out; the parent revision identifier is kept.

Import the editable ZIP into Kiln’s Library, or run node kiln.mjs import asset.zip in an authoring workspace. Use Kiln 1.4.0 or later.

Collection / asset / revision
project / a_57a0f6b709fd4164831167909e6d02f1 / r_7aa5f13af3ff439281cf3772b8350680
Parent revision
r_e5b4e1ab45d7497da640f2a4fe6edd39
Source SHA-256
e957d565cb03b34a6142f0d9a09623e3e315db19424779740e8f9ecaab48f94a
Lantern post: exact saved source
// ---- Shared courtyard furniture helpers (identical in every furniture asset) ----
// Palette from BRIEF.md (sRGB).
const PAL = {
  limestone: '#d6c8a8', travertine: '#c9b48c', weathered: '#9c8d70',
  iron: '#2b2a28', oak: '#8a6440', boxwood: '#3a5228', ivy: '#4b6a32',
  soil: '#4a3a2a', glow: '#ffd9a0', flower: '#c8504a',
};

/** Shared material definitions; every asset calls the same recipes with the same values. */
async function courtyardMaterials() {
  return {
    stone: await materialRecipe('kiln.material.stone.v1', {
      baseColor: PAL.limestone, roughness: 0.9,
      textureResources: { normal: 'kiln.texture.rough-concrete-normal.v1' } }),
    weathered: await materialRecipe('kiln.material.stone.v1', {
      baseColor: '#ab9b7c', roughness: 0.95,
      textureResources: { normal: 'kiln.texture.rough-concrete-normal.v1' } }),
    iron: await materialRecipe('kiln.material.painted-metal.v1', {
      baseColor: PAL.iron, roughness: 0.55, metalness: 0.35,
      textureResources: { normal: 'kiln.texture.rusted-metal-normal.v1' } }),
    oak: await materialRecipe('kiln.material.wood.v1', {
      baseColor: PAL.oak, roughness: 0.7,
      textureResources: { normal: 'kiln.texture.oak-veneer-normal.v1' } }),
    glow: await materialRecipe('kiln.material.emissive.v1', {
      baseColor: PAL.glow, roughness: 0.35, emissiveColor: PAL.glow, emissiveIntensity: 0.55 }),
  };
}

const DEG = Math.PI / 180;

/** World-scaled box projection UVs (metres * s), chosen per triangle from its geometric normal. */
function boxUV(geo, s = 1, off = [0, 0]) {
  const g = geo.index ? geo.toNonIndexed() : geo.clone();
  const p = g.attributes.position;
  const uv = new Float32Array(p.count * 2);
  const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
  for (let t = 0; t < p.count; t += 3) {
    a.fromBufferAttribute(p, t); b.fromBufferAttribute(p, t + 1); c.fromBufferAttribute(p, t + 2);
    const n = new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a));
    const ax = Math.abs(n.x), ay = Math.abs(n.y), az = Math.abs(n.z);
    for (let k = 0; k < 3; k++) {
      const i = t + k, x = p.getX(i), y = p.getY(i), z = p.getZ(i);
      let u, v;
      if (ax >= ay && ax >= az) { u = z; v = y; } else if (ay >= az) { u = x; v = z; } else { u = x; v = y; }
      uv[i * 2] = u * s + off[0]; uv[i * 2 + 1] = v * s + off[1];
    }
  }
  g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
  return g;
}

/** Flip triangle winding if the closed mesh has negative signed volume. */
function orientOutward(positions, indices) {
  let vol = 0;
  for (let t = 0; t < indices.length; t += 3) {
    const i = indices[t] * 3, j = indices[t + 1] * 3, k = indices[t + 2] * 3;
    const ax = positions[i], ay = positions[i + 1], az = positions[i + 2];
    const bx = positions[j], by = positions[j + 1], bz = positions[j + 2];
    const cx = positions[k], cy = positions[k + 1], cz = positions[k + 2];
    vol += ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx);
  }
  if (vol < 0) for (let t = 0; t < indices.length; t += 3) { const s = indices[t + 1]; indices[t + 1] = indices[t + 2]; indices[t + 2] = s; }
  return indices;
}

function area2(pts) { let s = 0; for (let i = 0; i < pts.length; i++) { const [x0, y0] = pts[i], [x1, y1] = pts[(i + 1) % pts.length]; s += x0 * y1 - x1 * y0; } return s / 2; }

/**
 * Closed solid lofted through planar sections with matching point counts.
 * sections: [{ pts: [[u,v],...], w }], map(u,v,w) -> [x,y,z]. Caps are triangulated.
 */
function loftSolid(sections, map, opts = {}) {
  const n = sections[0].pts.length;
  const rev = area2(sections[0].pts) < 0;
  const secs = sections.map((s) => ({ w: s.w, pts: rev ? s.pts.slice().reverse() : s.pts }));
  const pos = [], idx = [];
  for (const s of secs) for (const [u, v] of s.pts) pos.push(...map(u, v, s.w));
  for (let i = 0; i < secs.length - 1; i++) for (let j = 0; j < n; j++) {
    const a = i * n + j, b = i * n + (j + 1) % n, c = (i + 1) * n + (j + 1) % n, d = (i + 1) * n + j;
    idx.push(a, b, c, a, c, d);
  }
  if (opts.caps !== false) {
    const capFor = (si, flip) => {
      const base = pos.length / 3;
      const s = secs[si];
      for (const [u, v] of s.pts) pos.push(...map(u, v, s.w));
      const tris = THREE.ShapeUtils.triangulateShape(s.pts.map(([u, v]) => new THREE.Vector2(u, v)), []);
      for (const [a, b, c] of tris) {
        const A = s.pts[a], B = s.pts[b], C = s.pts[c];
        const ccw = (B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0]) > 0;
        if (ccw !== flip) idx.push(base + a, base + b, base + c); else idx.push(base + a, base + c, base + b);
      }
    };
    capFor(0, true); capFor(secs.length - 1, false);
  }
  orientOutward(pos, idx);
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.setIndex(idx);
  g.computeVertexNormals();
  return g;
}

/**
 * Lathe solid with per-vertex radial modulation. profile: [[r,y],...]; a point with r == 0
 * at either end closes that end with a fan. mod(theta, y, r) -> radius multiplier.
 */
function latheMesh(profile, segs, opts = {}) {
  const mod = opts.mod || (() => 1);
  const phase = opts.phase || 0;
  const pos = [], uvs = [], idx = [];
  const ringStart = [];
  let arc = 0;
  for (let k = 0; k < profile.length; k++) {
    const [r, y] = profile[k];
    if (k > 0) arc += Math.hypot(r - profile[k - 1][0], y - profile[k - 1][1]);
    ringStart.push(pos.length / 3);
    if (r === 0) { pos.push(0, y, 0); uvs.push(0, arc); continue; }
    for (let j = 0; j <= segs; j++) {
      const th = phase + (j / segs) * Math.PI * 2;
      const rr = r * mod(th, y, r, k);
      pos.push(rr * Math.cos(th), y, -rr * Math.sin(th));
      uvs.push((j / segs) * Math.PI * 2 * Math.max(r, 0.05), arc);
    }
  }
  for (let k = 0; k < profile.length - 1; k++) {
    const c0 = profile[k][0] === 0, c1 = profile[k + 1][0] === 0;
    for (let j = 0; j < segs; j++) {
      const a = c0 ? ringStart[k] : ringStart[k] + j, b = c0 ? ringStart[k] : ringStart[k] + j + 1;
      const d = c1 ? ringStart[k + 1] : ringStart[k + 1] + j, c = c1 ? ringStart[k + 1] : ringStart[k + 1] + j + 1;
      if (!c0) idx.push(a, b, c); else idx.push(a, c, d);
      if (!c0 && !c1) idx.push(a, c, d);
    }
  }
  orientOutward(pos, idx);
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
  g.setIndex(idx);
  g.computeVertexNormals();
  return creaseNormals(g, { angle: opts.crease || 40 });
}

/** Merge several geometries (each placed by position / Euler-degree rotation / scale) into one. */
function mergeGeos(items) {
  const P = [], N = [], U = [];
  for (const it of items) {
    let g = it.geo.index ? it.geo.toNonIndexed() : it.geo.clone();
    if (!g.attributes.normal) g.computeVertexNormals();
    const m = new THREE.Matrix4().compose(
      new THREE.Vector3(...(it.position || [0, 0, 0])),
      new THREE.Quaternion().setFromEuler(new THREE.Euler(...(it.rotation || [0, 0, 0]).map((d) => d * DEG), 'XYZ')),
      new THREE.Vector3(...(Array.isArray(it.scale) ? it.scale : [it.scale || 1, it.scale || 1, it.scale || 1])));
    g.applyMatrix4(m);
    const p = g.attributes.position, n = g.attributes.normal, u = g.attributes.uv;
    for (let i = 0; i < p.count; i++) {
      P.push(p.getX(i), p.getY(i), p.getZ(i)); N.push(n.getX(i), n.getY(i), n.getZ(i));
      if (u) U.push(u.getX(i), u.getY(i)); else U.push(0, 0);
    }
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
  g.setAttribute('normal', new THREE.Float32BufferAttribute(N, 3));
  g.setAttribute('uv', new THREE.Float32BufferAttribute(U, 2));
  return g;
}

/**
 * Draw-call consolidation: merge every mesh under root that shares a material into ONE mesh
 * (baking each part's transform), weld it, and name it by role. names: Map(material -> name).
 * Non-mesh nodes (e.g. LightCentre) are kept.
 */
function consolidateByMaterial(root, names) {
  root.updateMatrixWorld(true);
  const inv = new THREE.Matrix4().copy(root.matrixWorld).invert();
  const meshes = [];
  root.traverse((o) => { if (o.isMesh) meshes.push(o); });
  const groups = new Map();
  for (const m of meshes) { if (!groups.has(m.material)) groups.set(m.material, []); groups.get(m.material).push(m); }
  for (const [mat, list] of groups) {
    const P = [], N = [], U = [];
    for (const m of list) {
      const g = m.geometry.index ? m.geometry.toNonIndexed() : m.geometry.clone();
      if (!g.attributes.normal) g.computeVertexNormals();
      g.applyMatrix4(new THREE.Matrix4().multiplyMatrices(inv, m.matrixWorld));
      const p = g.attributes.position, n = g.attributes.normal, u = g.attributes.uv;
      for (let i = 0; i < p.count; i++) {
        P.push(p.getX(i), p.getY(i), p.getZ(i)); N.push(n.getX(i), n.getY(i), n.getZ(i));
        if (u) U.push(u.getX(i), u.getY(i)); else U.push(0, 0);
      }
      m.parent.remove(m);
    }
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
    g.setAttribute('normal', new THREE.Float32BufferAttribute(N, 3));
    g.setAttribute('uv', new THREE.Float32BufferAttribute(U, 2));
    const name = names.get(mat);
    if (!name) throw new Error('consolidateByMaterial: no role name for material ' + mat.name);
    const part = createPart(name, weld(g), mat, { parent: root });
    part.name = name;
  }
  return root;
}

/** Deterministic 3D value noise in [-1, 1] and a small fbm. */
function hash3(i, j, k) { let h = (i * 374761393 + j * 668265263 + k * 1274126177) | 0; h = Math.imul(h ^ (h >>> 13), 1274126177); h ^= h >>> 16; return ((h >>> 0) / 4294967295) * 2 - 1; }
function vnoise(x, y, z) {
  const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
  const xf = x - xi, yf = y - yi, zf = z - zi;
  const s = (t) => t * t * (3 - 2 * t);
  const u = s(xf), v = s(yf), w = s(zf);
  let r = 0;
  for (let a = 0; a < 2; a++) for (let b = 0; b < 2; b++) for (let c = 0; c < 2; c++)
    r += hash3(xi + a, yi + b, zi + c) * (a ? u : 1 - u) * (b ? v : 1 - v) * (c ? w : 1 - w);
  return r;
}
function fbm(x, y, z, oct = 3) { let s = 0, amp = 0.5, f = 1; for (let o = 0; o < oct; o++) { s += amp * vnoise(x * f, y * f, z * f); f *= 2.03; amp *= 0.5; } return s; }

/** Cellular lumps: 1 at a jittered feature point falling to 0 one cell away (cauliflower/foliage clumps). */
function lumps(x, y, z) {
  const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
  let best = 9;
  for (let a = -1; a <= 1; a++) for (let b = -1; b <= 1; b++) for (let c = -1; c <= 1; c++) {
    const i = xi + a, j = yi + b, k = zi + c;
    const fx = i + 0.5 + 0.45 * hash3(i, j, k), fy = j + 0.5 + 0.45 * hash3(j, k, i + 17), fz = k + 0.5 + 0.45 * hash3(k + 31, i, j);
    best = Math.min(best, Math.hypot(x - fx, y - fy, z - fz));
  }
  const t = Math.max(0, 1 - best / 0.85);
  return Math.sqrt(t);
}
/** Weld coincident vertices with identical attributes (keeps the GLB small after box UVs or merges). */
function weld(geo) { return mergeVertices(geo); }

/** Points along a Catmull-Rom curve through control points (own implementation, any count). */
function smoothPath(ctrl, per = 6) { return catmullRomPath(ctrl, per); }
// ---- end shared helpers ----

const meta = { name: 'lamp-post', role: 'prop' };

// Park lantern post, ~3.4 m: moulded cast-iron base, fluted tapering post, ladder bar,
// four-sided flared glazed lantern with eaved pyramid cap and finial. Faces +X.
const SQ = Math.SQRT2;
const POST_Y0 = 0.44, POST_Y1 = 2.62;
const LANT_Y0 = 2.83, LANT_Y1 = 3.17;       // glazing height
const LANT_B = 0.11, LANT_T = 0.17;        // glazing half-widths at bottom and top
const LANTERN_CENTRE_Y = (LANT_Y0 + LANT_Y1) / 2;

function arc(cx, cy, r, a0, a1, n) {
  const out = [];
  for (let i = 0; i <= n; i++) { const a = (a0 + (a1 - a0) * i / n) * DEG; out.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); }
  return out;
}
function squareTurn(profile, crease = 30) {
  return latheMesh(profile.map(([h, y]) => [h * SQ, y]), 4, { phase: Math.PI / 4, crease });
}
/** Square-section bar between two points. */
function bar(a, b, s = 0.018) {
  const h = s / 2;
  return sweepProfile([[-h, -h], [h, -h], [h, h], [-h, h]], [a, b], { creaseAngle: 50 });
}

async function build() {
  const root = createRoot('LampPost');
  const M = await courtyardMaterials();

  // Cast base: plinth disc, torus, cavetto, bell and collar.
  const base = latheMesh([
    [0, 0], [0.2, 0], [0.2, 0.035], [0.19, 0.045], [0.178, 0.048],
    ...arc(0.172, 0.075, 0.027, -80, 90, 5),
    [0.155, 0.104], [0.14, 0.115], [0.128, 0.14], [0.118, 0.19], [0.108, 0.26], [0.1, 0.32],
    [0.112, 0.33], ...arc(0.112, 0.348, 0.018, -90, 90, 4).slice(1), [0.098, 0.37], [0.088, 0.4], [0.082, 0.445], [0, 0.445],
  ], 28, { crease: 40 });

  // Fluted post (12 flutes), gently tapering.
  const FL = 12;
  const shaft = latheMesh([[0, POST_Y0 - 0.01], [0.074, POST_Y0 - 0.01], [0.072, POST_Y0 + 0.05],
    [0.066, 1.5], [0.06, POST_Y1 - 0.04], [0.062, POST_Y1], [0, POST_Y1]], FL * 4, {
    crease: 60,
    mod: (th, y) => (y < POST_Y0 + 0.03 || y > POST_Y1 - 0.03) ? 1 : 1 - 0.09 * Math.pow(Math.max(0, Math.cos(FL * th)), 1.5),
  });

  // Capital: collar ring, flared bell, abacus under the lantern seat.
  const capital = latheMesh([
    [0, POST_Y1 - 0.02], [0.068, POST_Y1 - 0.02], ...arc(0.07, POST_Y1 + 0.004, 0.016, -90, 90, 4).slice(1),
    [0.064, POST_Y1 + 0.03], [0.07, 2.68], [0.085, 2.72], [0.105, 2.75], [0.112, 2.765], [0.112, 2.78], [0, 2.78],
  ], 24, { crease: 40 });

  // Ladder bar through the post, with ball ends.
  const ladder = cylinderGeo(0.013, 0.013, 0.52, 8);
  const ball = sphereGeo(0.024, 8, 6);

  // Lantern seat tray, frame rings, corner bars, eaved pyramid cap, crown and finial.
  const tray = squareTurn([[0, 2.78], [0.125, 2.78], [0.135, 2.79], [0.135, 2.81], [0.12, 2.83], [0, 2.83]]);
  const ring = (y, h, hw) => squareTurn([[0, y], [hw, y], [hw, y + h], [0, y + h]]);
  const bars = [];
  for (const sx of [-1, 1]) for (const sz of [-1, 1])
    bars.push({ geo: bar([sx * (LANT_B + 0.004), LANT_Y0 - 0.005, sz * (LANT_B + 0.004)], [sx * (LANT_T + 0.004), LANT_Y1 + 0.005, sz * (LANT_T + 0.004)]) });
  // Mid glazing bar on each face (makes 8 small panes read as a lantern, not a box).
  for (let f = 0; f < 4; f++) {
    const a = f * 90 * DEG, cx = Math.cos(a), cz = -Math.sin(a);
    bars.push({ geo: bar([cx * (LANT_B + 0.004), LANT_Y0 - 0.005, cz * (LANT_B + 0.004)], [cx * (LANT_T + 0.004), LANT_Y1 + 0.005, cz * (LANT_T + 0.004)], 0.01) });
  }
  const cap = squareTurn([
    [0, LANT_Y1], [0.188, LANT_Y1], [0.212, LANT_Y1 + 0.012], [0.212, LANT_Y1 + 0.03], [0.19, LANT_Y1 + 0.036],
    [0.13, LANT_Y1 + 0.078], [0.075, LANT_Y1 + 0.115], [0.04, LANT_Y1 + 0.135], [0, LANT_Y1 + 0.135],
  ]);
  const crown = latheMesh([
    [0, LANT_Y1 + 0.13], [0.04, LANT_Y1 + 0.13], [0.045, LANT_Y1 + 0.145], [0.03, LANT_Y1 + 0.165], [0.022, LANT_Y1 + 0.18],
    ...arc(0, LANT_Y1 + 0.205, 0.028, -52, 90, 5).slice(0, -1), [0.008, LANT_Y1 + 0.235], [0.004, LANT_Y1 + 0.26], [0, LANT_Y1 + 0.262],
  ], 16, { crease: 40 });

  createPart('Post', weld(boxUV(mergeGeos([
    { geo: base }, { geo: shaft }, { geo: capital },
    { geo: ladder, position: [0, 2.69, 0], rotation: [90, 0, 0] },
    { geo: ball, position: [0, 2.69, 0.26] }, { geo: ball, position: [0, 2.69, -0.26] },
  ]), 3)), M.iron, { parent: root });
  createPart('LanternFrame', weld(boxUV(mergeGeos([
    { geo: tray }, { geo: ring(LANT_Y1 - 0.012, 0.016, LANT_T + 0.012) }, ...bars, { geo: cap }, { geo: crown },
  ]), 3)), M.iron, { parent: root });

  // Glazing: a flared four-sided glass volume, warm emissive.
  const glass = squareTurn([[0, LANT_Y0], [LANT_B, LANT_Y0], [LANT_T, LANT_Y1], [0, LANT_Y1]], 20);
  createPart('LanternGlass', glass, M.glow, { parent: root });

  // Light marker for the host (no geometry).
  const light = createPivot('LightCentre', [0, LANTERN_CENTRE_Y, 0], root);
  light.name = 'LightCentre';
  // One mesh per distinct material (one instanced draw each in Holodeck).
  consolidateByMaterial(root, new Map([[M.iron, 'Post_Iron'], [M.glow, 'Lantern_Glow']]));
  return root;
}

Asset content is dedicated CC0-1.0. Kiln and Holodeck software keep their own licences.