krishnax12/tensa-zangetsu-keychain-nfc

This code defines a PCB footprint and 3D model for an NFC-enabled electronic component featuring an NXP NT2H1311F0DTLH chip, a PCB copper coil antenna, and a custom-shaped, silkscreen-illustrated metal blade housing.

Version
0.0.3
License
unset
Stars
1

index.tsx

PCBPCB preview for index.tsx
SchematicSchematic preview for index.tsx
import React from "react"
import { NT2H1311F0DTLH } from "./imports/NT2H1311F0DTLH/NT2H1311F0DTLH"

type XY = [number, number]

const points = (values: XY[]) => values.map(([x, y]) => ({ x, y }))

// Existing board outline — unchanged.
const outline = points([
  [18.58, 113.94],
  [7.5, 103.67],
  [1.79, 96.82],
  [0.49, 94.87],
  [0.49, 93.4],
  [-0.65, 92.91],
  [-3.1, 88.51],
  [-5.71, 81.66],
  [-8.8, 63.08],
  [-9.45, 54.77],
  [-9.45, 52.81],
  [-8.8, 51.83],
  [-9.62, 50.86],
  [-9.94, 45.97],
  [-8.8, 44.01],
  [-9.94, 43.52],
  [-10.11, 42.54],
  [-10.6, 32.76],
  [-10.6, 31.79],
  [-8.8, 30.8],
  [-10.76, 29.34],
  [-11.08, 12.71],
  [-10.27, 11.25],
  [-11.57, 10.76],
  [-14.51, 5.38],
  [-16.14, 3.91],
  [-16.3, 3.42],
  [-16.14, -4.89],
  [-15.16, -7.33],
  [-15.49, -9.78],
  [-14.51, -15.65],
  [-15, -16.95],
  [-12.06, -18.91],
  [-7.66, -20.86],
  [-8.48, -13.69],
  [-9.62, -11.74],
  [-8.8, -10.43],
  [-8.96, -3.91],
  [-3.91, -0.82],
  [-2.28, -1.3],
  [-1.47, -40.1],
  [0.82, -40.75],
  [3.42, -39.77],
  [1.79, -13.37],
  [1.79, -1.63],
  [1.96, -0.65],
  [8.31, 2.93],
  [10.6, 6.19],
  [9.45, 6.52],
  [10.92, 7.5],
  [10.6, 11.9],
  [8.31, 12.39],
  [8.31, 13.37],
  [10.43, 13.86],
  [10.27, 16.79],
  [7.01, 17.28],
  [9.29, 17.77],
  [9.29, 20.21],
  [7.33, 20.7],
  [7.33, 21.19],
  [9.94, 21.68],
  [9.94, 24.61],
  [9.29, 25.59],
  [9.94, 27.06],
  [9.94, 34.88],
  [9.29, 35.37],
  [10.11, 38.3],
  [7.66, 38.79],
  [7.66, 39.28],
  [8.64, 40.75],
  [10.27, 41.24],
  [10.43, 47.11],
  [9.62, 47.6],
  [10.6, 49.55],
  [10.92, 54.44],
  [8.8, 54.93],
  [8.96, 57.87],
  [11.41, 60.8],
  [12.88, 76.94],
  [12.22, 81.83],
  [12.55, 96.01],
  [13.2, 96.5],
  [15, 103.83],
  [18.58, 113.61],
])

// Existing shaped guard opening — unchanged, unplated.
const guardOpening = points([
  [-13.6, -10.4],
  [-12.9, -9.8],
  [-11.3, -11],
  [-10.2, -14.2],
  [-9.9, -17.6],
  [-12.1, -16.4],
  [-13.1, -15.4],
  [-13.7, -11.1],
])

function Silk({ route, width = 0.25 }: { route: XY[]; width?: number }) {
  return (
    <silkscreenpath layer="top" strokeWidth={width} route={points(route)} />
  )
}

// Long white core with a pointed shoulder.
const core: XY[] = [
  [-2.9, 1.8],
  [-2.7, 28],
  [-2.1, 51],
  [-1.2, 66],
  [1.2, 74],
  [3.3, 66],
  [2.6, 51],
  [2.3, 28],
  [2.4, 4.7],
  [-2.9, 1.8],
]

