@nfcard/validation 0.10.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nfcard/validation",
3
- "version": "0.10.0",
3
+ "version": "0.13.0",
4
4
  "description": "Shared Zod validation schemas for the NFCard product family.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "zod": "^3.24.0",
20
- "@nfcard/types": "0.9.0"
20
+ "@nfcard/types": "0.12.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "tsup": "^8.0.0"
@@ -0,0 +1,214 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { cardDesignSchema, cardAssetSchema } from './index'
3
+ import {
4
+ CHIP_SCALE_FLOOR,
5
+ QR_MIN_SIZE_MM,
6
+ chipAnchorToMm,
7
+ mmToNearestChipAnchor,
8
+ elementPrintability,
9
+ type ChipAxis,
10
+ } from './printability'
11
+
12
+ // A legacy (grid-only) 3D card design — unchanged shape, no elements[].
13
+ const legacyDesign = {
14
+ material: '3dprint_standard',
15
+ designType: 'custom',
16
+ thicknessMm: 1.8,
17
+ pattern: null,
18
+ layout: { chipAnchor: { col: 0, row: 1 } },
19
+ filaments: { background: '#F2F1ED', text: '#1A1A1A', accent: '#3FA34D' },
20
+ }
21
+
22
+ const chipEl = (over = {}) => ({
23
+ id: 'chip1',
24
+ type: 'chip',
25
+ shape: 0,
26
+ transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 1, z: 0 },
27
+ ...over,
28
+ })
29
+ const textEl = (over = {}) => ({
30
+ id: 't1',
31
+ type: 'text',
32
+ text: 'Anna Kovács',
33
+ sizeMm: 4.4,
34
+ weight: 700,
35
+ role: 'text',
36
+ transform: { xMm: -18, yMm: -14, rotationDeg: 0, scale: 1, z: 1 },
37
+ ...over,
38
+ })
39
+ const qrEl = (over = {}) => ({
40
+ id: 'q1',
41
+ type: 'qr',
42
+ url: 'https://nfcard.hu',
43
+ sizeMm: 22,
44
+ frameRole: 'accent',
45
+ moduleRole: 'text',
46
+ transform: { xMm: 26, yMm: 12, rotationDeg: 0, scale: 1, z: 2 },
47
+ ...over,
48
+ })
49
+
50
+ // A valid CUSTOM design with a free-transform element layer. The chip element sits
51
+ // dead-centre, so the dual-written layout.chipAnchor must be {1,1}.
52
+ const withElements = (elements: unknown[], over = {}) => ({
53
+ ...legacyDesign,
54
+ contractVersion: 2,
55
+ layout: { chipAnchor: { col: 1, row: 1 } },
56
+ elements,
57
+ ...over,
58
+ })
59
+
60
+ const codes = (res: ReturnType<typeof cardDesignSchema.safeParse>): string[] =>
61
+ res.success ? [] : res.error.issues.map((i) => i.message)
62
+
63
+ describe('cardDesignSchema — free-transform element layer (NFCARD-197/198)', () => {
64
+ it('legacy design with no elements still validates (zero regression)', () => {
65
+ expect(cardDesignSchema.safeParse(legacyDesign).success).toBe(true)
66
+ })
67
+
68
+ it('accepts a valid custom design with chip + text + qr elements', () => {
69
+ const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl(), qrEl()]))
70
+ expect(res.success).toBe(true)
71
+ })
72
+
73
+ it('rejects a chip scaled below the NFC-coil floor (grow-only)', () => {
74
+ const res = cardDesignSchema.safeParse(withElements([chipEl({ transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 0.5, z: 0 } })]))
75
+ expect(res.success).toBe(false)
76
+ expect(codes(res)).toContain('chipScaleBelowCoil')
77
+ })
78
+
79
+ it('accepts a chip scaled UP (decorative ring)', () => {
80
+ const res = cardDesignSchema.safeParse(withElements([chipEl({ transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 1.3, z: 0 } })]))
81
+ expect(res.success).toBe(true)
82
+ })
83
+
84
+ it('rejects a QR below the scannable size floor', () => {
85
+ const res = cardDesignSchema.safeParse(withElements([chipEl(), qrEl({ sizeMm: 12 })]))
86
+ expect(res.success).toBe(false)
87
+ expect(codes(res)).toContain('qrTooSmall')
88
+ })
89
+
90
+ it('rejects a raster image asset on a 3D card (vector-only)', () => {
91
+ const res = cardDesignSchema.safeParse(
92
+ withElements(
93
+ [chipEl(), { id: 'img1', type: 'image', assetRef: 'a1', widthMm: 20, heightMm: 12, role: 'text', transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } }],
94
+ { assets: [{ id: 'a1', kind: 'raster', rasterDataUrl: 'data:image/png;base64,AAAA' }] },
95
+ ),
96
+ )
97
+ expect(res.success).toBe(false)
98
+ expect(codes(res)).toContain('rasterImageOn3d')
99
+ })
100
+
101
+ it('accepts a vector image asset on a 3D card', () => {
102
+ const res = cardDesignSchema.safeParse(
103
+ withElements(
104
+ [chipEl(), { id: 'img1', type: 'image', assetRef: 'a1', widthMm: 20, heightMm: 12, role: 'text', transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } }],
105
+ { assets: [{ id: 'a1', kind: 'vector', svg: '<svg/>' }] },
106
+ ),
107
+ )
108
+ expect(res.success).toBe(true)
109
+ })
110
+
111
+ it('rejects an element referencing a missing asset', () => {
112
+ const res = cardDesignSchema.safeParse(
113
+ withElements([chipEl(), { id: 'img1', type: 'image', assetRef: 'nope', widthMm: 20, heightMm: 12, role: 'text', transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } }]),
114
+ )
115
+ expect(res.success).toBe(false)
116
+ expect(codes(res)).toContain('assetMissing')
117
+ })
118
+
119
+ it('rejects elements on a template (templates are fixed — custom-only)', () => {
120
+ const res = cardDesignSchema.safeParse(withElements([chipEl()], { designType: 'template', templateId: 'tpl-3d-flat' }))
121
+ expect(res.success).toBe(false)
122
+ expect(codes(res)).toContain('elementsCustomOnly')
123
+ })
124
+
125
+ it('rejects elements without contractVersion 2', () => {
126
+ const res = cardDesignSchema.safeParse(withElements([chipEl()], { contractVersion: undefined }))
127
+ expect(res.success).toBe(false)
128
+ expect(codes(res)).toContain('contractVersionRequired')
129
+ })
130
+
131
+ it('rejects a chipAnchor inconsistent with the chip element (dual-write tamper)', () => {
132
+ const res = cardDesignSchema.safeParse(withElements([chipEl()], { layout: { chipAnchor: { col: 0, row: 0 } } }))
133
+ expect(res.success).toBe(false)
134
+ expect(codes(res)).toContain('chipAnchorDualWriteMismatch')
135
+ })
136
+
137
+ it('requires exactly one chip element', () => {
138
+ const none = cardDesignSchema.safeParse(withElements([textEl()]))
139
+ expect(codes(none)).toContain('exactlyOneChip')
140
+ const two = cardDesignSchema.safeParse(withElements([chipEl(), chipEl({ id: 'chip2' })]))
141
+ expect(codes(two)).toContain('exactlyOneChip')
142
+ })
143
+
144
+ it('rejects an element that extends off the card', () => {
145
+ const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl({ transform: { xMm: 60, yMm: 0, rotationDeg: 0, scale: 1, z: 1 } })]))
146
+ expect(res.success).toBe(false)
147
+ expect(codes(res)).toContain('elementOffCard')
148
+ })
149
+
150
+ it('rejects duplicate element ids', () => {
151
+ const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl({ id: 'chip1' })]))
152
+ expect(res.success).toBe(false)
153
+ expect(codes(res)).toContain('duplicateElementId')
154
+ })
155
+
156
+ it('rejects empty text', () => {
157
+ const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl({ text: ' ' })]))
158
+ expect(res.success).toBe(false)
159
+ expect(codes(res)).toContain('textEmpty')
160
+ })
161
+ })
162
+
163
+ describe('printability helpers', () => {
164
+ it('chip scale floor is just under 1 (grow-only, coil-bound)', () => {
165
+ expect(CHIP_SCALE_FLOOR).toBeGreaterThan(0.95)
166
+ expect(CHIP_SCALE_FLOOR).toBeLessThan(1)
167
+ })
168
+
169
+ it('QR min size keeps the module at the scan floor', () => {
170
+ expect(QR_MIN_SIZE_MM).toBe(20)
171
+ })
172
+
173
+ it('mmToNearestChipAnchor inverts chipAnchorToMm for all 9 cells', () => {
174
+ for (const col of [0, 1, 2] as ChipAxis[]) {
175
+ for (const row of [0, 1, 2] as ChipAxis[]) {
176
+ const mm = chipAnchorToMm(col, row)
177
+ expect(mmToNearestChipAnchor(mm)).toEqual({ col, row })
178
+ }
179
+ }
180
+ })
181
+
182
+ it('a centred chip projects to the centre cell', () => {
183
+ expect(mmToNearestChipAnchor({ x: 0, y: 0 })).toEqual({ col: 1, row: 1 })
184
+ })
185
+
186
+ it('elementPrintability passes a legal centred chip', () => {
187
+ const chip = chipEl() as Parameters<typeof elementPrintability>[0]
188
+ expect(elementPrintability(chip, { assetsById: new Map() })).toEqual([])
189
+ })
190
+ })
191
+
192
+ describe('per-element override fields survive validation (#1  P0 contract)', () => {
193
+ it('cardDesign RETAINS colour / italic / underline / bgRemoved / svgOverrides + vector-asset svg fields', () => {
194
+ const parsed = cardDesignSchema.parse(
195
+ withElements(
196
+ [
197
+ chipEl({ colour: 'black' }),
198
+ textEl({ colour: 'red', italic: true, underline: true }),
199
+ { id: 'img1', type: 'image', assetRef: 'a1', widthMm: 20, heightMm: 12, role: 'text', bgRemoved: true, svgOverrides: ['green', undefined], transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 } },
200
+ ],
201
+ { assets: [{ id: 'a1', kind: 'vector', svg: '<svg/>', svgColours: ['#ff0000', '#00ff00'], svgFils: ['red', 'green'] }] },
202
+ ),
203
+ ) as { elements: Record<string, unknown>[]; assets: Record<string, unknown>[] }
204
+ expect(parsed.elements[0].colour).toBe('black')
205
+ expect(parsed.elements[1]).toMatchObject({ colour: 'red', italic: true, underline: true })
206
+ expect(parsed.elements[2]).toMatchObject({ bgRemoved: true, svgOverrides: ['green', undefined] })
207
+ expect(parsed.assets[0]).toMatchObject({ svgColours: ['#ff0000', '#00ff00'], svgFils: ['red', 'green'] })
208
+ })
209
+
210
+ it('cardAssetSchema retains the raster bg-removal + aspect fields (PVC preview)', () => {
211
+ const a = cardAssetSchema.parse({ id: 'r1', kind: 'raster', rasterDataUrl: 'data:,', rasterBgDataUrl: 'data:,bg', rasterAspect: 1.5 })
212
+ expect(a).toMatchObject({ rasterBgDataUrl: 'data:,bg', rasterAspect: 1.5 })
213
+ })
214
+ })
package/src/index.test.ts CHANGED
@@ -490,6 +490,32 @@ describe('physicalConfigSchema / validatePhysicalConfig', () => {
490
490
  });
