{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "electric-heater",
  "type": "registry:component",
  "title": "Electric process heater",
  "description": "Flanged immersion electric heater in a horizontal pressure shell on fixed and sliding saddles: body flange with the element tubesheet flange and full studding, finned standoff, Ex d terminal enclosure with bolted cover, cable glands, element thermocouples and an auxiliary over-temperature sensor box, process inlet and outlet, vent, drain, PSV, thermowells and a local junction box. Rated power sets the shell size and length.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "thermal/electric-heater.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/thermal/electric-heater.tsx",
      "content": "// Process electric heater: horizontal pressure shell on saddles, flanged immersion heater bundle with standoff and Ex d terminal enclosure, inlet/outlet nozzles, thermowells. ASME VIII-1, IEC 60079-1, ASME B16.5.\nimport * as THREE from 'three'\nimport { Kit, type MatKey, type V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { gauge, junctionBox, liftingLug, saddle, tagPlate } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { nozzleStandout, pipeOD, shellThickness, type Rating } from '../lib/standards'\nimport { textMaterial } from '../lib/textures'\nimport { clamp, earthBoss, girthDims, girthFlange, girthStuds, head, openNozzle, seamX, shellX } from './_thermal'\n\nexport interface ElectricHeaterProps {\n  /** rated power, kW */\n  kw?: number\n  nps?: number\n  rating?: Rating\n  /** design pressure, barg */\n  pressure?: number\n  /** shell bottom above deck, m */\n  elevation?: number\n  insulated?: boolean\n  tag?: string\n  paint?: MatKey\n}\n\nexport const defaults = {\n  kw: 600,\n  nps: 6,\n  rating: 300 as Rating,\n  pressure: 30,\n  elevation: 0.7,\n  insulated: false,\n  tag: 'EH-100',\n  paint: 'equipmentPaint' as MatKey,\n}\n\ntype P = typeof defaults\n\nfunction dims(p: P) {\n  // typical: element bundle sized at ~ 4 W/cm² sheath loading, 16 mm elements on a 26 mm pitch\n  const D = clamp(Math.round((0.28 + 0.00045 * p.kw) * 20) / 20, 0.3, 1.6)\n  const L = clamp(1.6 + p.kw / 220, 2, 9)\n  const wall = shellThickness(p.pressure, D)\n  const Ro = D / 2 + wall\n  const G = girthDims(Ro)\n  const yc = p.elevation + Ro\n  const hd = D / 4 + wall\n  const xF = -L / 2 // shell flange face (heater flange bolts here)\n  const tHF = G.tf * 1.1 // heater (bundle) flange thickness\n  const standoff = clamp(0.25 + 0.1 * D, 0.3, 0.5)\n  const Re = Math.min(G.Rf * 0.92, 0.75) // terminal enclosure radius\n  const encL = clamp(0.35 + 0.25 * D, 0.4, 0.8)\n  const xE1 = xF - tHF - standoff // enclosure flange face\n  const xE0 = xE1 - encL\n  const xEnd = L / 2 + hd\n  const off = -(xE0 + xEnd) / 2\n  const so = nozzleStandout(p.nps, p.rating, p.insulated)\n  const xin = xF + G.tf + G.hubL + pipeOD(p.nps) + 0.15\n  const xout = L / 2 - pipeOD(p.nps) - 0.2\n  return { D, L, wall, Ro, G, yc, hd, xF, tHF, standoff, Re, encL, xE0, xE1, xEnd, off, so, xin, xout }\n}\n\nexport function ports(props: ElectricHeaterProps = {}): Port[] {\n  const p = { ...defaults, ...props } as P\n  const d = dims(p)\n  return [\n    port('inlet', 'Process inlet', [d.xin + d.off, d.yc + d.Ro + d.so, 0], [0, 1, 0], p.nps, p.rating),\n    port('outlet', 'Process outlet', [d.xout + d.off, d.yc + d.Ro + d.so, 0], [0, 1, 0], p.nps, p.rating),\n  ]\n}\n\nexport function ElectricHeater(props: ElectricHeaterProps & GroupProps) {\n  const { kw, nps, rating, pressure, elevation, insulated, tag, paint, ...group } = { ...defaults, ...props }\n  const p = { kw, nps, rating, pressure, elevation, insulated, tag, paint } as P\n  const data = useKit((k) => build(k, p), Object.values(p))\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n    </group>\n  )\n}\n\nexport default ElectricHeater\n\nfunction build(k: Kit, p: P) {\n  const d = dims(p)\n  const { Ro, G, yc, L, xF } = d\n  const P = p.paint\n  const S: MatKey = p.insulated ? 'insulation' : P\n  const encMat: MatKey = 'equipmentPaintDark'\n  k.at([d.off, 0, 0], undefined, () => {\n    k.at([0, yc, 0], undefined, () => {\n      // shell with welded head at the far end, body flange at the heater end\n      girthFlange(k, P, xF, Ro, G, 1)\n      shellX(k, S, Ro, xF + G.tf + G.hubL - 0.001, L / 2)\n      head(k, S, Ro, d.hd, L / 2, 1)\n      seamX(k, P, Ro, L / 2)\n      // heater flange (tubesheet carrying the elements) bolted to the shell flange\n      k.cyl(P, G.Rf, d.tHF, [xF - d.tHF / 2 - 0.002, 0, 0], 'x', 64)\n      k.cyl('gasket', G.Rf - 0.03, 0.004, [xF - 0.002, 0, 0], 'x', 48)\n      girthStuds(k, xF - d.tHF - 0.004, xF + G.tf, G)\n      // standoff (thermal break) to the terminal enclosure, with cooling fins\n      const Rs = Math.min(Ro * 0.55, 0.3)\n      k.cyl(P, Rs, d.standoff, [xF - d.tHF - d.standoff / 2, 0, 0], 'x', 32)\n      for (let i = 1; i < 5; i++) k.cyl(P, Rs + 0.04, 0.008, [xF - d.tHF - (d.standoff * i) / 5, 0, 0], 'x', 32)\n      // auxiliary Ex d box for the over-temperature sensors, on the standoff\n      const xa = xF - d.tHF - d.standoff * 0.5\n      k.rbox(encMat, [0.18, 0.16, 0.16], 0.015, [xa, Rs + 0.12, 0])\n      k.cyl(encMat, 0.03, 0.06, [xa, Rs + 0.02, 0], 'y', 10)\n      k.cyl('brass', 0.015, 0.05, [xa + 0.11, Rs + 0.12, 0], 'x', 10)\n      // Ex d terminal enclosure: flanged body and heavy bolted cover\n      const { Re, xE0, xE1, encL } = d\n      k.cyl(encMat, Re, encL - 0.08, [xE0 + 0.04 + (encL - 0.08) / 2, 0, 0], 'x', 48)\n      k.cyl(encMat, Re + 0.05, 0.05, [xE1 - 0.025, 0, 0], 'x', 48)\n      k.cyl(encMat, Re + 0.05, 0.05, [xE0 + 0.065, 0, 0], 'x', 48)\n      k.lathe(encMat, [[0, xE0 - 0.02], [Re * 0.6, xE0 - 0.015], [Re + 0.05, xE0 + 0.04]], [0, 0, 0], 'x', 48)\n      const nb = Math.max(12, Math.round((2 * Math.PI * (Re + 0.025)) / 0.09))\n      k.boltCircle('stainless', 'nut', nb, Re + 0.025, [xE0 + 0.01, 0, 0], 'x', 0.016, 0.1, [0.012], Math.PI / nb)\n      // cable glands on the underside, earth stud, element thermocouple heads, warning label\n      for (let i = 0; i < 3; i++) {\n        const x = xE0 + encL * (0.3 + i * 0.2)\n        k.cyl('brass', 0.03, 0.08, [x, -Re - 0.03, 0], 'y', 12)\n        k.spline('cable', [[x, -Re - 0.07, 0], [x, -Re - 0.3, 0.05], [x + 0.05, -yc + 0.05, 0.2]], 0.022, 8, 16)\n      }\n      earthBoss(k, [xE0 + encL * 0.5, -Re * 0.4, Re * 0.92], 'z')\n      for (const a of [0.5, -0.5]) {\n        const c: V3 = [xF - d.tHF - 0.03, Math.cos(a) * (G.Rf * 0.7), Math.sin(a) * (G.Rf * 0.7)]\n        k.cyl('stainless', 0.012, 0.12, [c[0] - 0.06, c[1], c[2]], 'x', 8)\n        k.cyl('stainless', 0.035, 0.06, [c[0] - 0.15, c[1], c[2]], 'x', 12)\n      }\n      const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -Math.PI / 2)\n      k.plane(textMaterial('Ex d IIB T3  DO NOT OPEN WHEN ENERGIZED', '#f2c200', '#111111', 1024, 96), Re * 1.3, 0.09, [xE0 - 0.022, Re * 0.4, 0], q)\n      liftingLug(k, [xE0 + encL / 2, Re - 0.005, 0], 'x', 0.7, encMat)\n    })\n    // nozzles: inlet / outlet on top, vent, drain, PSV, thermowells\n    const nz = (x: number, a: number, nps: number, standout: number, blind = false) => {\n      const dir: V3 = [0, Math.cos(a), Math.sin(a)]\n      return openNozzle(k, { base: [x, yc + Ro * dir[1], Ro * dir[2]], dir, nps, rating: p.rating, standout, pad: pipeOD(nps) * 1.8, mat: P, blind, shellR: Ro })\n    }\n    nz(d.xin, 0, p.nps, d.so)\n    nz(d.xout, 0, p.nps, d.so)\n    nz(L / 2 - 0.25, 0.7, 2, 0.2, true) // vent\n    nz((d.xin + d.xout) / 2, Math.PI, 2, Math.min(0.25, p.elevation - 0.15), true) // drain\n    nz((d.xin + d.xout) / 2 + 0.4, 0, 3, nozzleStandout(3, p.rating), true) // PSV\n    for (const x of [d.xout + pipeOD(p.nps) * 0.7 + 0.12, (d.xin + d.xout) / 2 - 0.4]) {\n      const a = 0.9\n      const n: V3 = [0, Math.cos(a), Math.sin(a)]\n      k.pipe('stainless', [x, yc + Ro * n[1] - 0.01, Ro * n[2]], [x, yc + (Ro + 0.12) * n[1], (Ro + 0.12) * n[2]], 0.034, 0.004)\n      k.cyl('stainless', 0.045, 0.08, [x, yc + (Ro + 0.16) * n[1], (Ro + 0.16) * n[2]], n, 14)\n    }\n    {\n      const a = 0.45\n      const x = d.xout - pipeOD(p.nps) * 0.6 - 0.18\n      const n: V3 = [0, Math.cos(a), Math.sin(a)]\n      k.pipe('stainless', [x, yc + (Ro - 0.01) * n[1], (Ro - 0.01) * n[2]], [x, yc + (Ro + 0.16) * n[1], (Ro + 0.16) * n[2]], 0.027, 0.004)\n      k.box('valveBody', [0.05, 0.05, 0.05], [x, yc + (Ro + 0.09) * n[1], (Ro + 0.09) * n[2]])\n      gauge(k, [x, yc + (Ro + 0.2) * n[1], (Ro + 0.2) * n[2]], n, 0.1, 'bar', 60)\n    }\n    // saddles: fixed at the flange end, sliding at the head end\n    const sx0 = xF + G.tf + G.hubL + Math.max(0.35, L * 0.18)\n    const sx1 = L / 2 - Math.max(0.3, L * 0.15)\n    k.at([0, yc, 0], undefined, () => {\n      saddle(k, sx0, Ro, -yc, { mat: P })\n      saddle(k, sx1, Ro, -yc, { mat: P, sliding: true })\n    })\n    for (const x of [sx0 + 0.3, sx1 - 0.3]) liftingLug(k, [x, yc + Ro - 0.01, 0], 'x', clamp(Ro / 0.4, 0.7, 1.2), P)\n    const a = -0.3\n    const nrm: V3 = [0, Math.sin(a), Math.cos(a)]\n    tagPlate(k, p.tag, [(sx0 + sx1) / 2, yc + Ro * nrm[1] + 0.005 * nrm[1], Ro * nrm[2] + 0.005], nrm, 0.3)\n    // local control junction box on a stand by the enclosure\n    const xj = d.xE0 + 0.1\n    k.box('galvanised', [0.06, 1.1, 0.06], [xj, 0.55, -Ro - 0.5])\n    k.box('galvanised', [0.35, 0.02, 0.35], [xj, 0.01, -Ro - 0.5])\n    junctionBox(k, [xj, 1.0, -Ro - 0.47], 'z', [0.35, 0.4, 0.18])\n  })\n}\n"
    },
    {
      "path": "thermal/_thermal.ts",
      "type": "registry:file",
      "target": "components/deck-twin/thermal/_thermal.ts",
      "content": "// Shared geometry for thermal equipment: body (girth) flanges, formed heads, eccentric cones, nozzle-to-nozzle bolting.\nimport * as THREE from 'three'\nimport type { Kit, MatKey, V3 } from '../lib/kit'\nimport { axisVec } from '../lib/kit'\nimport { gasket, nozzle, type NozzleOpts } from '../lib/parts'\nimport { flange as flangeDims, pipeOD, pipeWall, type Rating } from '../lib/standards'\n\nexport const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n\nexport interface Girth {\n  Rf: number\n  tf: number\n  hubL: number\n  hubT: number\n  Rbc: number\n  nb: number\n  db: number\n}\n\n/** typical: custom body flange for a shell of outside radius Ro (not B16.5/B16.47; sized like TEMA girth flanges). */\nexport function girthDims(Ro: number): Girth {\n  const D = 2 * Ro\n  const db = D < 0.6 ? 0.024 : D < 1.2 ? 0.03 : 0.036\n  const Rbc = Ro + 0.04 + 1.6 * db\n  const Rf = Rbc + 1.4 * db + 0.012\n  const tf = clamp(0.05 + 0.06 * D, 0.06, 0.16)\n  const nb = Math.max(12, 4 * Math.round((2 * Math.PI * Rbc) / 0.11 / 4))\n  return { Rf, tf, hubL: clamp(0.06 + 0.04 * D, 0.07, 0.14), hubT: clamp(0.012 + 0.012 * D, 0.016, 0.04), Rbc, nb, db }\n}\n\n/** Girth flange about the X axis at y/z = 0: raised face at `x`, hub running towards `dir`. */\nexport function girthFlange(k: Kit, mat: MatKey, x: number, Ro: number, G: Girth, dir: 1 | -1, seg = 64) {\n  const Ri = Ro * 0.97\n  const { Rf, tf, hubL, hubT } = G\n  if (dir > 0)\n    k.lathe(mat, [[Ri, x], [Rf, x], [Rf, x + tf], [Ro + hubT, x + tf], [Ro, x + tf + hubL]], [0, 0, 0], 'x', seg)\n  else k.lathe(mat, [[Ro, x - tf - hubL], [Ro + hubT, x - tf], [Rf, x - tf], [Rf, x], [Ri, x]], [0, 0, 0], 'x', seg)\n}\n\n/** Through-studs with a nut at each end, between flange back faces xa < xb, about the X axis. */\nexport function girthStuds(k: Kit, xa: number, xb: number, G: Girth, R = G.Rbc, n = G.nb, d = G.db, mat: MatKey = 'boltXylan') {\n  const nh = d * 0.95\n  const len = xb - xa + 2 * nh + 1.2 * d\n  k.boltCircle(mat, 'nut', n, R, [xa - nh - 0.6 * d, 0, 0], 'x', d, len, [0.6 * d + nh / 2, len - 0.6 * d - nh / 2], Math.PI / n)\n}\n\n/** 2:1 ellipsoidal (or any depth) head about X: tangent line at x0, crown towards dir. */\nexport function headProfile(R: number, depth: number, x0: number, dir: 1 | -1, n = 18): [number, number][] {\n  const pts: [number, number][] = []\n  for (let i = 0; i <= n; i++) {\n    const t = (i / n) * (Math.PI / 2)\n    pts.push([R * Math.cos(t), x0 + dir * depth * Math.sin(t)])\n  }\n  return dir > 0 ? pts : pts.reverse()\n}\n\nexport function head(k: Kit, mat: MatKey, R: number, depth: number, x0: number, dir: 1 | -1, seg = 64) {\n  k.lathe(mat, headProfile(R, depth, x0, dir), [0, 0, 0], 'x', seg)\n}\n\n/** Shell cylinder about X between x0 < x1. */\nexport function shellX(k: Kit, mat: MatKey, R: number, x0: number, x1: number, seg = 64) {\n  if (x1 - x0 > 1e-4) k.lathe(mat, [[R, x0], [R, x1]], [0, 0, 0], 'x', seg)\n}\n\n/** Circumferential weld seam bead about X. */\nexport function seamX(k: Kit, mat: MatKey, R: number, x: number) {\n  k.torus(mat, R + 0.001, 0.005, [x, 0, 0], 'x', 6, 64)\n}\n\n/** Eccentric cone about X, circle (r0, centre y0) at x0 to (r1, y1) at x1 (flat-bottom kettle reducer). */\nexport function eccentricCone(k: Kit, mat: MatKey, x0: number, r0: number, y0: number, x1: number, r1: number, y1: number, seg = 64) {\n  const pos: number[] = []\n  const idx: number[] = []\n  for (let i = 0; i <= seg; i++) {\n    const a = (i / seg) * Math.PI * 2\n    const c = Math.cos(a)\n    const s = Math.sin(a)\n    pos.push(x0, y0 + r0 * s, r0 * c, x1, y1 + r1 * s, r1 * c)\n  }\n  for (let i = 0; i < seg; i++) {\n    const a = i * 2\n    idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2)\n  }\n  const g = new THREE.BufferGeometry()\n  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3))\n  g.setIndex(idx)\n  g.computeVertexNormals()\n  k.add(mat, g)\n}\n\n/** Gasket + through-studs for two B16.5 flanges meeting face to face at `mid` along `axis`. */\nexport function jointBolting(k: Kit, nps: number, rating: Rating, mid: V3, axis: V3) {\n  const f = flangeDims(nps, rating)\n  const v = axisVec(axis)\n  const g = 0.0045\n  gasket(k, nps, rating, mid, axis, g)\n  const span = 2 * (f.thickness + f.raisedFaceHeight) + g\n  const studL = span + f.boltDia * 3\n  const start = new THREE.Vector3(...mid).addScaledVector(v, -studL / 2).toArray() as V3\n  k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, start, axis, f.boltDia, studL, [f.boltDia * 0.95, studL - f.boltDia * 0.95])\n}\n\n/** Earthing boss: small stainless pad with a bolt, on a surface facing +normal. */\nexport function earthBoss(k: Kit, pos: V3, normal: 'x' | 'z' | '-x' | '-z' | 'y') {\n  k.box('stainless', [0.05, 0.05, 0.05], pos)\n  const v = axisVec(normal)\n  k.hex('nut', 0.022, 0.012, [pos[0] + v.x * 0.03, pos[1] + v.y * 0.03, pos[2] + v.z * 0.03], normal)\n}\n\n/** nozzle() with a dark bore end just above the shell (and pad) surface, so an open nozzle reads hollow instead of showing the painted shell. */\nexport function openNozzle(k: Kit, o: NozzleOpts & { shellR?: number }): V3 {\n  const face = nozzle(k, o)\n  if (!o.blind) {\n    const ri = pipeOD(o.nps) / 2 - pipeWall(o.nps, '80')\n    const sag = o.shellR ? o.shellR - Math.sqrt(Math.max(0, o.shellR ** 2 - ri ** 2)) : 0\n    const d = new THREE.Vector3(...o.dir).normalize()\n    const c = new THREE.Vector3(...o.base).addScaledVector(d, Math.max(sag + 0.004, o.pad ? 0.0095 : 0.004))\n    k.disc('pipeBore', ri * 0.995, c.toArray() as V3, d.toArray() as V3, 28)\n  }\n  return face\n}\n"
    }
  ],
  "categories": [
    "thermal"
  ],
  "meta": {
    "export": "ElectricHeater",
    "anchor": "base",
    "standards": [
      "ASME BPVC VIII-1",
      "IEC 60079-1",
      "IEC 60079-7",
      "ASME B16.5"
    ]
  }
}