hoi4-agent-tools 1.0.0 → 1.1.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,30 +1,217 @@
1
1
  // Shared visual contract for the renderer and constraint solver. Coordinates
2
- // remain ordinary HOI4 focus-grid units; the pixel conversion proves whether
3
- // two rendered node rectangles intersect.
2
+ // remain ordinary HOI4 focus-grid units; connector geometry is converted to
3
+ // the same deterministic pixel-space cubic used in SVG review artifacts.
4
4
  export const FOCUS_HORIZONTAL_GRID_PIXELS = 176;
5
5
  export const FOCUS_VERTICAL_GRID_PIXELS = 116;
6
6
  export const FOCUS_NODE_WIDTH_PIXELS = 144;
7
7
  export const FOCUS_NODE_HEIGHT_PIXELS = 76;
8
+ export const FOCUS_CONNECTOR_FLATTEN_SEGMENTS = 24;
9
+ const GEOMETRY_EPSILON = 1e-7;
10
+ function resolvedGeometry(options) {
11
+ return {
12
+ horizontalSpacing: options.horizontalSpacing ?? FOCUS_HORIZONTAL_GRID_PIXELS,
13
+ verticalSpacing: options.verticalSpacing ?? FOCUS_VERTICAL_GRID_PIXELS,
14
+ nodeWidth: options.nodeWidth ?? FOCUS_NODE_WIDTH_PIXELS,
15
+ nodeHeight: options.nodeHeight ?? FOCUS_NODE_HEIGHT_PIXELS,
16
+ originX: options.originX ?? 0,
17
+ originY: options.originY ?? 0,
18
+ };
19
+ }
8
20
  export function focusNodesVisiblyOverlap(left, right) {
9
21
  return (Math.abs(left.x - right.x) * FOCUS_HORIZONTAL_GRID_PIXELS < FOCUS_NODE_WIDTH_PIXELS &&
10
22
  Math.abs(left.y - right.y) * FOCUS_VERTICAL_GRID_PIXELS < FOCUS_NODE_HEIGHT_PIXELS);
11
23
  }
