@7365admin1/layer-common 4.97.1-staging.500 → 4.97.1-staging.502
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/components/HidAccessPermissions.vue +146 -38
- package/components/HidUserEnrollment.vue +15 -15
- package/components/VehicleQrStickerDialog.vue +88 -43
- package/package.json +1 -1
- package/utils/hid-enrolment-subject.ts +22 -0
- package/utils/hid-permission-assignments.ts +66 -5
- package/utils/vehicle-qr-design.ts +312 -144
- package/utils/vehicle-qr-sticker.ts +119 -34
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vehicle QR sticker designs - the sticker face
|
|
2
|
+
* Vehicle QR sticker designs - the sticker face as plain shapes.
|
|
3
3
|
*
|
|
4
4
|
* The QR library only gives us the dark/light module grid; we draw it here so
|
|
5
|
-
* the look can change while the code itself does not: same payload
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* the look can change while the code itself does not: same payload ("S365V:" +
|
|
6
|
+
* token). Two kinds of sticker:
|
|
7
|
+
*
|
|
8
|
+
* - Square (classic): the 32 mm QR (ECC Q, 4-module quiet zone) on a card.
|
|
9
|
+
* - Shaped QR: the code itself takes the shape (heart, circle, car ...). The
|
|
10
|
+
* real QR (same ECC Q) sits in the middle with a thin paper ring round it; the rest
|
|
11
|
+
* of the shape is filled with extra modules in the same dot style, so the
|
|
12
|
+
* whole shape reads as one QR code. The extra modules are seeded by the token
|
|
13
|
+
* (same sticker every print) and can never form the patterns a scanner hunts
|
|
14
|
+
* for (no 2 x 2 dark block, so no QR corner; no dark-light-dark-light-dark
|
|
15
|
+
* timing run).
|
|
16
|
+
*
|
|
17
|
+
* The shapes are kept as data (`stickerFacePaints`) so the tests can draw them
|
|
18
|
+
* to pixels and decode them - every shape x size x style must still scan.
|
|
19
|
+
* Colours are dark ink on light paper only; the test checks each theme.
|
|
10
20
|
*/
|
|
11
21
|
|
|
12
22
|
export type QrMatrix = { size: number; get(row: number, col: number): number | boolean };
|
|
13
23
|
|
|
14
24
|
export type DotShape = "square" | "rounded" | "dots";
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
// ponytail: round "circle" corners were dropped - ZBar (a stand-in for phone readers) missed them whenever the sticker was tilted.
|
|
26
|
+
export type EyeShape = "square" | "rounded";
|
|
27
|
+
/** QR shapes other than the classic square: the modules fill this outline. */
|
|
17
28
|
export type SilhouetteShape =
|
|
18
29
|
| "circle"
|
|
19
30
|
| "heart"
|
|
@@ -23,8 +34,13 @@ export type SilhouetteShape =
|
|
|
23
34
|
| "shield"
|
|
24
35
|
| "bubble"
|
|
25
36
|
| "roundsquare"
|
|
37
|
+
| "blob"
|
|
38
|
+
| "droplet"
|
|
26
39
|
| "car";
|
|
27
|
-
export type
|
|
40
|
+
export type QrShape = "square" | SilhouetteShape;
|
|
41
|
+
/** The card round the QR; "none" = no card - a shaped QR is then the sticker itself. */
|
|
42
|
+
export type FrameShape = "none" | "rounded" | "square" | "soft" | "arch" | "ticket";
|
|
43
|
+
export type SizeChoice = "small" | "standard" | "large";
|
|
28
44
|
export type LabelChoice = "app" | "verify" | "scanme" | "site" | "none";
|
|
29
45
|
|
|
30
46
|
export type StickerTheme = { id: string; title: string; ink: string; eye: string; paper: string };
|
|
@@ -33,7 +49,10 @@ export type StickerDesign = {
|
|
|
33
49
|
dots: DotShape;
|
|
34
50
|
eyes: EyeShape;
|
|
35
51
|
theme: string;
|
|
52
|
+
/** The shape of the QR code itself. */
|
|
53
|
+
shape: QrShape;
|
|
36
54
|
frame: FrameShape;
|
|
55
|
+
size: SizeChoice;
|
|
37
56
|
label: LabelChoice;
|
|
38
57
|
/** A thin grey line around each sticker to cut along. */
|
|
39
58
|
cutLines?: boolean;
|
|
@@ -48,15 +67,6 @@ export const DOT_OPTIONS: { value: DotShape; title: string }[] = [
|
|
|
48
67
|
export const EYE_OPTIONS: { value: EyeShape; title: string }[] = [
|
|
49
68
|
{ value: "square", title: "Square" },
|
|
50
69
|
{ value: "rounded", title: "Rounded" },
|
|
51
|
-
{ value: "circle", title: "Circle" },
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
export const FRAME_OPTIONS: { value: FrameShape; title: string }[] = [
|
|
55
|
-
{ value: "rounded", title: "Rounded corners" },
|
|
56
|
-
{ value: "square", title: "Square corners" },
|
|
57
|
-
{ value: "soft", title: "Extra round" },
|
|
58
|
-
{ value: "arch", title: "Badge (round top)" },
|
|
59
|
-
{ value: "ticket", title: "Ticket (cut corners)" },
|
|
60
70
|
];
|
|
61
71
|
|
|
62
72
|
export const SILHOUETTE_OPTIONS: { value: SilhouetteShape; title: string }[] = [
|
|
@@ -68,14 +78,45 @@ export const SILHOUETTE_OPTIONS: { value: SilhouetteShape; title: string }[] = [
|
|
|
68
78
|
{ value: "shield", title: "Shield" },
|
|
69
79
|
{ value: "bubble", title: "Speech bubble" },
|
|
70
80
|
{ value: "roundsquare", title: "Rounded square" },
|
|
81
|
+
{ value: "blob", title: "Blob" },
|
|
82
|
+
{ value: "droplet", title: "Droplet" },
|
|
71
83
|
{ value: "car", title: "Car" },
|
|
72
84
|
];
|
|
73
85
|
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
export const QR_SHAPE_OPTIONS: { value: QrShape; title: string }[] = [
|
|
87
|
+
{ value: "square", title: "Square (classic)" },
|
|
88
|
+
...SILHOUETTE_OPTIONS,
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
export const FRAME_OPTIONS: { value: FrameShape; title: string }[] = [
|
|
92
|
+
{ value: "none", title: "None (the shape is the sticker)" },
|
|
93
|
+
{ value: "rounded", title: "Card, rounded corners" },
|
|
94
|
+
{ value: "square", title: "Card, square corners" },
|
|
95
|
+
{ value: "soft", title: "Card, extra round" },
|
|
96
|
+
{ value: "arch", title: "Badge (round top)" },
|
|
97
|
+
{ value: "ticket", title: "Ticket (cut corners)" },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/** The badge's round top would cut into a shaped code, so shaped codes skip it. */
|
|
101
|
+
export function frameOptionsFor(shape: QrShape): { value: FrameShape; title: string }[] {
|
|
102
|
+
return shape === "square"
|
|
103
|
+
? FRAME_OPTIONS.map((o) => (o.value === "none" ? { value: o.value, title: "None (no frame line)" } : o))
|
|
104
|
+
: FRAME_OPTIONS.filter((o) => o.value !== "arch");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const SIZE_OPTIONS: { value: SizeChoice; title: string; scale: number }[] = [
|
|
108
|
+
{ value: "small", title: "Small", scale: 0.8 },
|
|
109
|
+
{ value: "standard", title: "Standard", scale: 1 },
|
|
110
|
+
{ value: "large", title: "Large", scale: 1.2 },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
export const sizeScale = (size: SizeChoice) => SIZE_OPTIONS.find((o) => o.value === size)?.scale ?? 1;
|
|
114
|
+
|
|
115
|
+
/** The printed QR code (its data area, without the quiet zone) is never smaller than this. */
|
|
116
|
+
export const MIN_QR_DATA_MM = 25;
|
|
76
117
|
|
|
77
|
-
export function isSilhouette(
|
|
78
|
-
return SILHOUETTE_OPTIONS.some((o) => o.value ===
|
|
118
|
+
export function isSilhouette(shape: unknown): shape is SilhouetteShape {
|
|
119
|
+
return SILHOUETTE_OPTIONS.some((o) => o.value === shape);
|
|
79
120
|
}
|
|
80
121
|
|
|
81
122
|
export const LABEL_OPTIONS: { value: LabelChoice; title: string }[] = [
|
|
@@ -101,7 +142,9 @@ export const DEFAULT_STICKER_DESIGN: StickerDesign = {
|
|
|
101
142
|
dots: "square",
|
|
102
143
|
eyes: "square",
|
|
103
144
|
theme: "black",
|
|
145
|
+
shape: "square",
|
|
104
146
|
frame: "rounded",
|
|
147
|
+
size: "standard",
|
|
105
148
|
label: "app",
|
|
106
149
|
cutLines: true,
|
|
107
150
|
};
|
|
@@ -112,11 +155,17 @@ export function normalizeDesign(raw: unknown): StickerDesign {
|
|
|
112
155
|
const pick = <T extends string>(v: unknown, list: { value: T }[], d: T): T =>
|
|
113
156
|
list.some((o) => o.value === v) ? (v as T) : d;
|
|
114
157
|
const d = DEFAULT_STICKER_DESIGN;
|
|
158
|
+
// Saved before shaped codes: a cut-out shape was stored as the frame.
|
|
159
|
+
const legacy = isSilhouette(r.frame) ? r.frame : null;
|
|
160
|
+
const shape = legacy ?? pick(r.shape, QR_SHAPE_OPTIONS, d.shape);
|
|
161
|
+
const frame = legacy ? "none" : pick(r.frame, frameOptionsFor(shape), shape === "square" ? d.frame : "none");
|
|
115
162
|
return {
|
|
116
163
|
dots: pick(r.dots, DOT_OPTIONS, d.dots),
|
|
117
164
|
eyes: pick(r.eyes, EYE_OPTIONS, d.eyes),
|
|
118
165
|
theme: STICKER_THEMES.some((t) => t.id === r.theme) ? String(r.theme) : d.theme,
|
|
119
|
-
|
|
166
|
+
shape,
|
|
167
|
+
frame,
|
|
168
|
+
size: pick(r.size, SIZE_OPTIONS, d.size),
|
|
120
169
|
label: pick(r.label, LABEL_OPTIONS, d.label),
|
|
121
170
|
cutLines: typeof r.cutLines === "boolean" ? r.cutLines : true,
|
|
122
171
|
};
|
|
@@ -138,48 +187,50 @@ export function stickerContrast(a: string, b: string): number {
|
|
|
138
187
|
return (hi + 0.05) / (lo + 0.05);
|
|
139
188
|
}
|
|
140
189
|
|
|
141
|
-
// ---- geometry (
|
|
190
|
+
// ---- geometry (mm at Standard size; Small / Large scale the whole face) -------
|
|
142
191
|
|
|
143
192
|
export const CARD_W = 47;
|
|
144
193
|
export const CARD_H = 58;
|
|
145
|
-
export const QR_MM = 32; // the whole QR including its quiet zone
|
|
194
|
+
export const QR_MM = 32; // classic: the whole QR including its quiet zone
|
|
146
195
|
export const QUIET_MODULES = 4;
|
|
147
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Error correction on every design, shaped codes too. H was tried for shapes:
|
|
198
|
+
* its code needs 33 modules instead of 29, so each module prints ~12% smaller,
|
|
199
|
+
* and ZBar then missed tilted stickers; Q read 100% on ZXing, jsQR and ZBar.
|
|
200
|
+
*/
|
|
148
201
|
export const QR_ECC = "Q";
|
|
202
|
+
/**
|
|
203
|
+
* Paper ring between a shaped code and its pattern, in modules. 0 never read;
|
|
204
|
+
* 1 missed a few tilted large hexagons; 2 is the smallest that read 100%.
|
|
205
|
+
*/
|
|
206
|
+
export const QR_RING = 2;
|
|
149
207
|
const STROKE = 0.35;
|
|
150
208
|
|
|
151
209
|
/** A rectangle with per-corner radii [tl, tr, br, bl], or concave cut corners. */
|
|
152
210
|
export type Box = { t: "box"; x: number; y: number; w: number; h: number; r?: number[]; notch?: number };
|
|
153
211
|
export type Circle = { t: "circle"; cx: number; cy: number; r: number };
|
|
154
|
-
/** A closed outline, points in mm (the
|
|
212
|
+
/** A closed outline, points in mm (the shapes). */
|
|
155
213
|
export type Poly = { t: "poly"; pts: [number, number][] };
|
|
156
214
|
export type Shape = Box | Circle | Poly;
|
|
157
215
|
/** A filled shape; `hole` is cut out of it (rings, the frame line). */
|
|
158
216
|
export type Paint = { shape: Shape; hole?: Shape; fill: string };
|
|
159
217
|
|
|
160
|
-
|
|
161
|
-
export function qrTop(frame: FrameShape): number {
|
|
162
|
-
if (isSilhouette(frame)) return silhouetteLayout(frame).qrTop;
|
|
163
|
-
return frame === "arch" ? 7 : 2.5;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Size of the sticker face in mm. */
|
|
167
|
-
export function cardSize(frame: FrameShape): { w: number; h: number } {
|
|
168
|
-
return isSilhouette(frame) ? { w: SHAPE_W, h: SHAPE_H } : { w: CARD_W, h: CARD_H };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ---- cut-out shapes (all in mm; each fits a 62 x 58 box) ---------------------
|
|
218
|
+
// ---- shaped codes (all in mm; each fits a 62 x 58 box at Standard size) ------
|
|
172
219
|
|
|
173
220
|
export const SHAPE_W = 62;
|
|
174
221
|
export const SHAPE_H = 58;
|
|
175
|
-
/** The
|
|
222
|
+
/** The code + ring keeps at least this much paper to the shape's edge, so a slightly-off cut never reaches the code. */
|
|
176
223
|
export const SHAPE_EDGE_MARGIN = 1;
|
|
177
|
-
/**
|
|
178
|
-
export const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
224
|
+
/** Card round a shaped code (frame other than "none"): this much card on every side. */
|
|
225
|
+
export const FRAME_PAD = 2.5;
|
|
226
|
+
/**
|
|
227
|
+
* The plate tag: a solid ink box along the bottom of a shaped code, plate number
|
|
228
|
+
* (and the optional words) in paper colour. The code sits above it; the pattern
|
|
229
|
+
* fills round it, so the shape stays solid code with a name tag, not a hole.
|
|
230
|
+
*/
|
|
231
|
+
export const PLATE_TAG = { x: 14, y: 50, w: 34, h: 8 };
|
|
232
|
+
/** Chance a pattern cell is tried; after the no-block / no-timing rules about half the cells are dark, like the code. */
|
|
233
|
+
const PATTERN_DENSITY = 0.62;
|
|
183
234
|
|
|
184
235
|
type Pt = [number, number];
|
|
185
236
|
const MID = SHAPE_W / 2;
|
|
@@ -219,8 +270,15 @@ function bezierPts(p0: Pt, c: Pt, p1: Pt, steps = 20): Pt[] {
|
|
|
219
270
|
});
|
|
220
271
|
}
|
|
221
272
|
|
|
273
|
+
/** From `tip`, along the tangent, round the circle (cx, cy, r) and back: right half only. */
|
|
274
|
+
function tipAndCircleHalf(tip: Pt, cx: number, cy: number, r: number, endAngle: number, steps = 48): Pt[] {
|
|
275
|
+
const base = Math.atan2(tip[1] - cy, tip[0] - cx);
|
|
276
|
+
const tangent = base + Math.acos(r / Math.hypot(tip[0] - cx, tip[1] - cy));
|
|
277
|
+
return [tip, ...arcPts(cx, cy, r, tangent, endAngle, steps)];
|
|
278
|
+
}
|
|
279
|
+
|
|
222
280
|
function heartPts(): Pt[] {
|
|
223
|
-
// Two round lobes and a point; lobes wide enough that the
|
|
281
|
+
// Two round lobes and a point; lobes wide enough that the code fits between them.
|
|
224
282
|
const c = 11;
|
|
225
283
|
const r = 20;
|
|
226
284
|
const cx = MID + c;
|
|
@@ -233,7 +291,7 @@ function heartPts(): Pt[] {
|
|
|
233
291
|
|
|
234
292
|
function starPts(): Pt[] {
|
|
235
293
|
// A star badge (seal): 14 short points round a circle. A thin five-point
|
|
236
|
-
// star has no room for a
|
|
294
|
+
// star has no room for a code of at least 25 mm at this size.
|
|
237
295
|
const R = SHAPE_H / 2;
|
|
238
296
|
return Array.from({ length: 28 }, (_, i) => {
|
|
239
297
|
const a = -Math.PI / 2 + (i * Math.PI) / 14;
|
|
@@ -251,13 +309,31 @@ function bubblePts(): Pt[] {
|
|
|
251
309
|
return [...box.slice(0, split), ...tail, ...box.slice(split)];
|
|
252
310
|
}
|
|
253
311
|
|
|
312
|
+
function blobPts(): Pt[] {
|
|
313
|
+
// A soft, slightly lumpy round shape.
|
|
314
|
+
const wob = (a: number) => (1 + 0.045 * Math.sin(3 * a + 0.6) + 0.03 * Math.cos(5 * a)) / 1.075;
|
|
315
|
+
return Array.from({ length: 144 }, (_, i) => {
|
|
316
|
+
const a = (i * 2 * Math.PI) / 144;
|
|
317
|
+
return [MID + 30.5 * wob(a) * Math.cos(a), SHAPE_H / 2 + 28.5 * wob(a) * Math.sin(a)] as Pt;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function dropletPts(): Pt[] {
|
|
322
|
+
// A water drop, point up.
|
|
323
|
+
const r = 27.5;
|
|
324
|
+
const cy = SHAPE_H - r;
|
|
325
|
+
const half = tipAndCircleHalf([MID, 0.3], MID, cy, r, Math.PI / 2);
|
|
326
|
+
return mirror(half);
|
|
327
|
+
}
|
|
328
|
+
|
|
254
329
|
function carPts(): Pt[] {
|
|
255
|
-
// Front view: roof, windscreen, wing mirrors, body and the two front wheels.
|
|
330
|
+
// Front view: roof, windscreen, wing mirrors, body and the two front wheels (the plate tag sits between them).
|
|
256
331
|
return mirror([
|
|
257
332
|
[MID, 2.5], [44.5, 2.5], [46.8, 3.2], [47.8, 4.6], [54, 20.5],
|
|
258
333
|
[56, 20.2], [60.6, 19.2], [61.6, 21], [61.4, 23], [59.4, 24.2],
|
|
259
334
|
[60.6, 26], [61.5, 29.5], [61.5, 47], [60.2, 49],
|
|
260
|
-
|
|
335
|
+
// wheels joined by the bumper line, so the plate tag closes the bottom with no gap to cut round
|
|
336
|
+
[58.5, 49], [58.5, 56.2], [57.6, 57.4], [MID, 57.4],
|
|
261
337
|
]);
|
|
262
338
|
}
|
|
263
339
|
|
|
@@ -277,6 +353,8 @@ const SILHOUETTE_BUILDERS: Record<SilhouetteShape, () => Pt[]> = {
|
|
|
277
353
|
]),
|
|
278
354
|
bubble: bubblePts,
|
|
279
355
|
roundsquare: () => roundRectPts(2, 0, 58, 58, 14),
|
|
356
|
+
blob: blobPts,
|
|
357
|
+
droplet: dropletPts,
|
|
280
358
|
car: carPts,
|
|
281
359
|
};
|
|
282
360
|
|
|
@@ -304,57 +382,156 @@ export function rectInside(pts: Pt[], x: number, y: number, w: number, h: number
|
|
|
304
382
|
return true;
|
|
305
383
|
}
|
|
306
384
|
|
|
307
|
-
|
|
385
|
+
/** Where the code goes in a shape: `qr` is the code + its paper ring (a square, side `s`); `band` is the plate tag. */
|
|
386
|
+
export type SilhouetteLayout = { pts: Pt[]; qr: { x: number; y: number; s: number }; band: { x: number; y: number; w: number; h: number } };
|
|
308
387
|
const layoutCache = new Map<SilhouetteShape, SilhouetteLayout>();
|
|
309
388
|
|
|
389
|
+
/** Tops where a code square of side s fits inside the shape and above the plate tag. */
|
|
390
|
+
function fits(pts: Pt[], s: number): number[] {
|
|
391
|
+
const e = SHAPE_EDGE_MARGIN;
|
|
392
|
+
const tops: number[] = [];
|
|
393
|
+
for (let top = e; top + s + e <= PLATE_TAG.y; top += 0.25) {
|
|
394
|
+
if (rectInside(pts, MID - s / 2 - e, top - e, s + 2 * e, s + 2 * e)) tops.push(top);
|
|
395
|
+
}
|
|
396
|
+
return tops;
|
|
397
|
+
}
|
|
398
|
+
|
|
310
399
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
400
|
+
* The biggest code that fits the shape above the plate tag, keeping
|
|
401
|
+
* SHAPE_EDGE_MARGIN of paper to the edge. Bigger code = bigger modules =
|
|
402
|
+
* easier to scan; the pattern fills what is left.
|
|
314
403
|
*/
|
|
315
404
|
export function silhouetteLayout(shape: SilhouetteShape): SilhouetteLayout {
|
|
316
405
|
const cached = layoutCache.get(shape);
|
|
317
406
|
if (cached) return cached;
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
let
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if (w > best.w) best = { w, tops: [] };
|
|
328
|
-
best.tops.push(top);
|
|
407
|
+
// The whole shape sits above the plate tag (overlapping it 1.5 mm, so shape and tag cut out as one piece).
|
|
408
|
+
const k = (PLATE_TAG.y + 1.5) / SHAPE_H;
|
|
409
|
+
const pts = SILHOUETTE_BUILDERS[shape]().map(([x, y]) => [MID + (x - MID) * k, y * k] as Pt);
|
|
410
|
+
let lo = 10;
|
|
411
|
+
let hi = PLATE_TAG.y;
|
|
412
|
+
while (hi - lo > 0.05) {
|
|
413
|
+
const s = (lo + hi) / 2;
|
|
414
|
+
if (fits(pts, s).length) lo = s;
|
|
415
|
+
else hi = s;
|
|
329
416
|
}
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
417
|
+
const s = Math.floor(lo * 20) / 20;
|
|
418
|
+
const tops = fits(pts, s);
|
|
419
|
+
if (!tops.length) throw new Error(`QR does not fit the ${shape} shape`);
|
|
420
|
+
const top = tops[Math.floor(tops.length / 2)];
|
|
421
|
+
const layout = { pts, qr: { x: MID - s / 2, y: top, s }, band: { ...PLATE_TAG } };
|
|
333
422
|
layoutCache.set(shape, layout);
|
|
334
423
|
return layout;
|
|
335
424
|
}
|
|
336
425
|
|
|
337
|
-
/**
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
426
|
+
/** Everything about where things sit on one sticker face (mm at Standard size; times `scale` on paper). */
|
|
427
|
+
export type StickerGeometry = {
|
|
428
|
+
scale: number;
|
|
429
|
+
/** The face (card or shape) - the SVG viewBox. */
|
|
430
|
+
w: number;
|
|
431
|
+
h: number;
|
|
432
|
+
/** Module size and the top-left of the code's first module. */
|
|
433
|
+
m: number;
|
|
434
|
+
ox: number;
|
|
435
|
+
oy: number;
|
|
436
|
+
/** The QR code on paper: its data area (no quiet zone / ring), mm. */
|
|
437
|
+
dataMm: number;
|
|
438
|
+
/** The shape (or card) on paper, mm. */
|
|
439
|
+
shapeW: number;
|
|
440
|
+
shapeH: number;
|
|
441
|
+
/** Shaped codes: outline and plate tag, already placed on the face. */
|
|
442
|
+
pts?: Pt[];
|
|
443
|
+
band?: { x: number; y: number; w: number; h: number };
|
|
444
|
+
/** Classic: top of the 32 mm QR on the card. */
|
|
445
|
+
qrTop?: number;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
export function stickerGeometry(design: StickerDesign, n: number): StickerGeometry {
|
|
449
|
+
const scale = sizeScale(design.size);
|
|
450
|
+
if (!isSilhouette(design.shape)) {
|
|
451
|
+
const m = QR_MM / (n + QUIET_MODULES * 2);
|
|
452
|
+
const top = design.frame === "arch" ? 7 : 2.5;
|
|
453
|
+
return {
|
|
454
|
+
scale, w: CARD_W, h: CARD_H, m,
|
|
455
|
+
ox: (CARD_W - QR_MM) / 2 + QUIET_MODULES * m, oy: top + QUIET_MODULES * m,
|
|
456
|
+
dataMm: n * m * scale, shapeW: CARD_W * scale, shapeH: CARD_H * scale, qrTop: top,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const L = silhouetteLayout(design.shape);
|
|
460
|
+
const pad = design.frame === "none" ? 0 : FRAME_PAD;
|
|
461
|
+
const m = L.qr.s / (n + QR_RING * 2);
|
|
462
|
+
const pts = L.pts.map(([x, y]) => [x + pad, y + pad] as Pt);
|
|
463
|
+
const xs = pts.map((p) => p[0]);
|
|
464
|
+
const ys = pts.map((p) => p[1]);
|
|
465
|
+
return {
|
|
466
|
+
scale, w: SHAPE_W + 2 * pad, h: SHAPE_H + 2 * pad, m,
|
|
467
|
+
ox: L.qr.x + pad + QR_RING * m, oy: L.qr.y + pad + QR_RING * m,
|
|
468
|
+
dataMm: n * m * scale,
|
|
469
|
+
shapeW: (Math.max(...xs) - Math.min(...xs)) * scale,
|
|
470
|
+
shapeH: (Math.max(...ys) - Math.min(...ys)) * scale,
|
|
471
|
+
pts, band: { ...L.band, x: L.band.x + pad, y: L.band.y + pad },
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Sizes that keep the printed code at least MIN_QR_DATA_MM. */
|
|
476
|
+
export function sizeOptionsFor(design: StickerDesign, n: number) {
|
|
477
|
+
return SIZE_OPTIONS.filter((o) => stickerGeometry({ ...design, size: o.value }, n).dataMm >= MIN_QR_DATA_MM - 1e-9);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Same number for the same text + cell every time (no randomness between prints). */
|
|
481
|
+
function hashCell(seed: number, row: number, col: number): number {
|
|
482
|
+
let h = seed ^ Math.imul(row + 1000, 73856093) ^ Math.imul(col + 1000, 19349663);
|
|
346
483
|
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
|
|
347
484
|
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
|
|
348
|
-
return ((h ^ (h >>> 16)) >>> 0) / 4294967296
|
|
485
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
349
486
|
}
|
|
350
487
|
|
|
351
|
-
function
|
|
488
|
+
function seedOf(text: string): number {
|
|
489
|
+
let h = 0x811c9dc5;
|
|
490
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
|
|
491
|
+
return h >>> 0;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The pattern round a shaped code: cells on the code's grid, whole inside the
|
|
496
|
+
* outline, off the ring and the plate tag. Placed row by row; a cell stays
|
|
497
|
+
* light if it would close a 2 x 2 dark block (no solid centre = nothing a
|
|
498
|
+
* scanner can take for a QR corner or alignment square) or end a
|
|
499
|
+
* dark-light-dark-light-dark run (a timing line).
|
|
500
|
+
*/
|
|
501
|
+
export function patternCells(g: StickerGeometry, n: number, seed = ""): Set<string> {
|
|
502
|
+
const { m, ox, oy, pts, band } = g;
|
|
503
|
+
const cells = new Set<string>();
|
|
504
|
+
if (!pts || !band) return cells;
|
|
505
|
+
const s = seedOf(seed);
|
|
506
|
+
const near = 0.4; // mm of paper kept inside the edge
|
|
507
|
+
const clear = 0.6; // mm of paper kept round the plate tag
|
|
508
|
+
const has = (r: number, c: number) => cells.has(`${r},${c}`);
|
|
509
|
+
for (let row = Math.floor(-oy / m) - 1; oy + row * m < g.h; row++) {
|
|
510
|
+
for (let col = Math.floor(-ox / m) - 1; ox + col * m < g.w; col++) {
|
|
511
|
+
if (row >= -QR_RING && row < n + QR_RING && col >= -QR_RING && col < n + QR_RING) continue;
|
|
512
|
+
if (hashCell(s, row, col) >= PATTERN_DENSITY) continue;
|
|
513
|
+
const x = ox + col * m;
|
|
514
|
+
const y = oy + row * m;
|
|
515
|
+
if (x + m > band.x - clear && x < band.x + band.w + clear && y + m > band.y - clear && y < band.y + band.h + clear) continue;
|
|
516
|
+
const corners: Pt[] = [[x - near, y - near], [x + m + near, y - near], [x - near, y + m + near], [x + m + near, y + m + near]];
|
|
517
|
+
if (!corners.every(([px, py]) => pointInPoly(pts, px, py))) continue;
|
|
518
|
+
if (has(row - 1, col) && has(row, col - 1) && has(row - 1, col - 1)) continue;
|
|
519
|
+
if (has(row, col - 2) && has(row, col - 4) && !has(row, col - 1) && !has(row, col - 3)) continue;
|
|
520
|
+
if (has(row - 2, col) && has(row - 4, col) && !has(row - 1, col) && !has(row - 3, col)) continue;
|
|
521
|
+
cells.add(`${row},${col}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return cells;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function cardBox(frame: FrameShape, inset: number, W = CARD_W, H = CARD_H): Box {
|
|
352
528
|
const x = inset;
|
|
353
|
-
const w =
|
|
354
|
-
const h =
|
|
529
|
+
const w = W - inset * 2;
|
|
530
|
+
const h = H - inset * 2;
|
|
355
531
|
const r = (v: number) => Math.max(0, v - inset);
|
|
356
532
|
switch (frame) {
|
|
357
533
|
case "square":
|
|
534
|
+
case "none":
|
|
358
535
|
return { t: "box", x, y: x, w, h, r: [r(0.8), r(0.8), r(0.8), r(0.8)] };
|
|
359
536
|
case "soft":
|
|
360
537
|
return { t: "box", x, y: x, w, h, r: [r(8), r(8), r(8), r(8)] };
|
|
@@ -371,42 +548,23 @@ function isFinder(n: number, row: number, col: number): boolean {
|
|
|
371
548
|
return (row < 7 && col < 7) || (row < 7 && col >= n - 7) || (row >= n - 7 && col < 7);
|
|
372
549
|
}
|
|
373
550
|
|
|
374
|
-
/** Every shape on the sticker face, painted in order. */
|
|
375
|
-
export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Paint[] {
|
|
551
|
+
/** Every shape on the sticker face, painted in order. `seed` (the payload) picks the pattern round a shaped code. */
|
|
552
|
+
export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign, seed = ""): Paint[] {
|
|
376
553
|
const theme = stickerTheme(design.theme);
|
|
377
554
|
const n = matrix.size;
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
const oy = qrTop(design.frame) + QUIET_MODULES * m;
|
|
555
|
+
const g = stickerGeometry(design, n);
|
|
556
|
+
const { m, ox, oy } = g;
|
|
381
557
|
const paints: Paint[] = [];
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
paints.push({ shape: { t: "poly", pts }, fill: theme.paper });
|
|
385
|
-
// The rest of the shape: small round dots on the QR's grid, kept off the
|
|
386
|
-
// quiet zone, the plate box and the edge. Small separate dots never join
|
|
387
|
-
// into the solid bars a scanner looks for in a QR corner.
|
|
388
|
-
const near = 0.4; // mm of paper kept inside the edge
|
|
389
|
-
const clear = 1.2; // mm of paper kept round the plate box
|
|
390
|
-
const clearRing = QUIET_MODULES + 3; // pattern starts three modules beyond the quiet zone
|
|
391
|
-
for (let row = Math.floor(-oy / m) - 1; oy + row * m < SHAPE_H; row++) {
|
|
392
|
-
for (let col = Math.floor(-ox / m) - 1; ox + col * m < SHAPE_W; col++) {
|
|
393
|
-
if (row >= -clearRing && row < n + clearRing && col >= -clearRing && col < n + clearRing) continue;
|
|
394
|
-
if (!patternDot(row, col)) continue;
|
|
395
|
-
const x = ox + col * m;
|
|
396
|
-
const y = oy + row * m;
|
|
397
|
-
if (x + m > band.x - clear && x < band.x + band.w + clear && y + m > band.y - clear && y < band.y + band.h + clear) continue;
|
|
398
|
-
const corners: Pt[] = [[x - near, y - near], [x + m + near, y - near], [x - near, y + m + near], [x + m + near, y + m + near]];
|
|
399
|
-
if (!corners.every(([px, py]) => pointInPoly(pts, px, py))) continue;
|
|
400
|
-
paints.push({ shape: { t: "circle", cx: x + m / 2, cy: y + m / 2, r: m * 0.28 }, fill: theme.ink });
|
|
401
|
-
}
|
|
402
|
-
}
|
|
558
|
+
if (g.pts && design.frame === "none") {
|
|
559
|
+
paints.push({ shape: { t: "poly", pts: g.pts }, fill: theme.paper });
|
|
403
560
|
} else {
|
|
404
|
-
paints.push(
|
|
405
|
-
|
|
406
|
-
{ shape: cardBox(design.frame, 0), hole: cardBox(design.frame, STROKE), fill: theme.ink }
|
|
407
|
-
|
|
561
|
+
paints.push({ shape: cardBox(design.frame, 0, g.w, g.h), fill: theme.paper });
|
|
562
|
+
if (design.frame !== "none") {
|
|
563
|
+
paints.push({ shape: cardBox(design.frame, 0, g.w, g.h), hole: cardBox(design.frame, STROKE, g.w, g.h), fill: theme.ink });
|
|
564
|
+
}
|
|
408
565
|
}
|
|
409
566
|
const box = (x: number, y: number, w: number, h: number, rad = 0): Box => ({ t: "box", x, y, w, h, r: [rad, rad, rad, rad] });
|
|
567
|
+
if (g.band) paints.push({ shape: box(g.band.x, g.band.y, g.band.w, g.band.h, 2), fill: theme.ink });
|
|
410
568
|
|
|
411
569
|
// Round dots break the small alignment square (bottom right) into a ring of
|
|
412
570
|
// separate dots that some readers miss, so with dots it is drawn solid.
|
|
@@ -415,9 +573,17 @@ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Pain
|
|
|
415
573
|
const align = design.dots === "dots" && version >= 2 && version <= 6 ? n - 7 : -1;
|
|
416
574
|
const inAlign = (row: number, col: number) => align > 0 && Math.abs(row - align) <= 2 && Math.abs(col - align) <= 2;
|
|
417
575
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
576
|
+
const pattern = patternCells(g, n, seed);
|
|
577
|
+
const inCode = (row: number, col: number) => row >= 0 && col >= 0 && row < n && col < n;
|
|
578
|
+
const dark = (row: number, col: number) =>
|
|
579
|
+
inCode(row, col) ? !!matrix.get(row, col) && !isFinder(n, row, col) && !inAlign(row, col) : pattern.has(`${row},${col}`);
|
|
580
|
+
const [r0, r1, c0, c1] = g.pts
|
|
581
|
+
? [Math.floor(-oy / m) - 1, Math.ceil((g.h - oy) / m), Math.floor(-ox / m) - 1, Math.ceil((g.w - ox) / m)]
|
|
582
|
+
: [0, n - 1, 0, n - 1];
|
|
583
|
+
|
|
584
|
+
for (let row = r0; row <= r1; row++) {
|
|
585
|
+
for (let col = c0; col <= c1; col++) {
|
|
586
|
+
if (!dark(row, col)) continue;
|
|
421
587
|
const x = ox + col * m;
|
|
422
588
|
const y = oy + row * m;
|
|
423
589
|
if (design.dots === "dots") {
|
|
@@ -426,30 +592,23 @@ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Pain
|
|
|
426
592
|
// ponytail: one rect per horizontal run, so adjacent squares never show a hairline seam
|
|
427
593
|
// (rounded: a run is one pill, no notches between neighbours for a camera to trip on)
|
|
428
594
|
let end = col;
|
|
429
|
-
while (end + 1
|
|
595
|
+
while (end + 1 <= c1 && dark(row, end + 1)) end++;
|
|
430
596
|
paints.push({ shape: box(x, y, (end - col + 1) * m, m, design.dots === "rounded" ? m * 0.3 : 0), fill: theme.ink });
|
|
431
597
|
col = end;
|
|
432
598
|
}
|
|
433
599
|
}
|
|
434
600
|
}
|
|
435
601
|
|
|
436
|
-
for (const [
|
|
437
|
-
const x = ox +
|
|
438
|
-
const y = oy +
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
paints.push({
|
|
447
|
-
shape: box(x, y, 7 * m, 7 * m, round ? 2 * m : 0),
|
|
448
|
-
hole: box(x + m, y + m, 5 * m, 5 * m, round ? 1.2 * m : 0),
|
|
449
|
-
fill: theme.eye,
|
|
450
|
-
});
|
|
451
|
-
paints.push({ shape: box(x + 2 * m, y + 2 * m, 3 * m, 3 * m, round ? 0.9 * m : 0), fill: theme.eye });
|
|
452
|
-
}
|
|
602
|
+
for (const [fr, fc] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
|
|
603
|
+
const x = ox + fc * m;
|
|
604
|
+
const y = oy + fr * m;
|
|
605
|
+
const round = design.eyes === "rounded";
|
|
606
|
+
paints.push({
|
|
607
|
+
shape: box(x, y, 7 * m, 7 * m, round ? 2 * m : 0),
|
|
608
|
+
hole: box(x + m, y + m, 5 * m, 5 * m, round ? 1.2 * m : 0),
|
|
609
|
+
fill: theme.eye,
|
|
610
|
+
});
|
|
611
|
+
paints.push({ shape: box(x + 2 * m, y + 2 * m, 3 * m, 3 * m, round ? 0.9 * m : 0), fill: theme.eye });
|
|
453
612
|
}
|
|
454
613
|
if (align > 0) {
|
|
455
614
|
const x = ox + (align - 2) * m;
|
|
@@ -492,24 +651,29 @@ function shapePath(s: Shape): string {
|
|
|
492
651
|
return `M${f(cx - r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx + r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx - r)} ${f(cy)}Z`;
|
|
493
652
|
}
|
|
494
653
|
|
|
495
|
-
/** Gap between the sticker's edge and its grey cut line, mm. */
|
|
654
|
+
/** Gap between the sticker's edge and its grey cut line, mm on paper. */
|
|
496
655
|
export const CUT_LINE_GAP = 1;
|
|
497
656
|
|
|
498
657
|
/**
|
|
499
|
-
* The face as one SVG
|
|
500
|
-
* same-colour shapes share one path. `cutLine` adds
|
|
501
|
-
* CUT_LINE_GAP outside the edge: a wide grey stroke under a
|
|
502
|
-
* follows any outline, with rounded corners that are easy to cut.
|
|
658
|
+
* The face as one SVG. `w` x `h` is the drawing (mm at Standard size), printed
|
|
659
|
+
* `scale` times that. Runs of same-colour shapes share one path. `cutLine` adds
|
|
660
|
+
* a thin grey line CUT_LINE_GAP outside the edge: a wide grey stroke under a
|
|
661
|
+
* white one, so it follows any outline, with rounded corners that are easy to cut.
|
|
503
662
|
*/
|
|
504
|
-
export function paintsToSvg(
|
|
663
|
+
export function paintsToSvg(
|
|
664
|
+
paints: Paint[],
|
|
665
|
+
opts: { w?: number; h?: number; scale?: number; cutLine?: boolean; cutShapes?: Shape[] } = {},
|
|
666
|
+
): string {
|
|
505
667
|
const w = opts.w ?? CARD_W;
|
|
506
668
|
const h = opts.h ?? CARD_H;
|
|
669
|
+
const k = opts.scale ?? 1;
|
|
507
670
|
const out: string[] = [];
|
|
508
671
|
if (opts.cutLine && paints[0]) {
|
|
509
|
-
|
|
672
|
+
// One outline round everything listed (a shaped code + its plate tag), drawn under the fills.
|
|
673
|
+
const edge = (opts.cutShapes ?? [paints[0].shape]).map(shapePath).join("");
|
|
510
674
|
out.push(
|
|
511
|
-
`<path fill="none" stroke="#8c8c8c" stroke-linejoin="round" stroke-width="${f(CUT_LINE_GAP * 2 + 0.5)}" d="${edge}"/>`,
|
|
512
|
-
`<path fill="none" stroke="#ffffff" stroke-linejoin="round" stroke-width="${f(CUT_LINE_GAP * 2)}" d="${edge}"/>`,
|
|
675
|
+
`<path fill="none" stroke="#8c8c8c" stroke-linejoin="round" stroke-width="${f((CUT_LINE_GAP * 2 + 0.5) / k)}" d="${edge}"/>`,
|
|
676
|
+
`<path fill="none" stroke="#ffffff" stroke-linejoin="round" stroke-width="${f((CUT_LINE_GAP * 2) / k)}" d="${edge}"/>`,
|
|
513
677
|
);
|
|
514
678
|
}
|
|
515
679
|
let fill = "";
|
|
@@ -532,14 +696,18 @@ export function paintsToSvg(paints: Paint[], opts: { w?: number; h?: number; cut
|
|
|
532
696
|
}
|
|
533
697
|
flush();
|
|
534
698
|
return (
|
|
535
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}mm" height="${h}mm" overflow="visible">` +
|
|
699
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${f(w * k)}mm" height="${f(h * k)}mm" overflow="visible">` +
|
|
536
700
|
out.join("") +
|
|
537
701
|
`</svg>`
|
|
538
702
|
);
|
|
539
703
|
}
|
|
540
704
|
|
|
541
|
-
export function stickerFaceSvg(matrix: QrMatrix, design: StickerDesign): string {
|
|
542
|
-
|
|
705
|
+
export function stickerFaceSvg(matrix: QrMatrix, design: StickerDesign, seed = ""): string {
|
|
706
|
+
const g = stickerGeometry(design, matrix.size);
|
|
707
|
+
const paints = stickerFacePaints(matrix, design, seed);
|
|
708
|
+
// A shaped code with no card is cut round the shape and its plate tag together.
|
|
709
|
+
const cutShapes = g.pts && design.frame === "none" ? [paints[0].shape, paints[1].shape] : undefined;
|
|
710
|
+
return paintsToSvg(paints, { w: g.w, h: g.h, scale: g.scale, cutLine: design.cutLines !== false, cutShapes });
|
|
543
711
|
}
|
|
544
712
|
|
|
545
713
|
/** The words under the plate for this design ("" = none). */
|