@hatiolab/figure-model 0.4.2 → 0.4.3

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,4 +1,7 @@
1
- export type LayoutIssueCode = 'TIERS_REQUIRED' | 'MOUNT_INVALID' | 'TIER_NUMBER_SEQUENCE' | 'ELEVATION_NOT_ASCENDING' | 'FINITE_NUMBER_REQUIRED' | 'POSITIVE_NUMBER_REQUIRED' | 'POSITIVE_INTEGER_REQUIRED' | 'ARRAY_REQUIRED' | 'OBJECT_REQUIRED' | 'AXIS_INVALID' | 'PATTERN_INVALID' | 'SLOT_NUMBER_SEQUENCE'
1
+ import type { Vec3 } from './words.ts';
2
+ export type LayoutIssueCode = 'TIERS_REQUIRED' | 'MOUNT_INVALID' | 'ANCHOR_REQUIRED'
3
+ /** The tier names an anchor the holder does not give (params.anchorId). Checked only when the anchors are known. */
4
+ | 'ANCHOR_UNKNOWN' | 'TIER_NUMBER_DUPLICATE' | 'FINITE_NUMBER_REQUIRED' | 'POSITIVE_NUMBER_REQUIRED' | 'POSITIVE_INTEGER_REQUIRED' | 'ARRAY_REQUIRED' | 'OBJECT_REQUIRED' | 'AXIS_INVALID' | 'PATTERN_INVALID' | 'SLOTS_REQUIRED' | 'SLOT_NUMBER_DUPLICATE'
2
5
  /** A value JSON would turn into null (params.value: NaN, Infinity, -Infinity). */
3
6
  | 'NOT_PERSISTABLE';
4
7
  /** A problem as a code, the path of the value, and parameters -- never a sentence. The screen words it. */
@@ -9,18 +12,26 @@ export interface LayoutIssue {
9
12
  params?: Record<string, string | number>;
10
13
  }
11
14
  export declare function layoutIssue(code: LayoutIssueCode, path: string, params?: Record<string, string | number>): LayoutIssue;
12
- /** How a holder carries what it holds: on top, inside, or hanging below. */
15
+ /**
16
+ * How a tier carries what it holds. ON and IN seat items along the tier's local +Y -- IN differs only in that the room
17
+ * is enclosed (a judgement about room, not a turned direction); HANG hangs them along -Y.
18
+ */
13
19
  export type SlotMount = 'ON' | 'IN' | 'HANG';
20
+ /** The anchor at the holder's own origin, for a holder that gives no other. */
21
+ export declare const HOLDER_ANCHOR = "holder";
14
22
  export interface SlotLayout {
15
- mount: SlotMount;
16
- /** In tierNo order, bottom to top. */
17
23
  tiers: SlotLayoutTier[];
18
24
  }
19
25
  export interface SlotLayoutTier {
20
- /** From 1. */
26
+ /** The holder's own number for the tier: a positive integer, unique, kept when tiers are removed or reordered. */
21
27
  tierNo: number;
22
- /** The tier floor's height above the holder's floor -- not added to the tier below's. */
23
- elevation: number;
28
+ /** The anchor the tier stands on. */
29
+ anchorId: string;
30
+ mount: SlotMount;
31
+ /** The tier's centre in its anchor's frame. */
32
+ position: Vec3;
33
+ /** Degrees about the anchor's Y. */
34
+ rotation: number;
24
35
  layout: SlotPattern;
25
36
  }
26
37
  /** How the slots of one tier are arranged. */
@@ -53,16 +64,16 @@ export type SlotPattern = {
53
64
  pattern: 'CUSTOM';
54
65
  slots: PatternSlot[];
55
66
  };
56
- /** The order GRID slots are numbered in. ROW_MAJOR by default. */
67
+ /** The order GRID slots are numbered in. ROW_MAJOR by default. Changing it changes what an address means. */
57
68
  export type GridOrder = 'ROW_MAJOR' | 'COL_MAJOR' | 'SERPENTINE_ROW' | 'SERPENTINE_COL';
58
- /** The corner GRID slot 1 is at. { x: 'MIN', z: 'MIN' } by default. */
69
+ /** The corner GRID slot 1 is at. { x: 'MIN', z: 'MIN' } by default. Changing it changes what an address means. */
59
70
  export interface GridOrigin {
60
71
  x: 'MIN' | 'MAX';
61
72
  z: 'MIN' | 'MAX';
62
73
  }
63
74
  /** One slot's room and rules. Every slot of a SINGLE, ROW or GRID tier has the same. */