// Overlapping silkscreen strokes create a solid fill.
function FilledCore() {
  const rows: React.ReactNode[] = []

  for (let y = 2; y < 73.8; y += 0.16) {
    const xs: number[] = []

    for (let i = 0; i < core.length - 1; i++) {
      const [ax, ay] = core[i]
      const [bx, by] = core[i + 1]

      if ((ay <= y && by > y) || (by <= y && ay > y)) {
        xs.push(ax + ((y - ay) * (bx - ax)) / (by - ay))
      }
    }

    xs.sort((a, b) => a - b)

    if (xs.length === 2 && xs[1] - xs[0] > 0.2) {
      rows.push(
        <Silk
          key={y.toFixed(2)}
          route={[
            [xs[0] + 0.09, y],
            [xs[1] - 0.09, y],
          ]}
          width={0.32}
        />,
      )
    }
  }

  return <>{rows}</>
}

// Left guard solid silkscreen fill.
function FilledLeftGuard() {
  type Point = { x: number; y: number }

  const inside = (x: number, y: number, polygon: Point[]) => {
    let result = false

    for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
      const a = polygon[i]
      const b = polygon[j]

      if (
        a.y > y !== b.y > y &&
        x < a.x + ((b.x - a.x) * (y - a.y)) / (b.y - a.y)
      ) {
        result = !result
      }
    }

    return result
  }

  const edgeDistance = (x: number, y: number, polygon: Point[]) => {
    let nearest = Infinity

    for (let i = 0; i < polygon.length; i++) {
      const a = polygon[i]
      const b = polygon[(i + 1) % polygon.length]
      const dx = b.x - a.x
      const dy = b.y - a.y

      const t = Math.max(
        0,
        Math.min(
          1,
          ((x - a.x) * dx + (y - a.y) * dy) / (dx * dx + dy * dy || 1),
        ),
      )

      nearest = Math.min(
        nearest,
        Math.hypot(x - a.x - t * dx, y - a.y - t * dy),
      )
    }

    return nearest
  }

  const rows: React.ReactNode[] = []

  for (let y = -20.5; y <= -3.2; y += 0.12) {
    let start: number | null = null

    for (let x = -16.5; x <= -7.2; x += 0.08) {
      const valid =
        inside(x, y, outline) &&
        !inside(x, y, guardOpening) &&
        edgeDistance(x, y, outline) >= 0.55 &&
        edgeDistance(x, y, guardOpening) >= 0.55

      if (valid && start === null) start = x

      if (!valid && start !== null) {
        if (x - 0.08 > start) {
          rows.push(
            <Silk
              key={`${y}-${start}`}
              route={[
                [start, y],
                [x - 0.08, y],
              ]}
              width={0.3}
            />,
          )
        }

        start = null
      }
    }
  }

  return <>{rows}</>
}

// Angular white panel borders adapted from the reference.
const panels: XY[][] = [
  // Tip facets.
  [
    [17.7, 112.2],
    [3.1, 96.2],
    [7.5, 93.1],
    [17.7, 112.2],
  ],
  [
    [2.5, 95.4],
    [-4.4, 81.7],
    [-7.1, 68.1],
    [-1.9, 63.6],
    [1.2, 73],
    [2.2, 83.3],
    [7.5, 92.3],
    [2.5, 95.4],
  ],
  [
    [2.2, 83.3],
    [5.7, 79.2],
    [11, 83.4],
    [11.7, 95.3],
    [17.7, 112.2],
  ],
  [
    [1.9, 81.8],
    [1.5, 74.8],
    [4.8, 68.2],
    [8.9, 73.2],
    [10.7, 81.7],
    [5.7, 77.6],
    [1.9, 81.8],
  ],

  // Left blade panels.
  [
    [-7.5, 66.3],
    [-8.2, 55],
    [-8.6, 40.2],
    [-3.5, 39.8],
    [-2.8, 61.5],
    [-7.5, 66.3],
  ],
  [
    [-8.6, 39],
    [-9.1, 31.8],
    [-9.5, 13],
    [-10.5, 10.3],
    [-5, 6.5],
    [-3.7, 14],
    [-3.5, 39],
    [-8.6, 39],
  ],

  // Right blade panels.
  [
    [4.4, 65.4],
    [3.5, 40.2],
    [7.2, 40.2],
    [7.2, 54],
    [9.4, 60.6],
    [8.9, 71],
    [4.4, 65.4],
  ],
  [
    [3.5, 39],
    [3.3, 15],
    [7.1, 10.1],
    [6.2, 17.5],
    [6.5, 29],
    [7, 39],
    [3.5, 39],
  ],

  // Guard facets.
  [
    [-10.8, 9.4],
    [-14.7, 3.4],
    [-10.5, -0.2],
    [-4.1, 4.5],
    [-10.8, 9.4],
  ],
  [
    [-9.8, -1],
    [-3.8, 3.4],
    [7.7, 8.8],
    [3.3, 13.5],
    [3.3, 6],
    [-3.3, 1.2],
    [-9.8, -2.7],
    [-9.8, -8.5],
  ],
  [
    [-15, 2],
    [-15, -4.6],
    [-14, -7.5],
  ],
  [
    [-14.2, 1.3],
    [-14.2, -4.4],
    [-13.5, -6.9],
  ],

  // Border around the shaped guard opening.
  [
    [-14.3, -9.1],
    [-13, -8.3],
    [-10.4, -10.5],
    [-9.2, -14],
    [-8.8, -18.9],
    [-12.7, -17.3],
    [-14, -15.8],
    [-14.3, -9.1],
  ],
]

