{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sight-glass",
  "type": "registry:component",
  "title": "Sight glass",
  "description": "Flanged double-window sight flow indicator: hollow body with the flow bore open through, opposed borosilicate windows on gaskets in bolted square retainers, and a hinged stainless flapper that swings with flow. Face-to-face from typical vendor tables for class 150 / 300; integral raised-face flanges.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "piping/sight-glass.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/piping/sight-glass.tsx",
      "content": "// Flanged double-window flow sight glass: hollow forged body, opposed borosilicate windows in bolted square retainers, optional flapper indicator. Typical vendor face-to-face, B16.5 flanges.\nimport * as THREE from 'three'\nimport type { Kit, MatKey, V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { tagPlate } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { inch, pipeOD, type Rating } from '../lib/standards'\nimport { boreOf, flangeLength, portFlange } from './_piping'\n\nexport interface SightGlassProps {\n  nps?: number\n  rating?: Rating\n  indicator?: 'none' | 'flapper'\n  /** flapper deflection, 0 no flow … 1 full flow */\n  flow?: number\n  body?: MatKey\n}\n\nexport const defaults = { nps: 4, rating: 150 as Rating, indicator: 'flapper' as 'none' | 'flapper', flow: 0.5, body: 'valveBody' as MatKey }\n\n// typical: double-window flanged sight glass face-to-face (inches), class 150 and 300\nconst FF150: [number, number][] = [[0.5, 5], [1, 5.63], [1.5, 6.75], [2, 7.88], [3, 9.38], [4, 11], [6, 14.25], [8, 16.13]]\nconst FF300: [number, number][] = [[0.5, 5.5], [1, 6.25], [1.5, 7.5], [2, 8.63], [3, 11], [4, 13.25], [6, 21.38], [8, 23]]\nconst lerp = (t: [number, number][], x: number) => {\n  if (x <= t[0][0]) return t[0][1]\n  for (let i = 1; i < t.length; i++) if (x <= t[i][0]) return t[i - 1][1] + ((t[i][1] - t[i - 1][1]) * (x - t[i - 1][0])) / (t[i][0] - t[i - 1][0])\n  return t[t.length - 1][1] * (x / t[t.length - 1][0])\n}\nfunction length(nps: number, rating: Rating) {\n  const a = inch(rating <= 150 ? lerp(FF150, nps) : lerp(FF300, nps) * (rating <= 300 ? 1 : 1.2))\n  return Math.max(a, 2 * flangeLength(nps, rating) + pipeOD(nps) * 1.1)\n}\n\nexport function ports(p: SightGlassProps = {}): Port[] {\n  const { nps, rating } = { ...defaults, ...p }\n  const L = length(nps, rating)\n  return [port('A', 'Inlet', [-L / 2, 0, 0], [-1, 0, 0], nps, rating), port('B', 'Outlet', [L / 2, 0, 0], [1, 0, 0], nps, rating)]\n}\n\nexport function SightGlass({ nps = defaults.nps, rating = defaults.rating, indicator = defaults.indicator, flow = defaults.flow, body = defaults.body, ...group }: SightGlassProps & GroupProps) {\n  const data = useKit((k) => build(k, nps, rating, body), [nps, rating, body])\n  const fl = useKit((k) => flapper(k, nps), [nps])\n  const D = pipeOD(nps)\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n      {indicator === 'flapper' && (\n        <group position={[-D * 0.25, D * 0.5, 0]} rotation-z={-Math.min(Math.max(flow, 0), 1) * 0.9}>\n          <KitMesh data={fl} />\n        </group>\n      )}\n    </group>\n  )\n}\n\nexport default SightGlass\n\nconst rect = (w: number, h: number, hole?: number) => {\n  const s = new THREE.Shape()\n  s.moveTo(-w / 2, -h / 2).lineTo(w / 2, -h / 2).lineTo(w / 2, h / 2).lineTo(-w / 2, h / 2).closePath()\n  if (hole) {\n    const p = new THREE.Path()\n    p.absarc(0, 0, hole, 0, Math.PI * 2, true)\n    s.holes.push(p)\n  }\n  return s\n}\n// extrude a shape lying in the plane (u, v) by depth along n, starting at `at`\nconst plate = (k: Kit, mat: MatKey, s: THREE.Shape, depth: number, at: V3, u: V3, v: V3, n: V3) =>\n  k.extrude(mat, s, depth, new THREE.Matrix4().makeBasis(new THREE.Vector3(...u), new THREE.Vector3(...v), new THREE.Vector3(...n)).setPosition(...at), 32)\n\nfunction build(k: Kit, nps: number, rating: Rating, mat: MatKey) {\n  const D = pipeOD(nps)\n  const L = length(nps, rating)\n  const fl = flangeLength(nps, rating)\n  const bore = boreOf(nps, '40')\n  for (const s of [-1, 1]) portFlange(k, nps, rating, [(s * L) / 2, 0, 0], [s, 0, 0], { mat, bore })\n  // hollow body: two end walls with the bore, two window walls, top and bottom\n  const Lb = L - 2 * fl\n  const H = D * 1.25 + 0.02\n  const W = D * 1.15 + 0.02\n  const t = Math.max(0.01, D * 0.11)\n  const rg = Math.min(Lb / 2 - t - 0.008, H / 2 - t - 0.004) * 0.92\n  for (const s of [-1, 1]) {\n    plate(k, mat, rect(W, H, bore), t, [s > 0 ? Lb / 2 - t : -Lb / 2, 0, 0], [0, 0, 1], [0, 1, 0], [1, 0, 0])\n    plate(k, mat, rect(Lb - 2 * t, H, rg), t, [0, 0, s > 0 ? W / 2 - t : -W / 2], [1, 0, 0], [0, 1, 0], [0, 0, 1])\n    k.box(mat, [Lb - 2 * t, t, W - 2 * t], [0, s * (H / 2 - t / 2), 0])\n  }\n  // windows: glass seated on the window wall, gasket, square retainer with four bolts\n  const sq = 2 * rg + Math.max(0.035, D * 0.3)\n  const tr = Math.max(0.012, D * 0.1)\n  const bd = Math.max(0.008, Math.min(0.02, D * 0.09))\n  for (const s of [-1, 1]) {\n    const z0 = s * (W / 2)\n    k.lathe('gasket', [[rg, -0.001], [rg + 0.008, -0.001], [rg + 0.008, 0.001], [rg, 0.001], [rg, -0.001]], [0, 0, z0 + s * 0.001], 'z', 40)\n    k.cyl('glass', rg + 0.006, 0.012, [0, 0, z0 + s * 0.008], 'z', 40)\n    plate(k, mat, rect(sq, sq, rg * 0.92), tr, [0, 0, s > 0 ? z0 + 0.002 : z0 - 0.002 - tr], [1, 0, 0], [0, 1, 0], [0, 0, 1])\n    for (const cx of [-1, 1])\n      for (const cy of [-1, 1]) {\n        const p: V3 = [cx * (sq / 2 - bd * 1.3), cy * (sq / 2 - bd * 1.3), z0 + s * (0.002 + tr)]\n        k.hex('nut', bd * 1.8, bd * 0.8, [p[0], p[1], p[2] + s * bd * 0.4], 'z')\n        k.cyl('boltXylan', bd / 2, bd * 0.6, [p[0], p[1], p[2] + s * bd * 1.0], 'z', 8)\n      }\n  }\n  // hinge bosses for the flapper pin, cast flow arrow and tag on top\n  for (const s of [-1, 1]) k.cyl(mat, D * 0.08, 0.01, [-D * 0.25, D * 0.5, s * (W / 2 - t - 0.005)], 'z', 12)\n  k.cyl('stainless', D * 0.03, W - 2 * t, [-D * 0.25, D * 0.5, 0], 'z', 8)\n  const arrow = new THREE.Shape()\n  const a = Math.min(Lb * 0.5, D * 0.9)\n  arrow.moveTo(-a / 2, -0.008).lineTo(a / 4, -0.008).lineTo(a / 4, -0.018).lineTo(a / 2, 0).lineTo(a / 4, 0.018).lineTo(a / 4, 0.008).lineTo(-a / 2, 0.008).closePath()\n  plate(k, mat, arrow, 0.003, [0, H / 2, -W * 0.2], [1, 0, 0], [0, 0, 1], [0, 1, 0])\n  tagPlate(k, `${nps}\" CL${rating}`, [0, H / 2 + 0.0008, W * 0.22], 'y', Math.min(0.08, Lb * 0.4))\n}\n\n/** Hinged flapper hanging from its pin, swings downstream (+X) with flow. */\nfunction flapper(k: Kit, nps: number) {\n  const D = pipeOD(nps)\n  const h = D * 0.85\n  k.box('stainless', [0.003, h, D * 0.7], [0, -h / 2, 0])\n  k.cyl('stainless', D * 0.045, D * 0.72, [0, 0, 0], 'z', 10)\n}\n"
    },
    {
      "path": "piping/_piping.ts",
      "type": "registry:file",
      "target": "components/deck-twin/piping/_piping.ts",
      "content": "// Shared piping geometry: port flanges with joint studs, B16.25 bevelled pipe, grid-swept surfaces, ISO 14726 bands, clad insulation, small valves.\nimport * as THREE from 'three'\nimport { mat4, type Kit, type MatKey, type Orient, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { textMaterial } from '../lib/textures'\nimport { GASKET_SPIRAL_WOUND, SERVICE, flange as flangeDims, pipeOD, pipeWall, studLength, type Rating, type Schedule, type Service } from '../lib/standards'\n\nexport const GAP = GASKET_SPIRAL_WOUND\n/** Radial segments for a size; every lathe and ring sheet of that size uses it so joints share vertices. */\nexport const SEG = (nps: number) => (nps >= 10 ? 64 : nps >= 4 ? 48 : 32)\nexport const add = (a: V3, d: V3, s = 1): V3 => [a[0] + d[0] * s, a[1] + d[1] * s, a[2] + d[2] * s]\nexport const neg = (d: V3): V3 => [-d[0], -d[1], -d[2]]\n\n/** Weld end to raised face of a WN flange. */\nexport function flangeLength(nps: number, rating: Rating) {\n  const f = flangeDims(nps, rating)\n  return f.hubLength + f.raisedFaceHeight\n}\n\nexport interface FlangeOpts {\n  /** draw the back half of the joint studs and nuts (default true) */\n  studs?: boolean\n  /** body material for integral cast / forged flanges; default a forged WN flange in flange steel */\n  mat?: MatKey\n  /** bore radius, m (default: Sch 40 / STD bore of the size) */\n  bore?: number\n}\n\n/**\n * Weld-neck (or integral) RF flange whose raised face is a port face pointing along `dir`: closed solid with the bore\n * open through, plus the back half of the joint studs and nuts exactly where the plant assembler draws its mating\n * joint. Returns weld-end-to-face length.\n */\nexport function portFlange(k: Kit, nps: number, rating: Rating, face: V3, dir: V3, o: FlangeOpts = {}) {\n  const f = flangeDims(nps, rating)\n  const ro = pipeOD(nps) / 2\n  const ri = Math.min(o.bore ?? ro - pipeWall(nps, 'STD'), ro - 0.0015)\n  const rf = f.raisedFaceHeight\n  const L = f.hubLength + rf\n  const back = L - rf - f.thickness\n  const hubR = Math.max(f.hubDia / 2, ro * 1.05)\n  const cast = o.mat !== undefined\n  const mat: MatKey = o.mat ?? 'flangeSteel'\n  const r0 = cast ? Math.max(ro * 1.08, ri + 0.004) : ro\n  const seg = SEG(nps)\n  const base = add(face, dir, -L)\n  k.lathe(mat, [[ri, 0], [r0, 0], [hubR, Math.max(back - 0.004, back * 0.7)], [hubR + 0.004, back], [f.od / 2, back], [f.od / 2, L - rf], [f.raisedFace / 2, L - rf]], base, dir, seg)\n  k.lathe('machined', [[f.raisedFace / 2, L - rf], [f.raisedFace / 2, L], [ri, L]], base, dir, seg)\n  k.lathe('pipeBore', [[ri, L], [ri, 0]], base, dir, seg)\n  if (o.studs !== false) {\n    const half = studLength(nps, rating) / 2\n    k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, add(face, dir, GAP / 2 - half), dir, f.boltDia, half, [f.boltDia * 0.6])\n  }\n  return L\n}\n\n/** Bore radius of a pipe size and schedule. */\nexport const boreOf = (nps: number, schedule: Schedule) => pipeOD(nps) / 2 - pipeWall(nps, schedule)\n\n/** Full joint stud set (studs + two nuts each) centred on the gasket plane at `centre`, as the assembler draws it. */\nexport function jointStuds(k: Kit, nps: number, rating: Rating, centre: V3, dir: V3, studL = studLength(nps, rating)) {\n  const f = flangeDims(nps, rating)\n  k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, add(centre, dir, -studL / 2), dir, f.boltDia, studL, [f.boltDia * 0.6, studL - f.boltDia * 0.6])\n}\n\nconst BEVEL = Math.tan((37.5 * Math.PI) / 180)\nexport const LAND = 0.0016\n\n/** Axial depth of an ASME B16.25 37.5° weld bevel with a 1.6 mm land. */\nexport const bevelDepth = (nps: number, schedule: Schedule) => Math.max(0, pipeWall(nps, schedule) - LAND) * BEVEL\n\n/** Straight pipe from `start` along `dir` for length L. Ends: 'bevel' (machined B16.25 prep), 'square' (closed wall end) or 'open' (no end face, for joints). */\nexport type PipeEnd = 'bevel' | 'square' | 'open'\nexport function straight(k: Kit, mat: MatKey, start: V3, dir: V3, L: number, nps: number, schedule: Schedule, endA: PipeEnd = 'square', endB: PipeEnd = 'square', seg = SEG(nps)) {\n  if (L <= 1e-5) return\n  const ro = pipeOD(nps) / 2\n  const ri = ro - pipeWall(nps, schedule)\n  const h = Math.min(bevelDepth(nps, schedule), L / 3)\n  const hA = endA === 'bevel' ? h : 0\n  const hB = endB === 'bevel' ? h : 0\n  k.lathe(mat, [[ro, hA], [ro, L - hB]], start, dir, seg)\n  if (hA) k.lathe('machined', [[ri, 0], [ri + LAND, 0], [ro, hA]], start, dir, seg)\n  else if (endA === 'square') k.lathe(mat, [[ri, 0], [ro, 0]], start, dir, seg)\n  if (hB) k.lathe('machined', [[ro, L - hB], [ri + LAND, L], [ri, L]], start, dir, seg)\n  else if (endB === 'square') k.lathe(mat, [[ro, L], [ri, L]], start, dir, seg)\n  k.lathe('pipeBore', [[ri, L], [ri, 0]], start, dir, seg)\n}\n\n/**\n * Quad mesh from rows of points P[i][j]. With `normal`, normals are analytic and the winding follows them;\n * otherwise `out` (a rough outward direction) picks the winding and normals are computed.\n */\nexport function sheet(k: Kit, mat: MatKey, P: V3[][], normal?: (i: number, j: number) => V3, out?: (i: number, j: number) => V3) {\n  const nr = P.length\n  const nc = P[0].length\n  const pos: number[] = []\n  const nor: number[] = []\n  for (let i = 0; i < nr; i++)\n    for (let j = 0; j < nc; j++) {\n      pos.push(...P[i][j])\n      if (normal) nor.push(...normal(i, j))\n    }\n  const V = (q: V3) => new THREE.Vector3(...q)\n  let sign = 0\n  for (let i = 0; i < nr - 1 && !sign; i++)\n    for (let j = 0; j < nc - 1 && !sign; j++) {\n      const a = V(P[i][j])\n      const n = V(P[i + 1][j]).sub(a).cross(V(P[i][j + 1]).sub(a))\n      if (n.lengthSq() < 1e-14) continue\n      const ref = normal?.(i, j) ?? out?.(i, j)\n      sign = !ref || n.dot(V(ref)) >= 0 ? 1 : -1\n    }\n  const idx: number[] = []\n  for (let i = 0; i < nr - 1; i++)\n    for (let j = 0; j < nc - 1; j++) {\n      const a = i * nc + j\n      const b = a + nc\n      const c = a + 1\n      const d = b + 1\n      if (sign >= 0) idx.push(a, b, c, b, d, c)\n      else idx.push(a, c, b, b, c, d)\n    }\n  const g = new THREE.BufferGeometry()\n  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3))\n  if (normal) g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3))\n  g.setIndex(idx)\n  if (!normal) g.computeVertexNormals()\n  k.add(mat, g)\n}\n\n/** Circumferential butt weld cap: slightly proud bead. */\nexport function buttWeld(k: Kit, nps: number, pos: V3, dir: V3) {\n  const r = pipeOD(nps) / 2\n  const w = Math.max(0.006, Math.min(0.02, pipeWall(nps, 'STD') * 1.6))\n  k.torus('flangeSteel', r + 0.0006, w / 2, pos, dir, 6, nps >= 6 ? 48 : 32)\n}\n\n/**\n * Surface swept as a grid: f(s, phi) returns a local point (s along +Y from 0 to 1, phi around); x = .. sin(phi), z = .. cos(phi)\n * keeps the same handedness as a lathe so normals face out. Placed at `pos` with local +Y on `axis`.\n */\nexport function gridSurface(k: Kit, mat: MatKey, f: (s: number, phi: number) => V3, nu: number, nv: number, pos: V3 = [0, 0, 0], axis: Orient = 'y', inward = false) {\n  const p: number[] = []\n  const uv: number[] = []\n  const idx: number[] = []\n  for (let i = 0; i <= nu; i++)\n    for (let j = 0; j <= nv; j++) {\n      const q = f(i / nu, (j / nv) * Math.PI * 2)\n      p.push(q[0], q[1], q[2])\n      uv.push(j / nv, i / nu)\n    }\n  for (let i = 0; i < nu; i++)\n    for (let j = 0; j < nv; j++) {\n      const a = i * (nv + 1) + j\n      const b = a + nv + 1\n      if (inward) idx.push(a, b, a + 1, b, b + 1, a + 1)\n      else idx.push(a, a + 1, b, b, a + 1, b + 1)\n    }\n  const g = new THREE.BufferGeometry()\n  g.setAttribute('position', new THREE.Float32BufferAttribute(p, 3))\n  g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))\n  g.setIndex(idx)\n  g.computeVertexNormals()\n  // weld the seam normals (phi = 0 and 2π are duplicate vertices)\n  const n = g.getAttribute('normal') as THREE.BufferAttribute\n  const t = new THREE.Vector3()\n  for (let i = 0; i <= nu; i++) {\n    const a = i * (nv + 1)\n    const b = a + nv\n    t.set(n.getX(a) + n.getX(b), n.getY(a) + n.getY(b), n.getZ(a) + n.getZ(b)).normalize()\n    n.setXYZ(a, t.x, t.y, t.z)\n    n.setXYZ(b, t.x, t.y, t.z)\n  }\n  k.add(mat, g, mat4(pos, axis))\n}\n\n/** ISO 14726 identification band (basic colour) around a pipe of outer radius r. */\nexport function serviceBand(k: Kit, service: Service, pos: V3, dir: V3, r: number) {\n  const w = Math.min(0.15, Math.max(0.05, r * 1.6 + 0.02))\n  k.cyl(paint(SERVICE[service].band), r + 0.0012, w, pos, dir, 40, undefined, true)\n  return w\n}\n\n/** typical: insulation thickness for hot / personnel-protection service, m. */\nexport const insulationThickness = (nps: number) => (nps <= 2 ? 0.04 : nps <= 6 ? 0.05 : nps <= 12 ? 0.065 : 0.075)\n\n/**\n * Aluminium-jacketed insulation from `start` along `dir` for length L on pipe NPS `nps`: bevelled weather end caps,\n * 0.9 m sheet laps and stainless bands at 229 mm. Returns the jacket radius.\n */\nexport function cladding(k: Kit, start: V3, dir: V3, L: number, nps: number) {\n  const ro = pipeOD(nps) / 2\n  const R = ro + insulationThickness(nps)\n  const cap = Math.min(insulationThickness(nps) * 0.9, L / 4)\n  if (L < 2 * cap + 0.02) return R\n  k.lathe('insulation', [[ro + 0.001, 0], [R, cap], [R, L - cap], [ro + 0.001, L]], start, dir, 48)\n  const at = (s: number) => add(start, dir, s)\n  for (let s = 0.9; s < L - cap - 0.05; s += 0.9) k.cyl('insulation', R + 0.0009, 0.05, at(s), dir, 48, undefined, true)\n  // bands at most 229 mm (9 in) on centre\n  const n = Math.max(1, Math.ceil((L - 2 * cap) / 0.229))\n  for (let i = 0; i <= n; i++) {\n    const s = cap + 0.04 + ((L - 2 * cap - 0.08) * i) / n\n    k.cyl('stainless', R + 0.0016, 0.019, at(s), dir, 48, undefined, true)\n  }\n  return R\n}\n\n/** Small quarter-turn ball valve centred at `pos`, bore along `dir`, lever lying along `lever` (unit, not parallel to dir). */\nexport function smallBallValve(k: Kit, nps: number, pos: V3, dir: V3, lever: V3, leverMat: MatKey = 'actuatorRed', bodyMat: MatKey = 'stainless') {\n  const d = pipeOD(nps)\n  const L = d * 2.6\n  const D = new THREE.Vector3(...dir).normalize()\n  const up = new THREE.Vector3(...lever).normalize()\n  const stem = new THREE.Vector3().crossVectors(up, D).normalize()\n  const S = stem.toArray() as V3\n  k.cyl(bodyMat, d * 0.62, L * 0.55, pos, dir, 16)\n  k.sphere(bodyMat, d * 0.85, pos, undefined, 16)\n  for (const s of [-1, 1]) k.hex(bodyMat, d * 1.5, L * 0.22, add(pos, dir, (s * L) / 2 - s * L * 0.11), dir)\n  const top = add(pos, S, d * 1.15)\n  k.rod(bodyMat, add(pos, S, d * 0.6), top, d * 0.18, 8)\n  k.hex('nut', d * 0.5, d * 0.25, top, S)\n  const ll = Math.max(0.1, d * 4.5)\n  const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(up, stem, up.clone().cross(stem)))\n  k.box(leverMat, [ll, d * 0.12, d * 0.4], add(add(top, S, d * 0.18), [up.x, up.y, up.z], ll / 2 - d * 0.2), q)\n  return L\n}\n\n/** ASME B16.20 spiral-wound gasket (approx dims): inner ring, 316L / graphite winding, colour-coded centring ring against the studs. */\nexport function spiralWound(k: Kit, nps: number, rating: Rating, centre: V3, dir: V3) {\n  const f = flangeDims(nps, rating)\n  const ro = pipeOD(nps) / 2\n  const wi = ro + 0.003\n  const wo = f.raisedFace / 2 - 0.0015\n  const co = f.boltCircle / 2 - f.boltDia / 2 - 0.001\n  const t = 0.0032\n  const ring = (mat: MatKey, r0: number, r1: number, h: number) => k.lathe(mat, [[r0, -h / 2], [r1, -h / 2], [r1, h / 2], [r0, h / 2], [r0, -h / 2]], centre, dir, 48)\n  ring('stainless', ro * 0.94, wi, t)\n  ring('gasket', wi, wo, GAP)\n  ring('galvanised', wo, co, t)\n  // periphery colour code: continuous green (316L winding) with grey stripes (flexible graphite filler)\n  k.cyl(paint('#2f8f46'), co + 0.0004, t * 0.9, centre, dir, 48, undefined, true)\n  const d = new THREE.Vector3(...dir).normalize()\n  const ref = Math.abs(d.y) > 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0)\n  const u = new THREE.Vector3().crossVectors(d, ref).normalize()\n  const w = new THREE.Vector3().crossVectors(d, u)\n  for (let i = 0; i < 4; i++) {\n    const a = (i / 4) * Math.PI * 2 + 0.4\n    const p = new THREE.Vector3(...centre).addScaledVector(u, Math.cos(a) * (co + 0.0008)).addScaledVector(w, Math.sin(a) * (co + 0.0008))\n    const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(d, u.clone().multiplyScalar(Math.cos(a)).addScaledVector(w, Math.sin(a)), d.clone().cross(u.clone().multiplyScalar(Math.cos(a)).addScaledVector(w, Math.sin(a)))))\n    k.box(paint('#5a5d60'), [t * 0.95, 0.001, 0.012], p.toArray() as V3, q)\n  }\n}\n\n/** Laminated joint tag cable-tied to the outermost stud on the +Z side, hanging past the nut. */\nexport function jointTag(k: Kit, nps: number, rating: Rating, tag: string, studL = studLength(nps, rating)) {\n  const f = flangeDims(nps, rating)\n  let best = 0\n  let bz = -1\n  for (let i = 0; i < f.bolts; i++) {\n    const a = Math.PI / f.bolts + (i / f.bolts) * Math.PI * 2\n    if (Math.cos(a) > bz) [bz, best] = [Math.cos(a), a]\n  }\n  const R = f.boltCircle / 2\n  const y = -Math.sin(best) * R\n  const z = Math.cos(best) * R\n  const x = studL / 2 - f.boltDia * 0.1\n  k.torus('rubberBlack', f.boltDia * 0.62, 0.0012, [x, y, z], 'x', 4, 16)\n  const q = new THREE.Quaternion()\n  const p: V3 = [x + 0.001, y - f.boltDia * 0.6 - 0.025, z + f.boltDia * 0.2]\n  k.box('plasticGrey', [0.0015, 0.05, 0.034], p, q)\n  k.plane(textMaterial(tag, '#f4c400', '#111111', 256, 128), 0.032, 0.046, [p[0] + 0.0009, p[1], p[2]], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2))\n}\n\nexport interface Ring {\n  c: V3\n  e1: V3\n  e2: V3\n  r: number\n}\n\n/** Sheet through rings: point = c + r (cos a e1 + sin a e2) for each angle a. Radial normals, or computed with `out` picking the winding. */\nexport function ringSheet(k: Kit, mat: MatKey, rings: Ring[], angles: number[], normals: 'out' | 'in' | 'computed', out?: V3) {\n  const P = rings.map((g) => angles.map((a) => [0, 1, 2].map((i) => g.c[i] + g.r * (Math.cos(a) * g.e1[i] + Math.sin(a) * g.e2[i])) as V3))\n  const rad = (i: number, j: number): V3 => {\n    const g = rings[i]\n    const a = angles[j]\n    const sg = normals === 'in' ? -1 : 1\n    return [0, 1, 2].map((q) => sg * (Math.cos(a) * g.e1[q] + Math.sin(a) * g.e2[q])) as V3\n  }\n  if (normals === 'computed') sheet(k, mat, P, undefined, (i, j) => (out ? add(out, rad(i, j), 0.3) : rad(i, j)))\n  else sheet(k, mat, P, rad)\n}\n\n/** Uniform angles 0…2π for n segments. */\nexport const turn = (n: number) => Array.from({ length: n + 1 }, (_, j) => (j / n) * Math.PI * 2)\n\n/**\n * B16.25 weld prep on a ring-sampled end: bevel from the outer skin at `inner` (h back from the face) to the land at\n * the face `c`, then the land down to the bore. t points out of the end.\n */\nexport function bevelEnd(k: Kit, c: V3, t: V3, e1: V3, e2: V3, ro: number, ri: number, h: number, angles: number[]) {\n  const back = add(c, t, -h)\n  ringSheet(k, 'machined', [{ c: back, e1, e2, r: ro }, { c, e1, e2, r: ri + LAND }], angles, 'computed', t)\n  ringSheet(k, 'machined', [{ c, e1, e2, r: ri + LAND }, { c, e1, e2, r: ri }], angles, 'computed', t)\n}\n\nexport interface Hole {\n  /** half-width of the hole edge across the shell (z) */\n  A: number\n  /** half-length of the hole edge along the shell (x) */\n  B: number\n}\n\nconst NH = 24\n/** Angles shared by holed shells and the nozzles that fit them (2·NH + 1 samples from -π/2). */\nexport const nozzleAngles = Array.from({ length: 2 * NH + 1 }, (_, j) => -Math.PI / 2 + (j * Math.PI) / NH)\nconst ysq = (R: number, z: number) => Math.sqrt(Math.max(R * R - z * z, 0))\n\n/**\n * Cylinder of radius R along local X from x0 to x1 with optional holes centred at x = 0 on +Y (top) and -Y (bottom).\n * Hole edge: (B cos φ, ±√(R² − z²), z = ±A sin φ). Points are (x, R cos θ, R sin θ); returns the row angles θ.\n */\nexport function holedShell(k: Kit, mat: MatKey, R: number, x0: number, x1: number, top: Hole | null, bottom: Hole | null, outward: boolean) {\n  const rows: { th: number; xb: number }[] = []\n  const band = (h: Hole | null, c: number) => {\n    if (!h) return void rows.push({ th: c, xb: 0 })\n    for (let j = 0; j <= NH; j++) rows.push({ th: c + Math.asin((h.A * Math.sin(nozzleAngles[j])) / R), xb: h.B * Math.cos(nozzleAngles[j]) })\n  }\n  const fill = (a: number, b: number, n: number) => {\n    for (let i = 1; i < n; i++) rows.push({ th: a + ((b - a) * i) / n, xb: 0 })\n  }\n  const t1 = top ? Math.asin(Math.min(top.A / R, 1)) : 0\n  const t2 = bottom ? Math.asin(Math.min(bottom.A / R, 1)) : 0\n  band(top, 0)\n  fill(t1, Math.PI - t2, 20)\n  band(bottom, Math.PI)\n  fill(Math.PI + t2, 2 * Math.PI - t1, 20)\n  rows.push({ ...rows[0], th: rows[0].th + 2 * Math.PI })\n  const pt = (x: number, th: number): V3 => [x, R * Math.cos(th), R * Math.sin(th)]\n  const sg = outward ? 1 : -1\n  const n = (i: number): V3 => [0, sg * Math.cos(rows[i].th), sg * Math.sin(rows[i].th)]\n  sheet(k, mat, rows.map((r) => [pt(Math.max(r.xb, 0), r.th), pt(x1, r.th)]), (i) => n(i))\n  sheet(k, mat, rows.map((r) => [pt(x0, r.th), pt(-Math.max(r.xb, 0), r.th)]), (i) => n(i))\n  return rows.map((r) => r.th)\n}\n\n/** Nozzle skin of radius r (= hole A = B) from its intersection with a shell of radius R out to |y| = yEnd, on side ±1 of a holedShell. */\nexport function nozzleSkin(k: Kit, mat: MatKey, R: number, r: number, side: 1 | -1, yEnd: number, outward: boolean) {\n  const base = nozzleAngles.map((f) => [r * Math.cos(f), side * ysq(R, r * Math.sin(f)), side * r * Math.sin(f)] as V3)\n  const end = nozzleAngles.map((f) => [r * Math.cos(f), side * yEnd, side * r * Math.sin(f)] as V3)\n  const sg = outward ? 1 : -1\n  sheet(k, mat, [base, end], (_, j) => [sg * Math.cos(nozzleAngles[j]), 0, sg * side * Math.sin(nozzleAngles[j])])\n}\n"
    }
  ],
  "categories": [
    "piping"
  ],
  "meta": {
    "export": "SightGlass",
    "anchor": "inline",
    "standards": [
      "ASME B16.5",
      "ASME B31.3"
    ]
  }
}