64
75
  export interface SlotSpec {
65
- /** The room a held thing may take: width along X, depth along Z, height along Y. */
76
+ /** The room a held thing may take, in the tier's axes: width along X, depth along Z, height along Y. */
66
77
  allowed: {
67
78
  width: number;
68
79
  depth: number;
@@ -80,7 +91,7 @@ export interface SlotSpec {
80
91
  }
81
92
  /** A slot of a CUSTOM tier. */
82
93
  export interface PatternSlot extends SlotSpec {
83
- /** From 1 within the tier. */
94
+ /** The tier's own number for the slot: a positive integer, unique in the tier, kept when other slots are removed. */
84
95
  slotNo: number;
85
96
  /** For display (FRONT, REAR, L, R …). */
86
97
  name?: string;
@@ -94,12 +105,18 @@ export interface SlotAddress {
94
105
  tier: number;
95
106
  slot: number;
96
107
  }
97
- /** A slot found by its address. offset is from the tier's centre, elevation from the holder's floor. */
108
+ /** A slot found by its address, with its tier's placement on the anchor. */
98
109
  export interface ResolvedSlot {
99
110
  tier: number;
100
111
  slotNo: number;
101
112
  name?: string;
102
- elevation: number;
113
+ anchorId: string;
114
+ mount: SlotMount;
115
+ /** The tier's centre in the anchor's frame. */
116
+ tierPosition: Vec3;
117
+ /** The tier's turn about the anchor's Y, degrees. */
118
+ tierRotation: number;
119
+ /** The slot's centre from the tier's centre, in the tier's axes. */
103
120
  offset: {
104
121
  x: number;
105
122
  z: number;
@@ -111,16 +128,23 @@ export interface ResolvedSlot {
111
128
  };
112
129
  accepts?: string[];
113
130
  stackLimit: number;
131
+ /** The slot's turn about Y in the tier, degrees. */
114
132
  rotation: number;
115
133
  }
134
+ export interface ValidateSlotLayoutOptions {
135
+ /** The anchors the holder gives. Left out: a tier's anchor is not checked against them. */
136
+ anchors?: Iterable<string>;
137
+ }
116
138
  /**
117
139
  * Everything wrong with a layout; an empty list is a sound one. stackLimit may be Infinity (no limit); a layout that is
118
140
  * to be stored as JSON is also checked with `persistenceIssues`.
119
141
  */
120
- export declare function validateSlotLayout(layout: SlotLayout): LayoutIssue[];
142
+ export declare function validateSlotLayout(layout: SlotLayout, options?: ValidateSlotLayoutOptions): LayoutIssue[];
121
143
  /** Values JSON turns into null (NaN, Infinity): stored, they would come back as a broken layout. */
122
144
  export declare function persistenceIssues(value: unknown, path?: string): LayoutIssue[];
123
145
  export declare function slotCount(pattern: SlotPattern): number;
146
+ /** A tier's slot numbers, in order. ROW and GRID count from 1; CUSTOM has the numbers it was given. */
147
+ export declare function slotNumbers(pattern: SlotPattern): number[];
124
148
  /** A GRID slot's number from its row r and column c (from 1), counted from the origin corner. */
125
149
  export declare function gridSlotNo(rows: number, cols: number, r: number, c: number, order?: GridOrder): number;
126
150
  /** The row and column of a GRID slot number -- the inverse of gridSlotNo. */
@@ -143,21 +167,24 @@ export declare function resolveSlot(layout: SlotLayout, address: SlotAddress, op
143
167
  export declare function slotIdOf(address: SlotAddress): string;
144
168
  /** The address a slot id names, or null when it is not one. */
145
169
  export declare function addressOfSlotId(slotId: unknown): SlotAddress | null;
146
- /** Every address of a layout, tier by tier. For a big GRID, resolve only the addresses you need. */
170
+ /** Every address of a layout, tier by tier as written. For a big GRID, resolve only the addresses you need. */
147
171
  export declare function slotAddresses(layout: SlotLayout): Generator<SlotAddress>;
172
+ /** A place in an anchor's frame: a position and a turn about the anchor's Y in degrees. */
173
+ export interface AnchorFrame {
174
+ position: Vec3;
175
+ rotation: number;
176
+ }
177
+ /** A slot's frame in its anchor's: the tier's placement, then the slot's offset and turn in the tier. */
178
+ export declare function slotFrame(slot: ResolvedSlot): AnchorFrame;
148
179
  /** An item's size: width along X, depth along Z, height along Y -- in the layout's unit. */
149
180
  export interface ItemDims {
150
181
  width: number;
151
182
  depth: number;
152
183
  height: number;
153
184
  }
154
- /** Where an item sits, in the holder's coordinates: its centre, its turn about Y in degrees, and one uniform scale. */
185
+ /** Where an item sits: its centre, its turn about Y in degrees, and one uniform scale. */
155
186
  export interface SeatTransform {
156
- position: {
157
- x: number;
158
- y: number;
159
- z: number;
160
- };
187
+ position: Vec3;
161
188
  rotation: number;
162
189
  scale: number;
163
190
  }
@@ -168,26 +195,44 @@ export declare function rotatedFootprint(dims: ItemDims, rotation: number): {
168
195
  depth: number;
169
196
  };
170
197
  /**
171
- * Where an item sits in a slot: shrunk to fit the slot's width and depth (by its turned extent, never enlarged), on top
172
- * of `belowHeight` -- the shown heights (height × scale) of what is stacked under it. `margin` is room left only for
173
- * showing (0 ≤ margin < 1); whether it fits is judged elsewhere, at full size. Null when a size or value is unusable --
174
- * never NaN or Infinity.
198
+ * Where an item sits in its slot's own frame: shrunk to fit the slot's width and depth (by its turned extent, never
199
+ * enlarged), on top of `belowHeight` -- the shown heights (height × scale) of what is stacked under it -- along +Y for ON
200
+ * and IN, -Y for HANG. x and z are 0 and it is not turned: the slot's frame has the offset and the turn. `margin` is room
201
+ * left only for showing (0 ≤ margin < 1); whether it fits is judged elsewhere, at full size. Null when a size or value is
202
+ * unusable -- never NaN or Infinity.
203
+ * slotFrame(slot) then seatInSlot(...) = seatTransform(...)
175
204
  */
176
- export declare function seatTransform(dims: ItemDims, slot: ResolvedSlot, mount: SlotMount, belowHeight?: number, margin?: number): SeatTransform | null;
177
- /** Where each of a slot's stacked items sits, bottom first. An item of unknown size is null and adds no height. */
178
- export declare function stackSeatTransforms(stack: (ItemDims | null | undefined)[], slot: ResolvedSlot, mount: SlotMount, margin?: number): (SeatTransform | null)[];
179
- /** A slot anchor's place in the holder: the slot's offset and the tier's elevation, nothing else. */
180
- export declare function slotAnchorPosition(slot: ResolvedSlot): {
181
- x: number;
182
- y: number;
183
- z: number;
184
- };
205
+ export declare function seatInSlot(dims: ItemDims, slot: ResolvedSlot, belowHeight?: number, margin?: number): SeatTransform | null;
206
+ /** Where an item sits in its anchor's frame: the slot's frame, then the seat in it. */
207
+ export declare function seatTransform(dims: ItemDims, slot: ResolvedSlot, belowHeight?: number, margin?: number): SeatTransform | null;
208
+ /** Where each of a slot's stacked items sits in the anchor's frame, bottom first. An item of unknown size is null and adds no height. */
209
+ export declare function stackSeatTransforms(stack: (ItemDims | null | undefined)[], slot: ResolvedSlot, margin?: number): (SeatTransform | null)[];
210
+ export type SlotSpaceCode =
211
+ /** Two slots' rooms share space (params: tier, slot, otherTier, otherSlot). Touching is not overlapping. */
212
+ 'SLOT_OVERLAP'
213
+ /** A slot's footprint reaches past the face its anchor gives (params: tier, slot). */
214
+ | 'SLOT_OUTSIDE_ANCHOR'
215
+ /** The anchor gives no face size, so whether its slots stay on it is unknown (params: anchorId). */
216
+ | 'ANCHOR_SPACE_UNKNOWN';
217
+ /** A warning about a layout's room: a setting to look at, never a judgement that items can physically be put there. */
218
+ export interface SlotSpaceWarning {
219
+ code: SlotSpaceCode;
220
+ path: string;
221
+ params: Record<string, string | number>;
222
+ }
223
+ export interface SlotSpaceOptions {
224
+ /** The size of the face each anchor gives, centred on the anchor, in its X and Z. An anchor left out is UNKNOWN. */
225
+ anchorFaces?: Record<string, {
226
+ width: number;
227
+ depth: number;
228
+ }>;
229
+ }
185
230
  /**
186
- * Where an item sits when it hangs off a slot anchor: only the stack height, half its height, scale and turn; x and z are
187
- * 0 because the anchor already has the offset and elevation.
188
- * slotAnchorPosition(slot) + seatInAnchor(...).position = seatTransform(...).position
231
+ * Where a sound layout's slots crowd each other or leave their anchor's face, as warnings. Only slots on the same anchor
232
+ * are compared: anchors move apart. Rooms are each slot's allowed box, placed by its tier and turned; sharing a face or
233
+ * an edge is not overlapping.
189
234
  */
190
- export declare function seatInAnchor(dims: ItemDims, slot: ResolvedSlot, mount: SlotMount, belowHeight?: number, margin?: number): SeatTransform | null;
235
+ export declare function slotSpaceWarnings(layout: SlotLayout, options?: SlotSpaceOptions): SlotSpaceWarning[];
191
236
  export type FitVerdict = 'OK' | 'NG' | 'UNKNOWN';
192
237
  export type FitReason = 'SLOT_NOT_FOUND' | 'TYPE_NOT_ACCEPTED' | 'WIDTH_EXCEEDED' | 'DEPTH_EXCEEDED' | 'HEIGHT_EXCEEDED' | 'STACK_LIMIT_EXCEEDED' | 'SPEC_UNKNOWN';
193
238
  export interface FitCandidate {
@@ -1 +1 @@
1
- {"version":3,"file":"slot-layout.d.ts","sourceRoot":"","sources":["../src/slot-layout.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"slot-layout.d.ts","sourceRoot":"","sources":["../src/slot-layout.ts"],"names":[],"mappings":"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"}
@@ -1,19 +1,28 @@
1
1
  /*
2
- * Slot layouts (ADR-0118): a holder's tiers, the slots on each tier, how slots are numbered, where an item sits in a
3
- * slot, and whether it fits. Pure computation; nothing here knows a scene, a graph or a component.
2
+ * Slot layouts (ADR-0118): a holder's anchors, the tiers placed on them, the slots of each tier, how slots are numbered,
3
+ * where an item sits in a slot, and whether it fits. Pure computation; nothing here knows a scene, a graph or a component.
4
4
  *
5
- * A holder has one or more tiers; each tier has its own arrangement (SINGLE, ROW, GRID or CUSTOM). A slot's address is
6
- * (tier, slot), both counted from 1, and slot numbers restart on every tier. ROW and GRID slots are not stored: they are
7
- * worked out from the rule, so thousands of slots are one rule. A slot's id is `t{tier}_s{slot}` -- a logical id, not
8
- * the name of a node in any 3D asset.
5
+ * holder → anchor → tier → slot → item
9
6
  *
10
- * Coordinates are the holder's own: X and Z put a slot's centre relative to the tier's centre, Y is the tier floor's
11
- * height above the holder's floor. Lengths are in whatever unit the layout is written in; nothing here converts them.
12
- * Rotations are in degrees about Y.
7
+ * An anchor is a frame the holder gives: a position and a direction, and whatever moves it (a fork, a lift). The holder
8
+ * decides how many anchors there are; nothing here makes one. A tier is a group of slots placed on one anchor -- a shelf
9
+ * level, the load area of one fork. Several tiers may share an anchor, at the same height or not. A slot is one place an
10
+ * item is put; its address is (tier, slot). Tier numbers are the holder's own and slot numbers the tier's own: positive,
11
+ * unique, kept as they are when tiers or slots are removed or shown in another order -- never renumbered by height or
12
+ * position. ROW and GRID slots are not stored: they are worked out from the rule, so thousands of slots are one rule. A
13
+ * slot's id is `t{tier}_s{slot}` -- a logical id, not the name of a node in any 3D asset.
14
+ *
15
+ * Where an item is, each part said once:
16
+ * item in the world = holder × anchor × tier × slot × seat
17
+ * The anchor's frame is the holder's to give. Here a tier is placed in its anchor's frame (position, turn about the
18
+ * anchor's Y), a slot in its tier's (offset in X and Z, turn about Y), and an item in its slot (stack height, scale).
19
+ * Lengths are in whatever unit the layout is written in; nothing here converts them. Rotations are in degrees about Y.
13
20
  */
14
21
  export function layoutIssue(code, path, params) {
15
22
  return params ? { code, path, params } : { code, path };
16
23
  }
24
+ /** The anchor at the holder's own origin, for a holder that gives no other. */
25
+ export const HOLDER_ANCHOR = 'holder';
17
26
  /* ── checking a layout ───────────────────────────────────────────────────── */
18
27
  const isFiniteNumber = (v) => typeof v === 'number' && Number.isFinite(v);
19
28
  const isPositive = (v) => isFiniteNumber(v) && v > 0;
@@ -37,24 +46,38 @@ function checkSpec(spec, path, issues) {
37
46
  * Everything wrong with a layout; an empty list is a sound one. stackLimit may be Infinity (no limit); a layout that is
38
47
  * to be stored as JSON is also checked with `persistenceIssues`.
39
48
  */
40
- export function validateSlotLayout(layout) {
49
+ export function validateSlotLayout(layout, options = {}) {
41
50
  const issues = [];
42
51
  if (!layout || !Array.isArray(layout.tiers) || layout.tiers.length === 0)
43
52
  return [layoutIssue('TIERS_REQUIRED', 'tiers')];
44
- if (!['ON', 'IN', 'HANG'].includes(layout.mount))
45
- issues.push(layoutIssue('MOUNT_INVALID', 'mount'));
53
+ const anchors = options.anchors ? new Set(options.anchors) : null;
54
+ const tierNos = new Set();
46
55
  layout.tiers.forEach((tier, i) => {
47
56
  const path = `tiers[${i}]`;
48
57
  if (!tier || typeof tier !== 'object') {
49
58
  issues.push(layoutIssue('OBJECT_REQUIRED', path));
50
59
  return;
51
60
  }
52
- if (tier.tierNo !== i + 1)
53
- issues.push(layoutIssue('TIER_NUMBER_SEQUENCE', `${path}.tierNo`));
54
- if (!isFiniteNumber(tier.elevation))
55
- issues.push(layoutIssue('FINITE_NUMBER_REQUIRED', `${path}.elevation`));
56
- else if (i > 0 && isFiniteNumber(layout.tiers[i - 1]?.elevation) && tier.elevation <= layout.tiers[i - 1].elevation)
57
- issues.push(layoutIssue('ELEVATION_NOT_ASCENDING', `${path}.elevation`));
61
+ if (!isPositiveInt(tier.tierNo))
62
+ issues.push(layoutIssue('POSITIVE_INTEGER_REQUIRED', `${path}.tierNo`));
63
+ else if (tierNos.has(tier.tierNo))
64
+ issues.push(layoutIssue('TIER_NUMBER_DUPLICATE', `${path}.tierNo`, { tierNo: tier.tierNo }));
65
+ else
66
+ tierNos.add(tier.tierNo);
67
+ if (typeof tier.anchorId !== 'string' || !tier.anchorId.trim())
68
+ issues.push(layoutIssue('ANCHOR_REQUIRED', `${path}.anchorId`));
69
+ else if (anchors && !anchors.has(tier.anchorId))
70
+ issues.push(layoutIssue('ANCHOR_UNKNOWN', `${path}.anchorId`, { anchorId: tier.anchorId }));
71
+ if (!['ON', 'IN', 'HANG'].includes(tier.mount))
72
+ issues.push(layoutIssue('MOUNT_INVALID', `${path}.mount`));
73
+ if (!tier.position || typeof tier.position !== 'object')
74
+ issues.push(layoutIssue('OBJECT_REQUIRED', `${path}.position`));
75
+ else
76
+ for (const k of ['x', 'y', 'z'])
77
+ if (!isFiniteNumber(tier.position[k]))
78
+ issues.push(layoutIssue('FINITE_NUMBER_REQUIRED', `${path}.position.${k}`));
79
+ if (!isFiniteNumber(tier.rotation))
80
+ issues.push(layoutIssue('FINITE_NUMBER_REQUIRED', `${path}.rotation`));
58
81
  const l = tier.layout;
59
82
  switch (l?.pattern) {
60
83
  case 'SINGLE':
@@ -81,14 +104,24 @@ export function validateSlotLayout(layout) {
81
104
  checkSpec(l.slot, `${path}.layout.slot`, issues);
82
105
  break;
83
106
  case 'CUSTOM': {
84
- const slots = Array.isArray(l.slots) ? l.slots : [];
85
- const nos = slots.map(s => s?.slotNo).sort((a, b) => a - b);
86
- if (!nos.length || nos.some((n, k) => n !== k + 1))
87
- issues.push(layoutIssue('SLOT_NUMBER_SEQUENCE', `${path}.layout.slots`));
88
- slots.forEach((s, k) => {
107
+ if (!Array.isArray(l.slots)) {
108
+ issues.push(layoutIssue('ARRAY_REQUIRED', `${path}.layout.slots`));
109
+ break;
110
+ }
111
+ if (!l.slots.length)
112
+ issues.push(layoutIssue('SLOTS_REQUIRED', `${path}.layout.slots`));
113
+ const slotNos = new Set();
114
+ l.slots.forEach((s, k) => {
115
+ const at = `${path}.layout.slots[${k}]`;
116
+ if (!isPositiveInt(s?.slotNo))
117
+ issues.push(layoutIssue('POSITIVE_INTEGER_REQUIRED', `${at}.slotNo`));
118
+ else if (slotNos.has(s.slotNo))
119
+ issues.push(layoutIssue('SLOT_NUMBER_DUPLICATE', `${at}.slotNo`, { slotNo: s.slotNo }));
120
+ else
121
+ slotNos.add(s.slotNo);
89
122
  if (!isFiniteNumber(s?.offset?.x) || !isFiniteNumber(s?.offset?.z))
90
- issues.push(layoutIssue('FINITE_NUMBER_REQUIRED', `${path}.layout.slots[${k}].offset`));
91
- checkSpec(s, `${path}.layout.slots[${k}]`, issues);
123
+ issues.push(layoutIssue('FINITE_NUMBER_REQUIRED', `${at}.offset`));
124
+ checkSpec(s, at, issues);
92
125
  });
93
126
  break;
94
127
  }
@@ -126,6 +159,12 @@ export function slotCount(pattern) {
126
159
  return pattern.slots.length;
127
160
  }
128
161
  }
162
+ /** A tier's slot numbers, in order. ROW and GRID count from 1; CUSTOM has the numbers it was given. */
163
+ export function slotNumbers(pattern) {
164
+ if (pattern.pattern === 'CUSTOM')
165
+ return pattern.slots.map(s => s.slotNo).sort((a, b) => a - b);
166
+ return Array.from({ length: slotCount(pattern) }, (_, i) => i + 1);
167
+ }
129
168
  /** A GRID slot's number from its row r and column c (from 1), counted from the origin corner. */
130
169
  export function gridSlotNo(rows, cols, r, c, order = 'ROW_MAJOR') {
131
170
  switch (order) {
@@ -169,7 +208,7 @@ const flip = (v, max) => (max && v !== 0 ? -v : v);
169
208
  */
170
209
  export function resolveSlot(layout, address, options = {}) {
171
210
  const tier = layout?.tiers?.find(t => t?.tierNo === address?.tier);
172
- if (!tier || !isPositiveInt(address.slot) || address.slot > slotCount(tier.layout))
211
+ if (!tier || !isPositiveInt(address.slot))
173
212
  return null;
174
213
  const l = tier.layout;
175
214
  let spec;
@@ -177,16 +216,22 @@ export function resolveSlot(layout, address, options = {}) {
177
216
  let name;
178
217
  switch (l.pattern) {
179
218
  case 'SINGLE':
219
+ if (address.slot !== 1)
220
+ return null;
180
221
  spec = l.slot;
181
222
  offset = { x: 0, z: 0 };
182
223
  break;
183
224
  case 'ROW': {
225
+ if (address.slot > l.count)
226
+ return null;
184
227
  const v = flip((address.slot - 1 - (l.count - 1) / 2) * l.pitch, l.origin === 'MAX');
185
228
  spec = l.slot;
186
229
  offset = l.axis === 'X' ? { x: v, z: 0 } : { x: 0, z: v };
187
230
  break;
188
231
  }
189
232
  case 'GRID': {
233
+ if (address.slot > l.rows * l.cols)
234
+ return null;
190
235
  const { r, c } = gridCellOf(l.rows, l.cols, address.slot, l.order);
191
236
  const origin = l.origin ?? { x: 'MIN', z: 'MIN' };
192
237
  spec = l.slot;
@@ -210,7 +255,10 @@ export function resolveSlot(layout, address, options = {}) {
210
255
  tier: tier.tierNo,
211
256
  slotNo: address.slot,
212
257
  name,
213
- elevation: tier.elevation,
258
+ anchorId: tier.anchorId,
259
+ mount: tier.mount,
260
+ tierPosition: { ...tier.position },
261
+ tierRotation: tier.rotation,
214
262
  offset,
215
263
  allowed: { ...spec.allowed },
216
264
  accepts: spec.accepts ? [...spec.accepts] : undefined,
@@ -228,12 +276,31 @@ export function addressOfSlotId(slotId) {
228
276
  const m = typeof slotId === 'string' ? /^t(\d+)_s(\d+)$/.exec(slotId) : null;
229
277
  return m ? { tier: Number(m[1]), slot: Number(m[2]) } : null;
230
278
  }
231
- /** Every address of a layout, tier by tier. For a big GRID, resolve only the addresses you need. */
279
+ /** Every address of a layout, tier by tier as written. For a big GRID, resolve only the addresses you need. */
232
280
  export function* slotAddresses(layout) {
233
281
  for (const tier of layout?.tiers ?? [])
234
- for (let slot = 1; slot <= slotCount(tier.layout); slot++)
282
+ for (const slot of slotNumbers(tier.layout))
235
283
  yield { tier: tier.tierNo, slot };
236
284
  }
285
+ /** A point of the tier's X-Z plane turned by the tier's rotation about Y (right-handed: +X turns toward -Z). */
286
+ function turnY(x, z, degrees) {
287
+ if (!degrees)
288
+ return { x, z };
289
+ const a = (degrees * Math.PI) / 180;
290
+ const c = Math.cos(a);
291
+ const s = Math.sin(a);
292
+ // clears the 6e-17 left at multiples of 90°
293
+ const clean = (v) => Math.round(v * 1e9) / 1e9;
294
+ return { x: clean(x * c + z * s), z: clean(-x * s + z * c) };
295
+ }
296
+ /** A slot's frame in its anchor's: the tier's placement, then the slot's offset and turn in the tier. */
297
+ export function slotFrame(slot) {
298
+ const o = turnY(slot.offset.x, slot.offset.z, slot.tierRotation);
299
+ return {
300
+ position: { x: slot.tierPosition.x + o.x, y: slot.tierPosition.y, z: slot.tierPosition.z + o.z },
301
+ rotation: slot.tierRotation + slot.rotation
302
+ };
303
+ }
237
304
  export const DEFAULT_SEAT_MARGIN = 0.05;
238
305
  const knownDims = (d) => !!d && isPositive(d.width) && isPositive(d.depth) && isPositive(d.height);
239
306
  /** The X and Z extent of an item turned by `rotation` degrees. */
@@ -246,12 +313,14 @@ export function rotatedFootprint(dims, rotation) {
246
313
  return { width: clean(dims.width * c + dims.depth * s), depth: clean(dims.width * s + dims.depth * c) };
247
314
  }
248
315
  /**
249
- * Where an item sits in a slot: shrunk to fit the slot's width and depth (by its turned extent, never enlarged), on top
250
- * of `belowHeight` -- the shown heights (height × scale) of what is stacked under it. `margin` is room left only for
251
- * showing (0 ≤ margin < 1); whether it fits is judged elsewhere, at full size. Null when a size or value is unusable --
252
- * never NaN or Infinity.
316
+ * Where an item sits in its slot's own frame: shrunk to fit the slot's width and depth (by its turned extent, never
317
+ * enlarged), on top of `belowHeight` -- the shown heights (height × scale) of what is stacked under it -- along +Y for ON
318
+ * and IN, -Y for HANG. x and z are 0 and it is not turned: the slot's frame has the offset and the turn. `margin` is room
319
+ * left only for showing (0 ≤ margin < 1); whether it fits is judged elsewhere, at full size. Null when a size or value is
320
+ * unusable -- never NaN or Infinity.
321
+ * slotFrame(slot) then seatInSlot(...) = seatTransform(...)
253
322
  */
254
- export function seatTransform(dims, slot, mount, belowHeight = 0, margin = DEFAULT_SEAT_MARGIN) {
323
+ export function seatInSlot(dims, slot, belowHeight = 0, margin = DEFAULT_SEAT_MARGIN) {
255
324
  if (!knownDims(dims))
256
325
  return null;
257
326
  if (!isFiniteNumber(belowHeight) || !isFiniteNumber(margin) || margin < 0 || margin >= 1)
@@ -259,34 +328,112 @@ export function seatTransform(dims, slot, mount, belowHeight = 0, margin = DEFAU
259
328
  const fp = rotatedFootprint(dims, slot.rotation);
260
329
  const scale = Math.min(1, Math.min(slot.allowed.width / fp.width, slot.allowed.depth / fp.depth) * (1 - margin));
261
330
  const h = dims.height * scale;
262
- const dir = mount === 'HANG' ? -1 : 1;
263
- const y = slot.elevation + dir * (belowHeight + h / 2);
264
- return { position: { x: slot.offset.x, y, z: slot.offset.z }, rotation: slot.rotation, scale };
331
+ const dir = slot.mount === 'HANG' ? -1 : 1;
332
+ return { position: { x: 0, y: dir * (belowHeight + h / 2), z: 0 }, rotation: 0, scale };
333
+ }
334
+ /** Where an item sits in its anchor's frame: the slot's frame, then the seat in it. */
335
+ export function seatTransform(dims, slot, belowHeight = 0, margin = DEFAULT_SEAT_MARGIN) {
336
+ const seat = seatInSlot(dims, slot, belowHeight, margin);
337
+ if (!seat)
338
+ return null;
339
+ const f = slotFrame(slot);
340
+ return { position: { x: f.position.x, y: f.position.y + seat.position.y, z: f.position.z }, rotation: f.rotation, scale: seat.scale };
265
341
  }
266
- /** Where each of a slot's stacked items sits, bottom first. An item of unknown size is null and adds no height. */
267
- export function stackSeatTransforms(stack, slot, mount, margin = DEFAULT_SEAT_MARGIN) {
342
+ /** Where each of a slot's stacked items sits in the anchor's frame, bottom first. An item of unknown size is null and adds no height. */
343
+ export function stackSeatTransforms(stack, slot, margin = DEFAULT_SEAT_MARGIN) {
268
344
  let below = 0;
269
345
  return stack.map(dims => {
270
- const t = dims ? seatTransform(dims, slot, mount, below, margin) : null;
346
+ const t = dims ? seatTransform(dims, slot, below, margin) : null;
271
347
  if (t)
272
348
  below += dims.height * t.scale;
273
349
  return t;
274
350
  });
275
351
  }
276
- /** A slot anchor's place in the holder: the slot's offset and the tier's elevation, nothing else. */
277
- export function slotAnchorPosition(slot) {
278
- return { x: slot.offset.x, y: slot.elevation, z: slot.offset.z };
352
+ /** Below this, a shared length is a touch, not an overlap. */
353
+ const TOUCH = 1e-6;
354
+ function roomOf(slot, tierIndex) {
355
+ const f = slotFrame(slot);
356
+ const hw = slot.allowed.width / 2;
357
+ const hd = slot.allowed.depth / 2;
358
+ const corners = [
359
+ [-hw, -hd],
360
+ [hw, -hd],
361
+ [hw, hd],
362
+ [-hw, hd]
363
+ ].map(([x, z]) => {
364
+ const p = turnY(x, z, f.rotation);
365
+ return { x: f.position.x + p.x, z: f.position.z + p.z };
366
+ });
367
+ const ax = turnY(1, 0, f.rotation);
368
+ const az = turnY(0, 1, f.rotation);
369
+ const dir = slot.mount === 'HANG' ? -1 : 1;
370
+ const ys = [f.position.y, f.position.y + dir * slot.allowed.height];
371
+ const xs = corners.map(c => c.x);
372
+ return {
373
+ tierIndex,
374
+ tier: slot.tier,
375
+ slot: slot.slotNo,
376
+ corners,
377
+ axes: [ax, az],
378
+ y0: Math.min(...ys),
379
+ y1: Math.max(...ys),
380
+ minX: Math.min(...xs),
381
+ maxX: Math.max(...xs)
382
+ };
383
+ }
384
+ /** Whether two turned rectangles share area: no axis of either separates them by more than a touch. */
385
+ function footprintsOverlap(a, b) {
386
+ for (const axis of [...a.axes, ...b.axes]) {
387
+ const pa = a.corners.map(c => c.x * axis.x + c.z * axis.z);
388
+ const pb = b.corners.map(c => c.x * axis.x + c.z * axis.z);
389
+ const shared = Math.min(Math.max(...pa), Math.max(...pb)) - Math.max(Math.min(...pa), Math.min(...pb));
390
+ if (shared <= TOUCH)
391
+ return false;
392
+ }
393
+ return true;
279
394
  }
280
395
  /**
281
- * Where an item sits when it hangs off a slot anchor: only the stack height, half its height, scale and turn; x and z are
282
- * 0 because the anchor already has the offset and elevation.
283
- * slotAnchorPosition(slot) + seatInAnchor(...).position = seatTransform(...).position
396
+ * Where a sound layout's slots crowd each other or leave their anchor's face, as warnings. Only slots on the same anchor
397
+ * are compared: anchors move apart. Rooms are each slot's allowed box, placed by its tier and turned; sharing a face or
398
+ * an edge is not overlapping.
284
399
  */
285
- export function seatInAnchor(dims, slot, mount, belowHeight = 0, margin = DEFAULT_SEAT_MARGIN) {
286
- const t = seatTransform(dims, slot, mount, belowHeight, margin);
287
- if (!t)
288
- return null;
289
- return { position: { x: 0, y: t.position.y - slot.elevation, z: 0 }, rotation: t.rotation, scale: t.scale };
400
+ export function slotSpaceWarnings(layout, options = {}) {
401
+ const warnings = [];
402
+ const byAnchor = new Map();
403
+ layout.tiers.forEach((tier, i) => {
404
+ const rooms = byAnchor.get(tier.anchorId) ?? [];
405
+ byAnchor.set(tier.anchorId, rooms);
406
+ for (const n of slotNumbers(tier.layout))
407
+ rooms.push(roomOf(resolveSlot(layout, { tier: tier.tierNo, slot: n }), i));
408
+ });
409
+ for (const [anchorId, rooms] of byAnchor) {
410
+ const face = options.anchorFaces?.[anchorId];
411
+ if (!face)
412
+ warnings.push({ code: 'ANCHOR_SPACE_UNKNOWN', path: 'tiers', params: { anchorId } });
413
+ else
414
+ for (const r of rooms)
415
+ if (r.corners.some(c => Math.abs(c.x) - face.width / 2 > TOUCH || Math.abs(c.z) - face.depth / 2 > TOUCH))
416
+ warnings.push({ code: 'SLOT_OUTSIDE_ANCHOR', path: `tiers[${r.tierIndex}]`, params: { tier: r.tier, slot: r.slot } });
417
+ // sweep along X so a big grid is not compared pair by pair
418
+ const sorted = [...rooms].sort((a, b) => a.minX - b.minX);
419
+ for (let i = 0; i < sorted.length; i++) {
420
+ const a = sorted[i];
421
+ for (let j = i + 1; j < sorted.length && sorted[j].minX < a.maxX - TOUCH; j++) {
422
+ const b = sorted[j];
423
+ if (Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0) <= TOUCH)
424
+ continue;
425
+ if (!footprintsOverlap(a, b))
426
+ continue;
427
+ const [first, second] = a.tierIndex < b.tierIndex || (a.tierIndex === b.tierIndex && a.slot < b.slot) ? [a, b] : [b, a];
428
+ warnings.push({
429
+ code: 'SLOT_OVERLAP',
430
+ path: `tiers[${second.tierIndex}]`,
431
+ params: { tier: second.tier, slot: second.slot, otherTier: first.tier, otherSlot: first.slot }
432
+ });
433
+ }
434
+ }
435
+ }
436
+ return warnings;
290
437
  }
291
438
  /**
292
439
  * Whether one more item fits in a slot that already holds `occupants`, at full size. The count limit and the height