// Follow the existing handle taper with inset silkscreen.
function gripPoint(t: number, side: number): XY {
  const y = -2.2 - 36.4 * t
  const left = -2.28 + 0.81 * ((-y - 1.3) / 38.8) + 0.45
  const right =
    (y >= -13.37 ? 1.79 : 1.79 + 1.63 * ((-y - 13.37) / 26.4)) - 0.45

  return [left + (right - left) * side, y]
}

// Passive NFC prototype. Tune the fabricated coil before production.
// U1: NXP NT2H1311F0DTL, NTAG213F, HXSON4.
// NXP datasheet: https://www.nxp.com/docs/en/data-sheet/NTAG213F_216F.pdf
// L1 is PCB copper, NOT an additional component to purchase.
// Its 2.6 uH schematic value is a TARGET, not a measured inductance.
const coilWidth = 0.25
const coilPitch = 0.65
const coilTurns = 3
const coilOrigin: XY = [1.65, 56.5]

const coilBoundary: XY[] = [
  [-6.7, 14],
  [-6, 40],
  [-4.6, 66],
  [-1, 82],
  [10, 99],
  [8.4, 78],
  [7.6, 66],
  [5.1, 44],
  [5.6, 14],
]

// Offset each polygon edge inward, then intersect adjacent offset lines.
function insetLoop(poly: XY[], distance: number): XY[] {
  const area = poly.reduce((sum, a, i) => {
    const b = poly[(i + 1) % poly.length]
    return sum + a[0] * b[1] - b[0] * a[1]
  }, 0)
  const sign = area > 0 ? 1 : -1
  const edges = poly.map((a, i) => {
    const b = poly[(i + 1) % poly.length]
    const dx = b[0] - a[0],
      dy = b[1] - a[1],
      length = Math.hypot(dx, dy)
    return {
      p: [
        a[0] - ((sign * dy) / length) * distance,
        a[1] + ((sign * dx) / length) * distance,
      ] as XY,
      d: [dx, dy] as XY,
    }
  })
  return edges.map((edge, i): XY => {
    const previous = edges[(i + edges.length - 1) % edges.length]
    const cross = (a: XY, b: XY) => a[0] * b[1] - a[1] * b[0]
    const delta: XY = [edge.p[0] - previous.p[0], edge.p[1] - previous.p[1]]
    const t = cross(delta, edge.d) / cross(previous.d, edge.d)
    return [
      previous.p[0] + t * previous.d[0],
      previous.p[1] + t * previous.d[1],
    ]
  })
}

const antennaRoute: XY[] = Array.from({ length: coilTurns }, (_, turn) => {
  const loop = insetLoop(coilBoundary, turn * coilPitch)
  const baseY = 14 + turn * coilPitch
  return [[-1.5, baseY] as XY, ...loop, [1.5, baseY] as XY]
}).flat()

