@pooder/kit 4.1.0 → 4.3.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,17 +1,35 @@
1
- import { DielineFeature } from "./geometry";
1
+ import { DielineFeature, getNearestPointOnDieline, getLowestPointOnDieline } from "./geometry";
2
2
 
3
3
  export interface ConstraintContext {
4
4
  dielineWidth: number;
5
5
  dielineHeight: number;
6
+ // Context may need access to geometry functions or the geometry itself
7
+ // For now, getNearestPointOnDieline creates its own paper scope, but ideally we pass a simplified geometry representation
8
+ geometry?: any;
9
+ }
10
+
11
+ export interface ConstraintFeature extends DielineFeature {
12
+ constraints?: Array<{
13
+ type: string;
14
+ params?: any;
15
+ validateOnly?: boolean;
16
+ }>;
6
17
  }
7
18
 
8
19
  export type ConstraintHandler = (
9
20
  x: number,
10
21
  y: number,
11
- feature: DielineFeature,
12
- context: ConstraintContext
22
+ feature: ConstraintFeature,
23
+ context: ConstraintContext,
24
+ params?: any
13
25
  ) => { x: number; y: number };
14
26
 
27
+ export interface ConstraintConfig {
28
+ type: string;
29
+ params?: any;
30
+ validateOnly?: boolean;
31
+ }
32
+
15
33
  export class ConstraintRegistry {
16
34
  private static handlers = new Map<string, ConstraintHandler>();
17
35
 
@@ -22,40 +40,118 @@ export class ConstraintRegistry {
22
40
  static apply(
23
41
  x: number,
24
42
  y: number,
25
- feature: DielineFeature,
26
- context: ConstraintContext
43
+ feature: ConstraintFeature,
44
+ context: ConstraintContext,
45
+ constraints?: ConstraintConfig[] // Optional override, defaults to feature.constraints
27
46
  ): { x: number; y: number } {
28
- if (!feature.constraints || !feature.constraints.type) {
47
+ const list = constraints || feature.constraints;
48
+ if (!list || list.length === 0) {
29
49
  return { x, y };
30
50
  }
31
51
 
32
- const handler = this.handlers.get(feature.constraints.type);
33
- if (handler) {
34
- return handler(x, y, feature, context);
52
+ let currentX = x;
53
+ let currentY = y;
54
+
55
+ for (const constraint of list) {
56
+ const handler = this.handlers.get(constraint.type);
57
+ if (handler) {
58
+ const result = handler(currentX, currentY, feature, context, constraint.params || {});
59
+ currentX = result.x;
60
+ currentY = result.y;
61
+ }
35
62
  }
36
63
 
37
- return { x, y };
64
+ return { x: currentX, y: currentY };
38
65
  }
39
66
  }
40
67
 
41
68
  // --- Built-in Strategies ---
42
69
 
43
70
  /**
44
- * Edge Constraint Strategy
45
- * Snaps the feature to the nearest allowed edge.
46
- * Params:
47
- * - allowedEdges: ('top' | 'bottom' | 'left' | 'right')[] (default: all)
48
- * - confine: boolean (default: false) - if true, keeps feature within edge length
49
- * - offset: number (default: 0) - physical offset from edge (positive = inwards usually, but here 0 is edge)
50
- * For simplicity, let's say offset is additive to the edge position.
51
- * Top: 0 + offset
52
- * Bottom: 1 - offset
53
- * Left: 0 + offset
54
- * Right: 1 - offset
71
+ * Path Constraint Strategy (formerly placement='edge')
72
+ * Snaps the feature to the nearest point on the Dieline Path.
73
+ */
74
+ const pathConstraint: ConstraintHandler = (x, y, feature, context, params) => {
75
+ // We need to denormalize, find nearest, then normalize back
76
+ // This is expensive but accurate.
77
+ const { dielineWidth, dielineHeight, geometry } = context;
78
+ if (!geometry) return { x, y }; // Cannot snap without geometry
79
+
80
+ // Geometry is centered at (cx, cy)
81
+ // x, y are normalized (0-1) relative to bounding box
82
+ const minX = geometry.x - geometry.width / 2;
83
+ const minY = geometry.y - geometry.height / 2;
84
+
85
+ const absX = minX + x * geometry.width;
86
+ const absY = minY + y * geometry.height;
87
+
88
+ // Use geometry helper
89
+ // Note: getNearestPointOnDieline creates a fresh paper scope each time.
90
+ // Optimization: geometry object passed in context could be a reusable paper path?
91
+ // For now, keep it simple as per existing logic.
92
+ const nearest = getNearestPointOnDieline(
93
+ { x: absX, y: absY },
94
+ geometry
95
+ );
96
+
97
+ let finalX = nearest.x;
98
+ let finalY = nearest.y;
99
+
100
+ // Only allow vertical offset if explicit offset limits are provided
101
+ // Otherwise, we snap strictly to the path (offset = 0)
102
+ const hasOffsetParams = params.minOffset !== undefined || params.maxOffset !== undefined;
103
+
104
+ if (hasOffsetParams && nearest.normal) {
105
+ // Project the cursor vector onto the normal vector
106
+ // This ensures the feature stays on the "normal line" of the nearest path point
107
+ const dx = absX - nearest.x;
108
+ const dy = absY - nearest.y;
109
+
110
+ const nx = nearest.normal.x;
111
+ const ny = nearest.normal.y;
112
+
113
+ // Dot product to get scalar projection
114
+ const dist = dx * nx + dy * ny;
115
+
116
+ // Limit the offset
117
+ // geometry.width is in pixels, dielineWidth is in physical units (e.g. mm)
118
+ // We assume dielineWidth corresponds to geometry.width
119
+ const scale = dielineWidth > 0 ? geometry.width / dielineWidth : 1;
120
+
121
+ // If one is provided but the other is not, default the other to 0.
122
+ // If neither is provided (shouldn't happen due to hasOffsetParams check), default to 0.
123
+ const rawMin = params.minOffset !== undefined ? params.minOffset : 0;
124
+ const rawMax = params.maxOffset !== undefined ? params.maxOffset : 0;
125
+
126
+ // However, if we want to allow one-sided infinity, user must explicitly provide Infinity?
127
+ // Wait, user requirement: "If only one is passed, the other defaults to 0."
128
+ // This implies:
129
+ // { minOffset: -5 } -> maxOffset = 0 (range: -5 to 0)
130
+ // { maxOffset: 5 } -> minOffset = 0 (range: 0 to 5)
131
+ // { minOffset: -5, maxOffset: 5 } -> (range: -5 to 5)
132
+
133
+ const minOffset = rawMin * scale;
134
+ const maxOffset = rawMax * scale;
135
+
136
+ const clampedDist = Math.max(minOffset, Math.min(dist, maxOffset));
137
+
138
+ finalX = nearest.x + nx * clampedDist;
139
+ finalY = nearest.y + ny * clampedDist;
140
+ }
141
+
142
+ // Re-normalize
143
+ const nx = geometry.width > 0 ? (finalX - minX) / geometry.width : 0.5;
144
+ const ny = geometry.height > 0 ? (finalY - minY) / geometry.height : 0.5;
145
+
146
+ return { x: nx, y: ny };
147
+ };
148
+
149
+ /**
150
+ * Edge Constraint Strategy (Box Edge)
151
+ * Snaps the feature to the nearest allowed edge of the BOUNDING BOX.
55
152
  */
56
- const edgeConstraint: ConstraintHandler = (x, y, feature, context) => {
153
+ const edgeConstraint: ConstraintHandler = (x, y, feature, context, params) => {
57
154
  const { dielineWidth, dielineHeight } = context;
58
- const params = feature.constraints?.params || {};
59
155
  const allowedEdges = params.allowedEdges || [
60
156
  "top",
61
157
  "bottom",
@@ -130,12 +226,9 @@ const edgeConstraint: ConstraintHandler = (x, y, feature, context) => {
130
226
  /**
131
227
  * Internal Constraint Strategy
132
228
  * Keeps the feature strictly inside the dieline bounds with optional margin.
133
- * Params:
134
- * - margin: number (default: 0) - physical margin
135
229
  */
136
- const internalConstraint: ConstraintHandler = (x, y, feature, context) => {
230
+ const internalConstraint: ConstraintHandler = (x, y, feature, context, params) => {
137
231
  const { dielineWidth, dielineHeight } = context;
138
- const params = feature.constraints?.params || {};
139
232
  const margin = params.margin || 0;
140
233
  const fw = feature.width || 0;
141
234
  const fh = feature.height || 0;
@@ -153,6 +246,77 @@ const internalConstraint: ConstraintHandler = (x, y, feature, context) => {
153
246
  return { x: clampedX, y: clampedY };
154
247
  };
155
248
 
249
+ /**
250
+ * Bottom Tangent Strategy (stand protrusion)
251
+ * Forces a feature to be tangent to the dieline bottom edge from outside (below).
252
+ */
253
+ const tangentBottomConstraint: ConstraintHandler = (x, y, feature, context, params) => {
254
+ const { dielineWidth, dielineHeight } = context;
255
+ const gap = params.gap || 0;
256
+ const confineX = params.confineX !== false;
257
+
258
+ const extentY =
259
+ feature.shape === "circle"
260
+ ? feature.radius || 0
261
+ : (feature.height || 0) / 2;
262
+ const newY = 1 + (extentY + gap) / dielineHeight;
263
+
264
+ let newX = x;
265
+ if (confineX) {
266
+ const extentX =
267
+ feature.shape === "circle"
268
+ ? feature.radius || 0
269
+ : (feature.width || 0) / 2;
270
+ const minX = extentX / dielineWidth;
271
+ const maxX = 1 - extentX / dielineWidth;
272
+ newX = minX > maxX ? 0.5 : Math.max(minX, Math.min(newX, maxX));
273
+ }
274
+
275
+ return { x: newX, y: newY };
276
+ };
277
+
278
+ /**
279
+ * Lowest Tangent Strategy (Lowest Point Lock)
280
+ * Finds the lowest point of the Dieline geometry and locks the feature's Y to that level.
281
+ * Allows horizontal movement (X).
282
+ */
283
+ const lowestTangentConstraint: ConstraintHandler = (x, y, feature, context, params) => {
284
+ const { dielineWidth, dielineHeight, geometry } = context;
285
+ if (!geometry) return { x, y };
286
+
287
+ const lowest = getLowestPointOnDieline(geometry);
288
+
289
+ // Calculate normalized Y of the lowest point
290
+ const minY = geometry.y - geometry.height / 2;
291
+ const normY = (lowest.y - minY) / geometry.height;
292
+
293
+ const gap = params.gap || 0;
294
+ const confineX = params.confineX !== false;
295
+
296
+ const extentY =
297
+ feature.shape === "circle"
298
+ ? feature.radius || 0
299
+ : (feature.height || 0) / 2;
300
+
301
+ const newY = normY + (extentY + gap) / dielineHeight;
302
+
303
+ let newX = x;
304
+ if (confineX) {
305
+ const extentX =
306
+ feature.shape === "circle"
307
+ ? feature.radius || 0
308
+ : (feature.width || 0) / 2;
309
+ const minX = extentX / dielineWidth;
310
+ const maxX = 1 - extentX / dielineWidth;
311
+ newX = minX > maxX ? 0.5 : Math.max(minX, Math.min(newX, maxX));
312
+ }
313
+
314
+ return { x: newX, y: newY };
315
+ };
316
+
156
317
  // Register built-ins
318
+ ConstraintRegistry.register("path", pathConstraint);
157
319
  ConstraintRegistry.register("edge", edgeConstraint);
158
320
  ConstraintRegistry.register("internal", internalConstraint);
321
+ ConstraintRegistry.register("tangent-bottom", tangentBottomConstraint);
322
+ ConstraintRegistry.register("lowest-tangent", lowestTangentConstraint);
package/src/coordinate.ts CHANGED
@@ -1,106 +1,106 @@
1
- export interface Point {
2
- x: number;
3
- y: number;
4
- }
5
-
6
- export interface Size {
7
- width: number;
8
- height: number;
9
- }
10
-
11
- export type Unit = "px" | "mm" | "cm" | "in";
12
-
13
- export interface Layout {
14
- scale: number;
15
- offsetX: number;
16
- offsetY: number;
17
- width: number;
18
- height: number;
19
- }
20
-
21
- export class Coordinate {
22
- /**
23
- * Calculate layout to fit content within container while preserving aspect ratio.
24
- */
25
- static calculateLayout(
26
- container: Size,
27
- content: Size,
28
- padding: number = 0,
29
- ): Layout {
30
- const availableWidth = Math.max(0, container.width - padding * 2);
31
- const availableHeight = Math.max(0, container.height - padding * 2);
32
-
33
- if (content.width === 0 || content.height === 0) {
34
- return { scale: 1, offsetX: 0, offsetY: 0, width: 0, height: 0 };
35
- }
36
-
37
- const scaleX = availableWidth / content.width;
38
- const scaleY = availableHeight / content.height;
39
- const scale = Math.min(scaleX, scaleY);
40
-
41
- const width = content.width * scale;
42
- const height = content.height * scale;
43
-
44
- const offsetX = (container.width - width) / 2;
45
- const offsetY = (container.height - height) / 2;
46
-
47
- return { scale, offsetX, offsetY, width, height };
48
- }
49
-
50
- /**
51
- * Convert an absolute value to a normalized value (0-1).
52
- * @param value Absolute value (e.g., pixels)
53
- * @param total Total dimension size (e.g., canvas width)
54
- */
55
- static toNormalized(value: number, total: number): number {
56
- return total === 0 ? 0 : value / total;
57
- }
58
-
59
- /**
60
- * Convert a normalized value (0-1) to an absolute value.
61
- * @param normalized Normalized value (0-1)
62
- * @param total Total dimension size (e.g., canvas width)
63
- */
64
- static toAbsolute(normalized: number, total: number): number {
65
- return normalized * total;
66
- }
67
-
68
- /**
69
- * Normalize a point's coordinates.
70
- */
71
- static normalizePoint(point: Point, size: Size): Point {
72
- return {
73
- x: this.toNormalized(point.x, size.width),
74
- y: this.toNormalized(point.y, size.height),
75
- };
76
- }
77
-
78
- /**
79
- * Denormalize a point's coordinates to absolute pixels.
80
- */
81
- static denormalizePoint(point: Point, size: Size): Point {
82
- return {
83
- x: this.toAbsolute(point.x, size.width),
84
- y: this.toAbsolute(point.y, size.height),
85
- };
86
- }
87
-
88
- static convertUnit(value: number, from: Unit, to: Unit): number {
89
- if (from === to) return value;
90
-
91
- // Base unit: mm
92
- const toMM: Record<Unit, number> = {
93
- px: 0.264583, // 1px = 0.264583mm (96 DPI)
94
- mm: 1,
95
- cm: 10,
96
- in: 25.4
97
- };
98
-
99
- const mmValue = value * (from === 'px' ? toMM.px : toMM[from] || 1);
100
-
101
- if (to === 'px') {
102
- return mmValue / toMM.px;
103
- }
104
- return mmValue / (toMM[to] || 1);
105
- }
106
- }
1
+ export interface Point {
2
+ x: number;
3
+ y: number;
4
+ }
5
+
6
+ export interface Size {
7
+ width: number;
8
+ height: number;
9
+ }
10
+
11
+ export type Unit = "px" | "mm" | "cm" | "in";
12
+
13
+ export interface Layout {
14
+ scale: number;
15
+ offsetX: number;
16
+ offsetY: number;
17
+ width: number;
18
+ height: number;
19
+ }
20
+
21
+ export class Coordinate {
22
+ /**
23
+ * Calculate layout to fit content within container while preserving aspect ratio.
24
+ */
25
+ static calculateLayout(
26
+ container: Size,
27
+ content: Size,
28
+ padding: number = 0,
29
+ ): Layout {
30
+ const availableWidth = Math.max(0, container.width - padding * 2);
31
+ const availableHeight = Math.max(0, container.height - padding * 2);
32
+
33
+ if (content.width === 0 || content.height === 0) {
34
+ return { scale: 1, offsetX: 0, offsetY: 0, width: 0, height: 0 };
35
+ }
36
+
37
+ const scaleX = availableWidth / content.width;
38
+ const scaleY = availableHeight / content.height;
39
+ const scale = Math.min(scaleX, scaleY);
40
+
41
+ const width = content.width * scale;
42
+ const height = content.height * scale;
43
+
44
+ const offsetX = (container.width - width) / 2;
45
+ const offsetY = (container.height - height) / 2;
46
+
47
+ return { scale, offsetX, offsetY, width, height };
48
+ }
49
+
50
+ /**
51
+ * Convert an absolute value to a normalized value (0-1).
52
+ * @param value Absolute value (e.g., pixels)
53
+ * @param total Total dimension size (e.g., canvas width)
54
+ */
55
+ static toNormalized(value: number, total: number): number {
56
+ return total === 0 ? 0 : value / total;
57
+ }
58
+
59
+ /**
60
+ * Convert a normalized value (0-1) to an absolute value.
61
+ * @param normalized Normalized value (0-1)
62
+ * @param total Total dimension size (e.g., canvas width)
63
+ */
64
+ static toAbsolute(normalized: number, total: number): number {
65
+ return normalized * total;
66
+ }
67
+
68
+ /**
69
+ * Normalize a point's coordinates.
70
+ */
71
+ static normalizePoint(point: Point, size: Size): Point {
72
+ return {
73
+ x: this.toNormalized(point.x, size.width),
74
+ y: this.toNormalized(point.y, size.height),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Denormalize a point's coordinates to absolute pixels.
80
+ */
81
+ static denormalizePoint(point: Point, size: Size): Point {
82
+ return {
83
+ x: this.toAbsolute(point.x, size.width),
84
+ y: this.toAbsolute(point.y, size.height),
85
+ };
86
+ }
87
+
88
+ static convertUnit(value: number, from: Unit, to: Unit): number {
89
+ if (from === to) return value;
90
+
91
+ // Base unit: mm
92
+ const toMM: Record<Unit, number> = {
93
+ px: 0.264583, // 1px = 0.264583mm (96 DPI)
94
+ mm: 1,
95
+ cm: 10,
96
+ in: 25.4
97
+ };
98
+
99
+ const mmValue = value * (from === 'px' ? toMM.px : toMM[from] || 1);
100
+
101
+ if (to === 'px') {
102
+ return mmValue / toMM.px;
103
+ }
104
+ return mmValue / (toMM[to] || 1);
105
+ }
106
+ }