491
491
  expect(r.success).toBe(false);
492
492
  });
493
+
494
+ it('accepts + RETAINS top-level elements/assets for a PVC (2D) custom design', () => {
495
+ // PVC parity: the free-transform layer + raster image assets ride top-level
496
+ // on a plastic config (which has no cardDesign) and must NOT be stripped by
497
+ // the strict object parse — otherwise the order persists no renderable design.
498
+ const r = physicalConfigSchema.safeParse({
499
+ ...validPhysicalConfig,
500
+ elements: [
501
+ {
502
+ id: 'img1',
503
+ type: 'image',
504
+ assetRef: 'a1',
505
+ widthMm: 20,
506
+ heightMm: 12,
507
+ role: 'text',
508
+ transform: { xMm: -15, yMm: 14, rotationDeg: 0, scale: 1, z: 1 },
509
+ },
510
+ ],
511
+ assets: [{ id: 'a1', kind: 'raster', rasterDataUrl: 'https://cdn.example/card.webp' }],
512
+ });
513
+ expect(r.success).toBe(true);
514
+ if (r.success) {
515
+ expect(r.data.elements).toHaveLength(1);
516
+ expect(r.data.assets?.[0]?.rasterDataUrl).toBe('https://cdn.example/card.webp');
517
+ }
518
+ });
493
519
  });