24
+ export function focusNodeRectangle(node, options = {}) {
25
+ const geometry = resolvedGeometry(options);
26
+ const minimumX = geometry.originX + node.x * geometry.horizontalSpacing;
27
+ const minimumY = geometry.originY + node.y * geometry.verticalSpacing;
28
+ return {
29
+ minimumX,
30
+ minimumY,
31
+ maximumX: minimumX + geometry.nodeWidth,
32
+ maximumY: minimumY + geometry.nodeHeight,
33
+ };
34
+ }
35
+ /** The exact cubic rendered for a prerequisite connector. */
36
+ export function focusConnectorCurve(parent, child, options = {}) {
37
+ const geometry = resolvedGeometry(options);
38
+ const parentRectangle = focusNodeRectangle(parent, geometry);
39
+ const childRectangle = focusNodeRectangle(child, geometry);
40
+ const start = {
41
+ x: (parentRectangle.minimumX + parentRectangle.maximumX) / 2,
42
+ y: parentRectangle.maximumY,
43
+ };
44
+ const end = {
45
+ x: (childRectangle.minimumX + childRectangle.maximumX) / 2,
46
+ y: childRectangle.minimumY,
47
+ };
48
+ const middleY = (start.y + end.y) / 2;
49
+ return {
50
+ start,
51
+ firstControl: { x: start.x, y: middleY },
52
+ secondControl: { x: end.x, y: middleY },
53
+ end,
54
+ };
55
+ }
56
+ /** Serialize the shared connector primitive without introducing renderer-only rounding. */
57
+ export function focusConnectorSvgPath(curve) {
58
+ return `M ${curve.start.x} ${curve.start.y} C ${curve.firstControl.x} ${curve.firstControl.y}, ${curve.secondControl.x} ${curve.secondControl.y}, ${curve.end.x} ${curve.end.y}`;
59
+ }
60
+ function cubicCoordinate(start, firstControl, secondControl, end, progress) {
61
+ const remaining = 1 - progress;
62
+ return (remaining * remaining * remaining * start +
63
+ 3 * remaining * remaining * progress * firstControl +
64
+ 3 * remaining * progress * progress * secondControl +
65
+ progress * progress * progress * end);
66
+ }
67
+ /** Fixed-size flattening keeps intersection work deterministic and bounded. */
68
+ export function flattenFocusConnectorCurve(curve) {
69
+ return Array.from({ length: FOCUS_CONNECTOR_FLATTEN_SEGMENTS + 1 }, (_unused, index) => {
70
+ const progress = index / FOCUS_CONNECTOR_FLATTEN_SEGMENTS;
71
+ return {
72
+ x: cubicCoordinate(curve.start.x, curve.firstControl.x, curve.secondControl.x, curve.end.x, progress),
73
+ y: cubicCoordinate(curve.start.y, curve.firstControl.y, curve.secondControl.y, curve.end.y, progress),
74
+ };
75
+ });
76
+ }
77
+ function pointBounds(points) {
78
+ let minimumX = Infinity;
79
+ let minimumY = Infinity;
80
+ let maximumX = -Infinity;
81
+ let maximumY = -Infinity;
82
+ for (const point of points) {
83
+ minimumX = Math.min(minimumX, point.x);
84
+ minimumY = Math.min(minimumY, point.y);
85
+ maximumX = Math.max(maximumX, point.x);
86
+ maximumY = Math.max(maximumY, point.y);
87
+ }
88
+ return { minimumX, minimumY, maximumX, maximumY };
89
+ }
90
+ function rectanglesOverlap(left, right) {
91
+ return !(left.maximumX < right.minimumX - GEOMETRY_EPSILON ||
92
+ right.maximumX < left.minimumX - GEOMETRY_EPSILON ||
93
+ left.maximumY < right.minimumY - GEOMETRY_EPSILON ||
94
+ right.maximumY < left.minimumY - GEOMETRY_EPSILON);
95
+ }
12
96
  function orientation(first, second, third) {
13
- return Math.sign((second.y - first.y) * (third.x - second.x) - (second.x - first.x) * (third.y - second.y));
14
- }
15
- /**
16
- * Returns true only for a proper interior intersection. Connector pairs that
17
- * merely touch, overlap collinearly, or share a focus endpoint are handled by
18
- * their callers and do not count as a crossing.
19
- */
20
- export function focusConnectorSegmentsProperlyCross(firstStart, firstEnd, secondStart, secondEnd) {
21
- const values = [
22
- orientation(firstStart, firstEnd, secondStart),
23
- orientation(firstStart, firstEnd, secondEnd),
24
- orientation(secondStart, secondEnd, firstStart),
25
- orientation(secondStart, secondEnd, firstEnd),
97
+ const crossProduct = (second.x - first.x) * (third.y - first.y) - (second.y - first.y) * (third.x - first.x);
98
+ return Math.abs(crossProduct) <= GEOMETRY_EPSILON ? 0 : Math.sign(crossProduct);
99
+ }
100
+ function pointOnSegment(point, start, end) {
101
+ return (orientation(start, end, point) === 0 &&
102
+ point.x >= Math.min(start.x, end.x) - GEOMETRY_EPSILON &&
103
+ point.x <= Math.max(start.x, end.x) + GEOMETRY_EPSILON &&
104
+ point.y >= Math.min(start.y, end.y) - GEOMETRY_EPSILON &&
105
+ point.y <= Math.max(start.y, end.y) + GEOMETRY_EPSILON);
106
+ }
107
+ function segmentsIntersect(firstStart, firstEnd, secondStart, secondEnd) {
108
+ if (!rectanglesOverlap({
109
+ minimumX: Math.min(firstStart.x, firstEnd.x),
110
+ minimumY: Math.min(firstStart.y, firstEnd.y),
111
+ maximumX: Math.max(firstStart.x, firstEnd.x),
112
+ maximumY: Math.max(firstStart.y, firstEnd.y),
113
+ }, {
114
+ minimumX: Math.min(secondStart.x, secondEnd.x),
115
+ minimumY: Math.min(secondStart.y, secondEnd.y),
116
+ maximumX: Math.max(secondStart.x, secondEnd.x),
117
+ maximumY: Math.max(secondStart.y, secondEnd.y),
118
+ }))
119
+ return false;
120
+ const firstStartOrientation = orientation(firstStart, firstEnd, secondStart);
121
+ const firstEndOrientation = orientation(firstStart, firstEnd, secondEnd);
122
+ const secondStartOrientation = orientation(secondStart, secondEnd, firstStart);
123
+ const secondEndOrientation = orientation(secondStart, secondEnd, firstEnd);
124
+ if (firstStartOrientation * firstEndOrientation < 0 &&
125
+ secondStartOrientation * secondEndOrientation < 0)
126
+ return true;
127
+ return ((firstStartOrientation === 0 && pointOnSegment(secondStart, firstStart, firstEnd)) ||
128
+ (firstEndOrientation === 0 && pointOnSegment(secondEnd, firstStart, firstEnd)) ||
129
+ (secondStartOrientation === 0 && pointOnSegment(firstStart, secondStart, secondEnd)) ||
130
+ (secondEndOrientation === 0 && pointOnSegment(firstEnd, secondStart, secondEnd)));
131
+ }
132
+ function polylineSegments(points) {
133
+ const segments = [];
134
+ for (let index = 1; index < points.length; index += 1) {
135
+ const start = points[index - 1];
136
+ const end = points[index];
137
+ if (start === undefined || end === undefined)
138
+ continue;
139
+ segments.push({
140
+ start,
141
+ end,
142
+ minimumY: Math.min(start.y, end.y),
143
+ maximumY: Math.max(start.y, end.y),
144
+ });
145
+ }
146
+ return segments.sort((left, right) => left.minimumY - right.minimumY ||
147
+ left.maximumY - right.maximumY ||
148
+ left.start.x - right.start.x ||
149
+ left.end.x - right.end.x);
150
+ }
151
+ export function focusConnectorPolylinesIntersect(firstPoints, secondPoints) {
152
+ if (!rectanglesOverlap(pointBounds(firstPoints), pointBounds(secondPoints)))
153
+ return false;
154
+ const firstSegments = polylineSegments(firstPoints);
155
+ const secondSegments = polylineSegments(secondPoints);
156
+ let secondStartIndex = 0;
157
+ for (const first of firstSegments) {
158
+ while (secondStartIndex < secondSegments.length &&
159
+ (secondSegments[secondStartIndex]?.maximumY ?? Infinity) < first.minimumY - GEOMETRY_EPSILON)
160
+ secondStartIndex += 1;
161
+ for (let secondIndex = secondStartIndex; secondIndex < secondSegments.length; secondIndex += 1) {
162
+ const second = secondSegments[secondIndex];
163
+ if (second === undefined)
164
+ continue;
165
+ if (second.minimumY > first.maximumY + GEOMETRY_EPSILON)
166
+ break;
167
+ if (segmentsIntersect(first.start, first.end, second.start, second.end))
168
+ return true;
169
+ }
170
+ }
171
+ return false;
172
+ }
173
+ export function focusConnectorCurvesIntersect(first, second) {
174
+ return focusConnectorPolylinesIntersect(flattenFocusConnectorCurve(first), flattenFocusConnectorCurve(second));
175
+ }
176
+ export function focusConnectorsVisiblyIntersect(firstParent, firstChild, secondParent, secondChild, options = {}) {
177
+ return focusConnectorCurvesIntersect(focusConnectorCurve(firstParent, firstChild, options), focusConnectorCurve(secondParent, secondChild, options));
178
+ }
179
+ function pointInsideRectangle(point, rectangle) {
180
+ return (point.x >= rectangle.minimumX - GEOMETRY_EPSILON &&
181
+ point.x <= rectangle.maximumX + GEOMETRY_EPSILON &&
182
+ point.y >= rectangle.minimumY - GEOMETRY_EPSILON &&
183
+ point.y <= rectangle.maximumY + GEOMETRY_EPSILON);
184
+ }
185
+ export function focusConnectorPolylineIntersectsRectangle(points, rectangle) {
186
+ if (!rectanglesOverlap(pointBounds(points), rectangle))
187
+ return false;
188
+ const topLeft = { x: rectangle.minimumX, y: rectangle.minimumY };
189
+ const topRight = { x: rectangle.maximumX, y: rectangle.minimumY };
190
+ const bottomRight = { x: rectangle.maximumX, y: rectangle.maximumY };
191
+ const bottomLeft = { x: rectangle.minimumX, y: rectangle.maximumY };
192
+ const sides = [
193
+ [topLeft, topRight],
194
+ [topRight, bottomRight],
195
+ [bottomRight, bottomLeft],
196
+ [bottomLeft, topLeft],
26
197
  ];
27
- return values.every((value) => value !== 0) && values[0] !== values[1] && values[2] !== values[3];
198
+ for (let index = 1; index < points.length; index += 1) {
199
+ const start = points[index - 1];
200
+ const end = points[index];
201
+ if (start === undefined || end === undefined)
202
+ continue;
203
+ if (pointInsideRectangle(start, rectangle) || pointInsideRectangle(end, rectangle))
204
+ return true;
205
+ if (sides.some(([sideStart, sideEnd]) => segmentsIntersect(start, end, sideStart, sideEnd)))
206
+ return true;
207
+ }
208
+ return false;
209
+ }
210
+ export function focusConnectorCurveIntersectsRectangle(curve, rectangle) {
211
+ return focusConnectorPolylineIntersectsRectangle(flattenFocusConnectorCurve(curve), rectangle);
212
+ }
213
+ export function focusConnectorIntersectsNode(parent, child, node, options = {}) {
214
+ return focusConnectorCurveIntersectsRectangle(focusConnectorCurve(parent, child, options), focusNodeRectangle(node, options));
28
215
  }
29
216
  export function focusNodeOrigin(node, minimumX, minimumY, padding, horizontalSpacing = FOCUS_HORIZONTAL_GRID_PIXELS, verticalSpacing = FOCUS_VERTICAL_GRID_PIXELS) {
30
217
  return {
@@ -1,4 +1,5 @@
1
1
  export * from './compiler.js';
2
+ export * from './compact.js';
2
3
  export * from './continuous-render.js';
3
4
  export * from './drift.js';
4
5
  export * from './geometry.js';