{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "plate-heat-exchanger",
  "type": "registry:component",
  "title": "Plate heat exchanger",
  "description": "Gasketed plate-and-frame exchanger: fixed frame plate on feet carrying all four flanged connections, a pack of pressed stainless plates with gaskets, movable pressure plate rolling on the top carrying bar, bottom guide bar, end support column, side tightening bolts with bearing boxes at the heads, long nuts and protective thread sleeves. The plate count sets the pack and frame length; the connection size sets the plate size.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "thermal/plate-heat-exchanger.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/thermal/plate-heat-exchanger.tsx",
      "content": "// Gasketed plate-and-frame heat exchanger: fixed frame plate with four connections, plate pack, pressure plate, tie bolts, carrying and guide bars, support column. EN 13445 / ASME VIII, ASME B16.5.\nimport { Kit, type MatKey, type V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { liftingLug, member, tagPlate } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { nozzleStandout, pipeOD, type Rating } from '../lib/standards'\nimport { clamp, earthBoss, openNozzle } from './_thermal'\n\nexport interface PlateHeatExchangerProps {\n  /** connection size, also sets the plate size */\n  nps?: number\n  rating?: Rating\n  /** number of plates in the pack */\n  plates?: number\n  /** plate pitch (plate + compressed gasket), m */\n  pitch?: number\n  tag?: string\n  paint?: MatKey\n}\n\nexport const defaults = {\n  nps: 6,\n  rating: 150 as Rating,\n  plates: 160,\n  pitch: 0.0036,\n  tag: 'E-300',\n  paint: 'motorPaint' as MatKey,\n}\n\ntype P = typeof defaults\n\n// typical plate envelope (width, height) for a connection size, after common M/T-series frames\nconst PLATE: [number, number, number][] = [\n  [2, 0.32, 0.75],\n  [3, 0.4, 0.95],\n  [4, 0.48, 1.1],\n  [6, 0.62, 1.6],\n  [8, 0.78, 2.0],\n  [10, 0.98, 2.4],\n  [12, 1.15, 2.7],\n  [16, 1.3, 3.0],\n]\n\nfunction dims(p: P) {\n  const row = PLATE.find((r) => r[0] >= p.nps) ?? PLATE[PLATE.length - 1]\n  const pw = row[1]\n  const ph = row[2]\n  const db = clamp(0.012 + 0.03 * pw, 0.024, 0.052) // tightening bolt\n  const fw = pw + 6 * db\n  const fh = ph + 0.32\n  const tF = clamp(0.04 + 0.09 * pw, 0.06, 0.18)\n  const tP = tF * 0.85\n  const pack = Math.max(10, Math.round(p.plates)) * p.pitch\n  const open = 0.35 + 0.3 * pack // free bar length to slide plates out\n  const tc = 0.18\n  const len = tF + pack + tP + open + tc\n  const x0 = -len / 2 // fixed frame front face\n  const xA = x0 + tF // pack starts\n  const xB = xA + pack\n  const xPP = xB + tP\n  const xC = x0 + len - tc / 2 // column centre\n  const yFoot = 0.22\n  const yc = yFoot + fh / 2\n  const so = nozzleStandout(p.nps, p.rating)\n  const ox = 0.05 + pipeOD(p.nps) * 0.5\n  const pz = pw / 2 - ox\n  const py = ph / 2 - ox\n  return { pw, ph, db, fw, fh, tF, tP, pack, x0, xA, xB, xPP, xC, tc, yFoot, yc, so, pz, py }\n}\n\nconst CONN = [\n  ['hot-in', 'Hot side inlet', 1, 1],\n  ['hot-out', 'Hot side outlet', 1, -1],\n  ['cold-in', 'Cold side inlet', -1, -1],\n  ['cold-out', 'Cold side outlet', -1, 1],\n] as const\n\nexport function ports(props: PlateHeatExchangerProps = {}): Port[] {\n  const p = { ...defaults, ...props } as P\n  const d = dims(p)\n  return CONN.map(([id, label, sz, sy]) => port(id, label, [d.x0 - d.so, d.yc + sy * d.py, sz * d.pz], [-1, 0, 0], p.nps, p.rating))\n}\n\nexport function PlateHeatExchanger(props: PlateHeatExchangerProps & GroupProps) {\n  const { nps, rating, plates, pitch, tag, paint, ...group } = { ...defaults, ...props }\n  const p = { nps, rating, plates, pitch, 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 PlateHeatExchanger\n\nfunction build(k: Kit, p: P) {\n  const d = dims(p)\n  const P = p.paint\n  const { pw, ph, fw, fh, tF, tP, yc, db } = d\n  // fixed frame plate on its feet\n  k.rbox(P, [tF, fh, fw], 0.02, [d.x0 + tF / 2, yc, 0])\n  k.box(P, [tF + 0.32, 0.03, fw + 0.08], [d.x0 + tF / 2 + 0.06, 0.015, 0])\n  for (const s of [-1, 1]) k.box(P, [tF + 0.2, d.yFoot, 0.03], [d.x0 + tF / 2 + 0.06, d.yFoot / 2 + 0.02, s * (fw / 2 - 0.05)])\n  for (const s of [-1, 1]) for (const dx of [-0.08, 0.2]) k.hex('nut', 0.036, 0.02, [d.x0 + tF / 2 + dx, 0.04, s * (fw / 2 - 0.12)])\n  for (const s of [-1, 1]) liftingLug(k, [d.x0 + tF / 2, yc + fh / 2, s * fw * 0.3], 'z', 0.9, P)\n  // plate pack: stainless plates with black gasket lines between, chevron-pressed area not visible edge-on\n  k.box('rubberBlack', [d.pack, ph - 0.012, pw - 0.012], [(d.xA + d.xB) / 2, yc, 0])\n  const n = Math.round(d.pack / p.pitch)\n  for (let i = 0; i < n; i++) {\n    const x = d.xA + (i + 0.5) * p.pitch\n    k.rbox('stainless', [Math.min(0.0012, p.pitch * 0.4), ph, pw], 0.03, [x, yc, 0], undefined, 1)\n  }\n  // movable pressure plate with roller on the carrying bar\n  k.rbox(P, [tP, fh - 0.06, fw], 0.02, [d.xB + tP / 2, yc - 0.03, 0])\n  const yBar = yc + ph / 2 + 0.07\n  k.box(P, [tP * 0.8, 0.14, 0.1], [d.xB + tP / 2, yBar + 0.03, 0])\n  k.cyl('castIron', 0.045, 0.06, [d.xB + tP / 2, yBar + 0.11, 0], 'z', 20)\n  // carrying bar (top) and guide bar (bottom), stainless clad\n  const xEnd = d.xC\n  k.box('stainless', [xEnd - d.x0 - tF + 0.02, 0.12, 0.05], [(d.x0 + tF + xEnd) / 2, yBar + 0.12, 0])\n  const yGuide = yc - ph / 2 - 0.05\n  k.box('stainless', [xEnd - d.x0 - tF + 0.02, 0.06, 0.04], [(d.x0 + tF + xEnd) / 2, yGuide, 0])\n  // support column with base plate\n  const yTop = yBar + 0.2\n  member(k, [xEnd, 0.025, 0], [xEnd, yTop, 0], 'HEB 160', P, [1, 0, 0])\n  k.box(P, [0.3, 0.025, 0.3], [xEnd, 0.0125, 0])\n  k.box(P, [0.22, 0.02, 0.22], [xEnd, yTop + 0.01, 0])\n  for (const sx of [-1, 1]) for (const sz of [-1, 1]) k.hex('nut', 0.034, 0.018, [xEnd + sx * 0.1, 0.034, sz * 0.1])\n  // tightening bolts in the side slots, nuts and washers at both plates\n  const nb = 2 + Math.floor(ph / 0.55)\n  const xb0 = d.x0 - 0.04\n  const xb1 = d.xPP + 0.12 + 0.12 * d.pack\n  for (const s of [-1, 1]) {\n    const z = s * (pw / 2 + db * 1.7)\n    for (let i = 0; i < nb; i++) {\n      const y = yc - fh / 2 + 0.12 + ((fh - 0.3) * i) / (nb - 1)\n      k.rod('boltXylan', [xb0, y, z], [xb1, y, z], db / 2, 10)\n      for (const [x, dir] of [\n        [d.x0, -1],\n        [d.xPP, 1],\n      ] as const) {\n        k.cyl('flangeSteel', db * 1.25, 0.008, [x + dir * 0.004, y, z], 'x', 16)\n        if (dir < 0) k.box('castIron', [db * 1.1, db * 2.4, db * 2.4], [x - 0.008 - db * 0.55, y, z]) // bearing box under the bolt head\n        k.hex('nut', db * 1.9, dir < 0 ? db * 0.95 : db * 1.6, [x + dir * (0.008 + (dir < 0 ? db * 1.1 + db * 0.48 : db * 0.8)), y, z], 'x')\n      }\n      // plastic protection sleeve over the exposed thread behind the pressure plate\n      const xs0 = d.xPP + 0.008 + db * 1.6\n      k.cyl('plasticGrey', db * 0.75, xb1 - xs0 + 0.01, [(xs0 + xb1) / 2 + 0.005, y, z], 'x', 12)\n    }\n  }\n  // four connections on the fixed frame plate, with port liners\n  for (const [, , sz, sy] of CONN) {\n    const base: V3 = [d.x0, yc + sy * d.py, sz * d.pz]\n    openNozzle(k, { base, dir: [-1, 0, 0], nps: p.nps, rating: p.rating, standout: d.so, mat: 'stainless' })\n    k.torus('rubberBlack', pipeOD(p.nps) / 2 + 0.012, 0.01, [d.x0 - 0.004, base[1], base[2]], 'x', 8, 32)\n  }\n  tagPlate(k, p.tag, [d.x0 + tF / 2, yc, fw / 2 + 0.001], 'z', 0.18)\n  k.box('nameplate', [0.16, 0.1, 0.002], [d.x0 + tF / 2, yc - 0.14, fw / 2 + 0.001])\n  earthBoss(k, [d.x0 + tF / 2, d.yFoot + 0.15, fw / 2 + 0.025], 'z')\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": "PlateHeatExchanger",
    "anchor": "base",
    "standards": [
      "ASME BPVC VIII-1",
      "EN 13445",
      "ASME B16.5",
      "API 662 / ISO 15547-1"
    ]
  }
}