494
520
 
495
521
  // ── digitalConfigSchema ─────────────────────────────────────────
package/src/index.ts CHANGED
Binary file
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync, existsSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import type { CardElement, CardAsset } from '@nfcard/types'
5
+ import {
6
+ elementPrintability,
7
+ chipAnchorToMm,
8
+ obbCorners,
9
+ CHIP_SCALE_FLOOR,
10
+ QR_MIN_SIZE_MM,
11
+ type ChipAxis,
12
+ } from './printability'
13
+
14
+ /**
15
+ * Cross-language PLACEMENT + PRINTABILITY parity — the TS half of the guard
16
+ * (NFCARD-247, doc 83).
17
+ *
18
+ * `cardgen.validate` (`validate_elements` / `obb_corners`) and `cardgen.pattern`
19
+ * (`chip_centre_for_anchor`) are by-hand Python ports of THIS file. The golden lives
20
+ * in the cardgen repo (its pytest, `test_parity_printability.py`, is the other half).
21
+ * This test pins printability.ts to that same committed golden, so a change to the
22
+ * gate, the chip Y-frame projection, or the OBB/rotation-sign formula here fails
23
+ * LOUDLY with a reminder to regenerate and update the Python side in lockstep —
24
+ * instead of silently drifting the printed card away from the live preview.
25
+ *
26
+ * Regenerate (both sides together):
27
+ * node ../../nfcard-cardgen/tests/parity/gen_printability_parity.mjs
28
+ *
29
+ * Skips cleanly if nfcard-cardgen isn't checked out alongside (shared-only CI).
30
+ */
31
+ const GOLDEN = fileURLToPath(
32
+ new URL('../../../nfcard-cardgen/tests/parity/printability_parity.json', import.meta.url),
33
+ )
34
+ const present = existsSync(GOLDEN)
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ const golden: any = present ? JSON.parse(readFileSync(GOLDEN, 'utf-8')) : { elementCases: [] }
37
+ const TOL = 1e-9
38
+
39
+ // Mirror cardgen `validate_elements`: per-element printability + the graph-level
40
+ // exactly-one-chip rule (printability.ts leaves that to the schema superRefine).
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ function expectedCodes(design: any): string[] {
43
+ const assetsById = new Map<string, CardAsset>(
44
+ (design.assets ?? []).map((a: CardAsset) => [a.id, a]),
45
+ )
46
+ const codes: string[] = []
47
+ let chipCount = 0
48
+ for (const el of design.elements as CardElement[]) {
49
+ if (el.type === 'chip') chipCount += 1
50
+ for (const v of elementPrintability(el, { assetsById })) codes.push(v.code)
51
+ }
52
+ if (chipCount !== 1) codes.push('chipCount')
53
+ return codes.sort()
54
+ }
55
+
56
+ describe.skipIf(!present)('printability parity golden (locks the TS source of truth)', () => {
57
+ it('shares the threshold constants the Python gate branches on', () => {
58
+ expect(golden.constants.chipScaleFloor).toBeCloseTo(CHIP_SCALE_FLOOR, 9)
59
+ expect(golden.constants.qrMinSizeMm).toBe(QR_MIN_SIZE_MM)
60
+ })
61
+
62
+ it('elementPrintability still reproduces the committed Violation codes', () => {
63
+ expect(golden.elementCases.length, 'golden has cases — regenerate if 0').toBeGreaterThan(0)
64
+ for (const c of golden.elementCases) {
65
+ expect(expectedCodes(c.design), `${c.name}: code drift — regenerate the golden`).toEqual(
66
+ c.expectedCodes,
67
+ )
68
+ }
69
+ })
70
+
71
+ it('chipAnchorToMm still reproduces the committed 3×3 grid → mm (the Y-frame)', () => {
72
+ for (const e of golden.chipAnchorMm) {
73
+ const p = chipAnchorToMm(e.col as ChipAxis, e.row as ChipAxis)
74
+ expect(Math.abs(p.x - e.x), `chip(${e.col},${e.row}).x`).toBeLessThan(TOL)
75
+ expect(Math.abs(p.y - e.y), `chip(${e.col},${e.row}).y`).toBeLessThan(TOL)
76
+ }
77
+ })
78
+
79
+ it('obbCorners still reproduces the committed corners (the rotation-sign)', () => {
80
+ for (const c of golden.obbCases) {
81
+ const got = obbCorners(c.transform, c.w, c.h)
82
+ expect(got.length).toBe(c.corners.length)
83
+ got.forEach((p, i) => {
84
+ expect(Math.abs(p.x - c.corners[i][0]), `${c.name} corner ${i}.x`).toBeLessThan(TOL)
85
+ expect(Math.abs(p.y - c.corners[i][1]), `${c.name} corner ${i}.y`).toBeLessThan(TOL)
86
+ })
87
+ }
88
+ })
89
+ })
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Manufacturing-printability envelope for the free-transform element layer
3
+ * (NFCARD-197/198). This is the SINGLE canonical source for the placement math +
4
+ * the per-element hard constraints: the editor (nfcard-web) imports it for live
5
+ * warnings, @nfcard/validation runs it as the hard order gate (superRefine), and
6
+ * cardgen `validate.py` ports it 1:1. A golden-fixture CI test keeps the TS and the
7
+ * Python port from drifting — without that, the live preview silently diverges from
8
+ * the printed card.
9
+ *
10
+ * All geometry is card-local millimetres, centre-origin, +x right / +y up (the
11
+ * cardgen frame). Pure functions only — no zod, no IO.
12
+ *
13
+ * Thresholds marked "provisional" are conservative defaults to be calibrated against
14
+ * cardgen's own `validate_spec` / `_thin_stroke_warnings`; they are deliberately set
15
+ * so they never reject a design the legacy auto-layout already prints.
16
+ */
17
+ import type { CardElement, CardAsset, ElementTransform } from '@nfcard/types'
18
+
19
+ // ── Card + manufacturing geometry (mirror @nfcard/pattern-engine + cardgen) ──
20
+ export const CARD_W_MM = 85.6
21
+ export const CARD_H_MM = 54.0
22
+ export const EDGE_MARGIN_MM = 1.5
23
+ /** Ø27.5 solid chip island (pattern-engine CHIP_RADIUS_MM). */
24
+ export const CHIP_RADIUS_MM = 13.75
25
+ export const MIN_WALL_MM = 0.8
26
+
27
+ // ── NFC coil: a fixed Ø25 mm COTS part (cardgen NfcSpec defaults) ──
28
+ export const NFC_COIL_DIAMETER_MM = 25
29
+ export const NFC_RADIAL_CLEARANCE_MM = 0.2
30
+ /**
31
+ * Chip island is GROW-ONLY. Shrinking the visual island does NOT shrink the coil —
32
+ * it collapses the solid wall between the coil pocket and the perforation. The floor
33
+ * keeps `pocket_r + MIN_WALL ≤ CHIP_RADIUS·scale` (cardgen spec.py:507-512), so the
34
+ * chip can only grow. ≈ (12.5 + 0.2 + 0.8) / 13.75 ≈ 0.982.
35
+ */
36
+ export const CHIP_SCALE_FLOOR =
37
+ (NFC_COIL_DIAMETER_MM / 2 + NFC_RADIAL_CLEARANCE_MM + MIN_WALL_MM) / CHIP_RADIUS_MM
38
+
39
+ // ── QR scannability ──
40
+ /** Keep the printed module ≥ ~0.8 mm (≈4 nozzle widths) for reliable phone scans. */
41
+ export const QR_MODULE_MIN_MM = 0.8
42
+ /** v1 (21×21) matrix + a 2-module quiet zone each side. */
43
+ export const QR_MATRIX_CELLS = 25
44
+ export const QR_MIN_SIZE_MM = QR_MODULE_MIN_MM * QR_MATRIX_CELLS // = 20
45
+
46
+ // ── Raised-text minimum (provisional; calibrate vs cardgen _thin_stroke_warnings) ──
47
+ // The legacy auto-content already prints em heights down to ~2.1 mm, so the HARD
48
+ // floor matches that (below it, FDM raised glyphs vanish). The 2–4 mm "thin" band is
49
+ // a SOFT editor warning, not a gate, so free text never regresses the auto-layout.
50
+ export const TEXT_MIN_EM_MM = 2.0
51
+
52
+ const EPS = 1e-6
53
+
54
+ export interface Pt {
55
+ x: number
56
+ y: number
57
+ }
58
+
59
+ function isFiniteNum(n: unknown): n is number {
60
+ return typeof n === 'number' && Number.isFinite(n)
61
+ }
62
+ export function isFiniteTransform(t: ElementTransform): boolean {
63
+ return (
64
+ isFiniteNum(t.xMm) &&
65
+ isFiniteNum(t.yMm) &&
66
+ isFiniteNum(t.rotationDeg) &&
67
+ isFiniteNum(t.scale) &&
68
+ isFiniteNum(t.z) &&
69
+ t.scale > 0
70
+ )
71
+ }
72
+
73
+ /** The 4 corners of a `wMm × hMm` box (centre-origin local) under a transform. */
74
+ export function obbCorners(t: ElementTransform, wMm: number, hMm: number): Pt[] {
75
+ const hw = (wMm * t.scale) / 2
76
+ const hh = (hMm * t.scale) / 2
77
+ const r = (t.rotationDeg * Math.PI) / 180
78
+ const c = Math.cos(r)
79
+ const s = Math.sin(r)
80
+ const local: [number, number][] = [
81
+ [-hw, -hh],
82
+ [hw, -hh],
83
+ [hw, hh],
84
+ [-hw, hh],
85
+ ]
86
+ return local.map(([x, y]) => ({ x: t.xMm + x * c - y * s, y: t.yMm + x * s + y * c }))
87
+ }
88
+
89
+ /** Are all corners inside the ID-1 rim minus the safety margin? */
90
+ export function cornersOnCard(corners: Pt[], margin = EDGE_MARGIN_MM): boolean {
91
+ const maxX = CARD_W_MM / 2 - margin
92
+ const maxY = CARD_H_MM / 2 - margin
93
+ return corners.every((p) => Math.abs(p.x) <= maxX + EPS && Math.abs(p.y) <= maxY + EPS)
94
+ }
95
+
96
+ // ── Chip 3×3-grid ⇄ free-mm projection (legacy dual-write) ──
97
+ // The exact `chip_centre_mm` math from @nfcard/pattern-engine / cardgen pattern.py.
98
+ const ANCHOR_NORM = [-0.85, 0, 0.85] as const
99
+ export type ChipAxis = 0 | 1 | 2
100
+
101
+ function chipMaxXY(): { maxX: number; maxY: number } {
102
+ return {
103
+ maxX: CARD_W_MM / 2 - EDGE_MARGIN_MM - CHIP_RADIUS_MM - 1,
104
+ maxY: CARD_H_MM / 2 - EDGE_MARGIN_MM - CHIP_RADIUS_MM - 1,
105
+ }
106
+ }
107
+
108
+ /** Grid cell → card-local mm centre (matches pattern-engine `chipCentreForAnchor`). */
109
+ export function chipAnchorToMm(col: ChipAxis, row: ChipAxis): Pt {
110
+ const { maxX, maxY } = chipMaxXY()
111
+ return { x: ANCHOR_NORM[col] * maxX, y: ANCHOR_NORM[row] * maxY }
112
+ }
113
+
114
+ /** Free mm → nearest grid cell (matches pattern-engine `nearestChipAnchor`). The
115
+ * lossy legacy shadow the editor dual-writes into `layout.chipAnchor`. */
116
+ export function mmToNearestChipAnchor(p: Pt): { col: ChipAxis; row: ChipAxis } {
117
+ const { maxX, maxY } = chipMaxXY()
118
+ const near = (v: number): ChipAxis => {
119
+ let best: ChipAxis = 0
120
+ let bd = Infinity
121
+ ;([0, 1, 2] as ChipAxis[]).forEach((i) => {
122
+ const d = Math.abs(ANCHOR_NORM[i] - v)
123
+ if (d < bd) {
124
+ bd = d
125
+ best = i
126
+ }
127
+ })
128
+ return best
129
+ }
130
+ return { col: near(maxX ? p.x / maxX : 0), row: near(maxY ? p.y / maxY : 0) }
131
+ }
132
+
133
+ // ── Per-element printability ──
134
+ export interface PrintabilityCtx {
135
+ /** A CardDesign is always 3D (CardDesignMaterial excludes plastic). */
136
+ assetsById: Map<string, CardAsset>
137
+ }
138
+ export interface Violation {
139
+ code: string
140
+ elementId: string
141
+ message: string
142
+ }
143
+
144
+ /** Estimated card footprint (w×h, pre-scale) used for the on-card OBB check. The
145
+ * editor measures real glyph widths; this is the schema-side backstop. */
146
+ function footprintOf(el: CardElement): { w: number; h: number } {
147
+ switch (el.type) {
148
+ case 'chip':
149
+ return { w: 2 * CHIP_RADIUS_MM, h: 2 * CHIP_RADIUS_MM }
150
+ case 'qr':
151
+ return { w: el.sizeMm, h: el.sizeMm }
152
+ case 'image':
153
+ case 'logo':
154
+ return { w: el.widthMm, h: el.heightMm }
155
+ case 'text': {
156
+ // Coarse: ~0.6 em per glyph wide, ~1.3 em tall. Editor does the precise check.
157
+ const w = Math.max(1, el.text.length) * el.sizeMm * 0.6
158
+ return { w, h: el.sizeMm * 1.3 }
159
+ }
160
+ }
161
+ }
162
+
163
+ /** All hard manufacturing violations for one element. Empty ⇒ printable. */
164
+ export function elementPrintability(el: CardElement, ctx: PrintabilityCtx): Violation[] {
165
+ const out: Violation[] = []
166
+ const push = (code: string, message: string) => out.push({ code, elementId: el.id, message })
167
+
168
+ if (!isFiniteTransform(el.transform)) {
169
+ push('transformInvalid', 'Element transform is not a finite, positive-scale value.')
170
+ return out
171
+ }
172
+
173
+ switch (el.type) {
174
+ case 'chip':
175
+ if (el.transform.scale < CHIP_SCALE_FLOOR - EPS) {
176
+ push('chipScaleBelowCoil', 'The chip cannot shrink below the 25 mm NFC coil.')
177
+ }
178
+ break
179
+ case 'qr':
180
+ if (el.sizeMm * el.transform.scale < QR_MIN_SIZE_MM - EPS) {
181
+ push('qrTooSmall', 'The QR code is below the scannable size floor.')
182
+ }
183
+ if (!el.url || !el.url.trim()) {
184
+ push('qrEmptyUrl', 'The QR code has no target URL.')
185
+ }
186
+ break
187
+ case 'text':
188
+ if (!el.text.trim()) {
189
+ push('textEmpty', 'The text element is empty.')
190
+ }
191
+ if (el.sizeMm * el.transform.scale < TEXT_MIN_EM_MM - EPS) {
192
+ push('textTooSmall', 'The text is too small to print reliably.')
193
+ }
194
+ break
195
+ case 'image':
196
+ case 'logo': {
197
+ const asset = ctx.assetsById.get(el.assetRef)
198
+ if (!asset) {
199
+ push('assetMissing', 'The element references a missing asset.')
200
+ } else if (asset.kind !== 'vector') {
201
+ // Vector-only on 3D: FDM extrudes vector outlines (raster is PVC-only).
202
+ push('rasterImageOn3d', 'Raster images are not printable on 3D cards — use a vector (SVG).')
203
+ }
204
+ break
205
+ }
206
+ }
207
+
208
+ const fp = footprintOf(el)
209
+ if (!cornersOnCard(obbCorners(el.transform, fp.w, fp.h))) {
210
+ push('elementOffCard', 'The element extends past the printable card area.')
211
+ }
212
+ return out
213
+ }