function NfcCircuit() {
  return (
    <>
      <NT2H1311F0DTLH
        name="U1"
        pcbX={0}
        pcbY={10}
        layer="bottom"
        schX={0}
        schY={0}
        pinLabels={{ pin1: "GND", pin2: "LB", pin3: "FD", pin4: "LA" }}
        pinAttributes={{
          pin1: { providesGround: true, mustBeConnected: true },
          pin2: { mustBeConnected: true },
          pin3: { isUsingOpenDrain: true, mustBeConnected: true },
          pin4: { mustBeConnected: true },
        }}
      />
      {/* Printed coil has no rectangular body. Its copper is checked against
        the board polygon separately instead of its artificial body box. */}
      <inductor
        name="L1"
        inductance="2.6uH"
        pcbX={coilOrigin[0]}
        pcbY={coilOrigin[1]}
        allowOffBoard
        schX={-4}
        schY={0}
        footprint={
          <footprint>
            <smtpad
              shape="circle"
              radius={0.275}
              pcbX={-1.5 - coilOrigin[0]}
              pcbY={14 - coilOrigin[1]}
              layer="bottom"
              portHints={["pin1"]}
            />
            <platedhole
              shape="circle"
              holeDiameter={0.3}
              outerDiameter={0.6}
              pcbX={1.5 - coilOrigin[0]}
              pcbY={15.3 - coilOrigin[1]}
              portHints={["pin2"]}
            />
            <pcbtrace
              route={antennaRoute.map(([x, y]) => ({
                route_type: "wire" as const,
                x: x - coilOrigin[0],
                y: y - coilOrigin[1],
                width: coilWidth,
                layer: "bottom" as const,
              }))}
            />
          </footprint>
        }
      />
      <trace
        name="RF_A"
        from=".U1 > .LA"
        to=".L1 > .pin1"
        thickness={0.2}
      />
      <trace
        name="RF_B"
        from=".U1 > .LB"
        to=".L1 > .pin2"
        thickness={0.2}
      />
      {/* Tie unused FD to local GND; keep SLEEP_EN=0 (factory default). */}
      <trace
        name="FD_GND"
        from=".U1 > .GND"
        to=".U1 > .FD"
        thickness={0.15}
      />
    </>
  )
}

/** Passive NFC blade prototype: no battery, no copper planes. RF tuning required. */
export const ZangetsuNfcBlade = () => (
  <board
    width="38mm"
    pcbStyle={{ viaHoleDiameter: 0.3, viaPadDiameter: 0.6 }}
    height="160mm"
    solderMaskColor="black"
    silkscreenColor="white"
    outline={outline}
  >
    <cutout shape="polygon" points={guardOpening} />
    <NfcCircuit />

    {/* Solid white central blade core. */}
    <FilledCore />

    {/* Solid white left guard silkscreen. */}
    <FilledLeftGuard />

    <Silk route={core} width={0.2} />

    {/* Reference-inspired angular blade and guard artwork. */}
    {panels.map((route, i) => (
      <Silk key={`panel-${i}`} route={route} />
    ))}

    {/* Handle border. */}
    <Silk
      route={[
        gripPoint(0, 0),
        gripPoint(1, 0),
        gripPoint(1, 1),
        gripPoint((13.37 - 2.2) / 36.4, 1),
        gripPoint(0, 1),
        gripPoint(0, 0),
      ]}
    />

    {/* Diamond handle wrapping. */}
    {Array.from({ length: 14 }, (_, i) => (
      <React.Fragment key={`wrap-${i}`}>
        <Silk
          route={[gripPoint(i / 14, 0), gripPoint((i + 1) / 14, 1)]}
          width={0.2}
        />
        <Silk
          route={[gripPoint(i / 14, 1), gripPoint((i + 1) / 14, 0)]}
          width={0.2}
        />
      </React.Fragment>
    ))}

    {/* Handle collars. */}
    <Silk route={[gripPoint(0.02, 0), gripPoint(0.02, 1)]} />
    <Silk route={[gripPoint(0.98, 0), gripPoint(0.98, 1)]} />
  </board>
)

export const ZangetsuRefOutlinePCB = ZangetsuNfcBlade
export const TensaZangetsu = ZangetsuNfcBlade
export default ZangetsuNfcBlade