@nfcard/validation 0.12.0 → 0.14.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.
@@ -1,213 +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
- }
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
+ }