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
PCB
Schematic
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