@snaptrude/plugin-core 0.9.2 → 0.9.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snaptrude/plugin-core",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -264,28 +264,52 @@ export abstract class PluginGeomCreateApi {
264
264
  /**
265
265
  * Create a closed solid **B-rep** by lofting between a bottom and a top
266
266
  * contour, optionally through intermediate cross-sections. Host API call —
267
- * returns a {@linkcode BrepHandle}. All sections must have the same number
268
- * of edges; side faces connect corresponding edges by authored index.
269
- * Corresponding edges must stay coplanar segment to segment (matching hole
270
- * counts allowed in the two-section form) a twisted loft would produce
271
- * non-planar side faces and is rejected. With intermediates the loft is a
272
- * chain of ruled segments folded into one solid: sections must be planar
273
- * and hole-free, ordered bottom intermediates → top. The contours are
274
- * copied the input handles are never mutated.
267
+ * returns a {@linkcode BrepHandle}. Sections with different edge counts are
268
+ * auto-matched: vertices are deterministically inserted on the smaller
269
+ * sections at the larger section's perimeter positions, so the same input
270
+ * always yields the same solid. For straight-edge (all-line) sections the
271
+ * seam is auto-aligned too winding and start-vertex correspondence are
272
+ * chosen to avoid a twist, so outlines authored from any starting corner
273
+ * loft cleanly; arc-bearing sections keep the authored correspondence
274
+ * unchanged. A pair whose side faces would be non-planar under every
275
+ * correspondence still builds for hole-free straight-edged pairs — planar
276
+ * side quads stay quads and warped ones split into planar triangles (the
277
+ * triangulated lane); arc-bearing or holed twisted pairs are rejected, as is
278
+ * auto-matching that would pair an arc with a straight edge. Matching hole
279
+ * counts are allowed in the two-section form. With intermediates the loft
280
+ * is a chain of ruled segments folded into one solid: sections must be
281
+ * planar and hole-free, ordered bottom → intermediates → top. The contours
282
+ * are copied — the input handles are never mutated.
275
283
  *
276
284
  * Inspect the result via `core.geom.query.brep.*`, or commit it to the scene
277
285
  * with `design.create.massFromBrep`.
278
286
  *
279
287
  * @param bottomContour The bottom cross-section
280
- * @param topContour The top cross-section (same edge count as the bottom)
288
+ * @param topContour The top cross-section (edge count may differ from the
289
+ * bottom — sections are auto-matched)
281
290
  * @param intermediateContours Optional in-between cross-sections, ordered
282
- * bottom to top (hole-free, same edge count as the bottom and top)
291
+ * bottom to top (hole-free; edge counts may differ every section is
292
+ * normalized to the largest)
293
+ * @param options Optional behavior switches. `compatibility` — `"auto"`
294
+ * (default) inserts vertices to match differing edge counts; `"strict"`
295
+ * skips normalization and throws VALIDATION when outer edge counts (or,
296
+ * in the two-section form, hole-pair counts) differ. `seamAlignment` —
297
+ * `"auto"` (default) searches seam rotations on straight-edge sections to
298
+ * avoid a twist; `"authored"` keeps the authored start-vertex
299
+ * correspondence unchanged (a twisted correspondence then builds through
300
+ * the triangulated lane for hole-free straight-edged pairs).
301
+ * `{ compatibility: "strict", seamAlignment: "authored" }` reproduces
302
+ * pre-0.9.4 lofting exactly.
283
303
  * @returns The new solid as a {@linkcode BrepHandle}
284
- * @throws VALIDATION if the contours are coincident, their edge or hole
285
- * counts differ, any section has holes when intermediates are present
304
+ * @throws VALIDATION if the contours are coincident, their hole counts
305
+ * differ, any section has holes when intermediates are present
286
306
  * (multi-section lofts take hole-free sections), a section is non-planar,
287
- * or a side face between corresponding edges would be non-planar (a
288
- * twisted section pairthe error names the offending sections)
307
+ * sections are too dissimilar to auto-match, auto-matching would pair an
308
+ * arc with a straight edge, or for arc-bearing or holed pairs only — no vertex correspondence avoids a twist (a
309
+ * side face between corresponding edges would be non-planar — the error
310
+ * names the offending sections); with `compatibility: "strict"`, if
311
+ * section edge counts (or hole-pair counts) differ — the error names the
312
+ * sections and counts and points at `compatibility: "auto"`
289
313
  * @throws OPERATION_FAILED if the lofted segments cannot be joined into a
290
314
  * valid solid
291
315
  *
@@ -293,6 +317,10 @@ export abstract class PluginGeomCreateApi {
293
317
  * @examplePrompt Loft between a large base and a smaller top
294
318
  * @examplePrompt Create a frustum from two squares
295
319
  * @examplePrompt Loft the tower through these three floor outlines
320
+ * @examplePrompt Loft a four-sided base to a five-sided top
321
+ * @examplePrompt Loft these outlines even though their corners don't line up
322
+ * @examplePrompt Loft these outlines exactly as authored, corner to corner
323
+ * @examplePrompt Fail the loft if my sections don't have matching edge counts
296
324
  *
297
325
  * # Example
298
326
  * ```ts
@@ -310,6 +338,7 @@ export abstract class PluginGeomCreateApi {
310
338
  bottomContour: ContourHandle,
311
339
  topContour: ContourHandle,
312
340
  intermediateContours?: ContourHandle[],
341
+ options?: { compatibility?: "strict" | "auto"; seamAlignment?: "authored" | "auto" },
313
342
  ): PluginApiReturn<BrepHandle>
314
343
 
315
344
  /**
@@ -636,24 +665,59 @@ export abstract class PluginGeomCreateApi {
636
665
  ): PluginApiReturn<BrepHandle>
637
666
 
638
667
  /**
639
- * Sweeps a planar profile along an open polyline path, with mitred corners
640
- * at each bend. Host API call — returns a new {@linkcode BrepHandle}; the
668
+ * Sweeps a planar profile along a polyline path, with mitred corners at
669
+ * each bend. Host API call — returns a new {@linkcode BrepHandle}; the
641
670
  * input contour is read-only. The profile must be hole-free and must not
642
- * lie in a plane containing the first path segment's direction.
643
- *
644
- * Runs on the OpenCascade kernel (the first kernel call loads a wasm of
645
- * tens of MBexpect a pause of seconds).
671
+ * lie in a plane containing the first path segment's direction. Repeating
672
+ * the first point as the last closes the path into a ring solid (a picture
673
+ * frame): closed paths must be flat and straight-segmented and take a
674
+ * straight-edged (arc-free) profilethe ring's cross-section is the
675
+ * authored profile projected along the first leg onto the first mitre
676
+ * plane, so a profile not perpendicular to the first leg appears sheared.
677
+ * An optional scale law tapers the sweep: `startScale`/`endScale`
678
+ * interpolate linearly by arc length, or `scales` gives one factor per path
679
+ * point (scaling is about the profile centroid). A uniform scale (all
680
+ * factors equal) works on any path today's sweep accepts; a varying scale
681
+ * takes straight (collinear) open paths with straight-edged profiles —
682
+ * tapering across a bend or along an arc edge would create faces Snaptrude
683
+ * cannot represent and throws (sweep each straight run separately and union
684
+ * the results instead). An optional `transition` picks the corner
685
+ * treatment: `"miter"` (default) is today's sharp mitred bend;
686
+ * `{ bevel: b }` chamfers each turning corner by cutting `b` back along
687
+ * both adjacent legs (works on open and closed paths — each leg must be
688
+ * long enough for its cuts). On a corner-less path `transition` has no effect, and the
689
+ * varying-scale lane takes straight paths, so it never sees a corner.
690
+ *
691
+ * Open paths run on the OpenCascade kernel (the first kernel call loads a
692
+ * wasm of tens of MB — expect a pause of seconds); closed paths are
693
+ * computed host-side and never load the kernel.
646
694
  *
647
695
  * Inspect the result via `core.geom.query.brep.*`, or commit it to the scene
648
696
  * with `design.create.massFromBrep`.
649
697
  *
650
698
  * @param profile The cross-section to sweep (hole-free contour)
651
- * @param path The polyline path as plain `{x, y, z}` points (≥2, open — first ≠ last)
699
+ * @param path The polyline path as plain `{x, y, z}` points (≥2; repeat the
700
+ * first point as the last to close the path into a ring)
701
+ * @param options Optional scale law and corner transition. Scale law:
702
+ * `startScale`/`endScale` (linear taper by arc length) or `scales` (one
703
+ * factor per path point; mutually exclusive with
704
+ * `startScale`/`endScale`); every factor must be finite and within
705
+ * 1e-3..1e3. `transition`: `"miter"` (default, sharp corners) or
706
+ * `{ bevel: b }` (chamfered corners, `b` finite within 1e-3..1e3; open
707
+ * and closed paths)
652
708
  * @returns The swept solid as a new {@linkcode BrepHandle}
653
709
  * @throws VALIDATION if the profile has holes, the path has fewer than 2
654
- * points, consecutive path points coincide, the path is closed (open
655
- * paths only in v1), a coordinate is not finite, or the profile plane
656
- * contains the first path segment's direction (degenerate sweep)
710
+ * points, consecutive path points coincide, a coordinate is not finite,
711
+ * or the profile plane contains the first path segment's direction
712
+ * (degenerate sweep); for closed paths, if the ring has fewer than 3
713
+ * distinct corners, the path is non-planar, a corner doubles back, the
714
+ * profile has an arc edge, or the profile is too large for a mitred
715
+ * corner; for scale options, if a factor is outside 1e-3..1e3, `scales`
716
+ * is combined with `startScale`/`endScale`, `scales` does not match the
717
+ * path length, or a varying scale is used on a bent or closed path or
718
+ * with an arc-edged profile; for `{ bevel }`, if a leg is too short for
719
+ * the cuts its beveled corners take from it or a corner nearly doubles
720
+ * back so its chamfer collapses (the errors name the corner)
657
721
  * @throws OPERATION_FAILED if the kernel cannot sweep the profile into a
658
722
  * valid solid or a transition patch is a curved surface Snaptrude cannot
659
723
  * represent
@@ -661,6 +725,10 @@ export abstract class PluginGeomCreateApi {
661
725
  * @examplePrompt Sweep this profile along the corridor path
662
726
  * @examplePrompt Extrude the railing section along this route
663
727
  * @examplePrompt Run a duct profile through these points
728
+ * @examplePrompt Sweep a frame profile around a closed rectangular loop
729
+ * @examplePrompt Taper this duct from 1x1 to 2x2 along its run
730
+ * @examplePrompt Sweep a column section that narrows toward the top
731
+ * @examplePrompt Chamfer the corners of this swept frame by 200mm
664
732
  *
665
733
  * # Example
666
734
  * ```ts
@@ -673,11 +741,34 @@ export abstract class PluginGeomCreateApi {
673
741
  * { x: 0, y: 2.8, z: 0 },
674
742
  * { x: 10, y: 2.8, z: 0 },
675
743
  * ])
744
+ * // A closed ring (picture frame): repeat the first point as the last
745
+ * const frame = await snaptrude.core.geom.create.brepFromSweep(profile, [
746
+ * { x: 0, y: 0, z: 0 },
747
+ * { x: 0, y: 3, z: 0 },
748
+ * { x: 4, y: 3, z: 0 },
749
+ * { x: 4, y: 0, z: 0 },
750
+ * { x: 0, y: 0, z: 0 },
751
+ * ])
752
+ * // A tapered straight run: 0.4x0.4 at the start, 0.8x0.8 at the end
753
+ * const flue = await snaptrude.core.geom.create.brepFromSweep(
754
+ * profile,
755
+ * [
756
+ * { x: 0, y: 0, z: 0 },
757
+ * { x: 0, y: 10, z: 0 },
758
+ * ],
759
+ * { startScale: 1, endScale: 2 },
760
+ * )
676
761
  * ```
677
762
  */
678
763
  public abstract brepFromSweep(
679
764
  profile: ContourHandle,
680
765
  path: Vec3Components[],
766
+ options?: {
767
+ startScale?: number
768
+ endScale?: number
769
+ scales?: number[]
770
+ transition?: "miter" | { bevel: number }
771
+ },
681
772
  ): PluginApiReturn<BrepHandle>
682
773
 
683
774
  /**
@@ -897,11 +988,18 @@ export type PluginGeomCreateBrepFromExtrusionArgs = z.infer<
897
988
  * | `bottomContour` | {@linkcode ContourHandle} | The bottom cross-section |
898
989
  * | `topContour` | {@linkcode ContourHandle} | The top cross-section |
899
990
  * | `intermediateContours` | {@linkcode ContourHandle}`[]`? | Optional in-between cross-sections, ordered bottom to top |
991
+ * | `options` | `object`? | Optional behavior switches: `compatibility` (`"strict"` throws on mismatched edge counts instead of auto-matching; default `"auto"`) and `seamAlignment` (`"authored"` keeps the authored correspondence instead of searching seam rotations; default `"auto"`) |
900
992
  */
901
993
  export const PluginGeomCreateBrepFromLoftArgs = z.object({
902
994
  bottomContour: ContourHandle,
903
995
  topContour: ContourHandle,
904
996
  intermediateContours: z.array(ContourHandle).optional(),
997
+ options: z
998
+ .object({
999
+ compatibility: z.enum(["strict", "auto"]).optional(),
1000
+ seamAlignment: z.enum(["authored", "auto"]).optional(),
1001
+ })
1002
+ .optional(),
905
1003
  })
906
1004
 
907
1005
  export type PluginGeomCreateBrepFromLoftArgs = z.infer<typeof PluginGeomCreateBrepFromLoftArgs>
@@ -1034,18 +1132,53 @@ export type PluginGeomCreateBrepFromChamferArgs = z.infer<
1034
1132
  typeof PluginGeomCreateBrepFromChamferArgs
1035
1133
  >
1036
1134
 
1135
+ // Taper scale factor — bounded so a near-zero scale cannot pinch a section
1136
+ // into a degenerate sliver the downstream planarity gates would miss.
1137
+ const SweepScaleFactor = z.number().finite().min(1e-3).max(1e3)
1138
+
1037
1139
  /**
1038
1140
  * Arguments for {@linkcode PluginGeomCreateApi.brepFromSweep}.
1039
1141
  *
1040
1142
  * | Property | Type | Description |
1041
1143
  * |---|---|---|
1042
1144
  * | `profile` | {@linkcode ContourHandle} | The cross-section to sweep (hole-free) |
1043
- * | `path` | {@linkcode Vec3Components}`[]` | The open polyline path (≥2 points, finite components) |
1145
+ * | `path` | {@linkcode Vec3Components}`[]` | The polyline path (≥2 points, finite components; repeating the first point as the last closes it into a ring — closed paths must be flat and straight-segmented and take a straight-edged profile) |
1146
+ * | `options` | `object`? | Optional scale law: `startScale`/`endScale` (linear taper by arc length) or per-point `scales` (mutually exclusive; factors finite, 1e-3..1e3; `scales.length` must equal `path.length`). Optional corner `transition`: `"miter"` (default) or `{ bevel }` (chamfered corners; finite, 1e-3..1e3) |
1044
1147
  */
1045
- export const PluginGeomCreateBrepFromSweepArgs = z.object({
1046
- profile: ContourHandle,
1047
- path: z.array(FiniteVec3Components).min(2),
1048
- })
1148
+ export const PluginGeomCreateBrepFromSweepArgs = z
1149
+ .object({
1150
+ profile: ContourHandle,
1151
+ path: z.array(FiniteVec3Components).min(2),
1152
+ options: z
1153
+ .object({
1154
+ startScale: SweepScaleFactor.optional(),
1155
+ endScale: SweepScaleFactor.optional(),
1156
+ scales: z.array(SweepScaleFactor).optional(),
1157
+ transition: z
1158
+ .union([
1159
+ z.literal("miter"),
1160
+ z.object({ bevel: z.number().finite().min(1e-3).max(1e3) }),
1161
+ ])
1162
+ .optional(),
1163
+ })
1164
+ .optional(),
1165
+ })
1166
+ .superRefine((a, ctx) => {
1167
+ const options = a.options
1168
+ if (!options || options.scales === undefined) return
1169
+ if (options.startScale !== undefined || options.endScale !== undefined) {
1170
+ ctx.addIssue({
1171
+ code: z.ZodIssueCode.custom,
1172
+ message: "scales is mutually exclusive with startScale/endScale",
1173
+ })
1174
+ }
1175
+ if (options.scales.length !== a.path.length) {
1176
+ ctx.addIssue({
1177
+ code: z.ZodIssueCode.custom,
1178
+ message: "scales must have exactly one entry per path point",
1179
+ })
1180
+ }
1181
+ })
1049
1182
 
1050
1183
  export type PluginGeomCreateBrepFromSweepArgs = z.infer<typeof PluginGeomCreateBrepFromSweepArgs>
1051
1184
 
@@ -163,6 +163,64 @@ export abstract class PluginGeomQueryBrepApi {
163
163
  vertexIndexB: number
164
164
  ): PluginApiReturn<EdgeHandle[]>
165
165
 
166
+ /**
167
+ * Read the exact curve of a brep edge as plain values — a discriminated
168
+ * union on `type`. Pure read: no geometry kernel is loaded. Points and
169
+ * directions are {@linkcode Vec3Components}; lengths and radii are raw
170
+ * Babylon units.
171
+ *
172
+ * | `type` | Fields |
173
+ * |---|---|
174
+ * | `"line"` | `startPoint`, `endPoint`, `length` |
175
+ * | `"arc"` | `startPoint`, `endPoint`, `centre`, `axis`, `radius`, `length` |
176
+ * | `"circle"` | `centre`, `axis`, `radius`, `length` — a full-circle edge (e.g. a cylinder cap rim) |
177
+ *
178
+ * @param brep The brep the edge belongs to
179
+ * @param edge The edge whose curve to read
180
+ * @returns The curve data as a discriminated union on `type` (see table above)
181
+ * @throws VALIDATION if the edge is not on `brep`
182
+ *
183
+ * @examplePrompt Read the radius of this curved edge
184
+ * @examplePrompt Is this edge straight or an arc?
185
+ * @examplePrompt Get the exact start and end points of this edge
186
+ * @examplePrompt Measure the length of each edge of this mass
187
+ *
188
+ * # Example
189
+ * ```ts
190
+ * const [mass] = await snaptrude.design.query.listMasses()
191
+ * const brep = await snaptrude.design.query.geometry.getBrep(mass)
192
+ * if (brep) {
193
+ * const edges = await snaptrude.core.geom.query.brep.listEdges(brep)
194
+ * for (const edge of edges) {
195
+ * const curve = await snaptrude.core.geom.query.brep.getEdgeCurve(brep, edge)
196
+ * if (curve.type === "arc") console.log("arc radius:", curve.radius)
197
+ * }
198
+ * }
199
+ * ```
200
+ */
201
+ public abstract getEdgeCurve(
202
+ brep: BrepHandle,
203
+ edge: EdgeHandle
204
+ ): PluginApiReturn<
205
+ | { type: "line"; startPoint: Vec3Components; endPoint: Vec3Components; length: number }
206
+ | {
207
+ type: "arc"
208
+ startPoint: Vec3Components
209
+ endPoint: Vec3Components
210
+ centre: Vec3Components
211
+ axis: Vec3Components
212
+ radius: number
213
+ length: number
214
+ }
215
+ | {
216
+ type: "circle"
217
+ centre: Vec3Components
218
+ axis: Vec3Components
219
+ radius: number
220
+ length: number
221
+ }
222
+ >
223
+
166
224
  /**
167
225
  * Get the position of a vertex within a brep.
168
226
  * @param brep The brep to query
@@ -408,6 +466,22 @@ export type PluginGeomQueryBrepListEdgesBetweenArgs = z.infer<
408
466
  typeof PluginGeomQueryBrepListEdgesBetweenArgs
409
467
  >
410
468
 
469
+ /**
470
+ * Arguments for {@linkcode PluginGeomQueryBrepApi.getEdgeCurve}.
471
+ *
472
+ * | Property | Type | Description |
473
+ * |---|---|---|
474
+ * | `brep` | {@linkcode BrepHandle} | The brep the edge belongs to |
475
+ * | `edge` | {@linkcode EdgeHandle} | The edge whose curve to read |
476
+ */
477
+ export const PluginGeomQueryBrepGetEdgeCurveArgs = z.object({
478
+ brep: BrepHandle,
479
+ edge: EdgeHandle,
480
+ })
481
+ export type PluginGeomQueryBrepGetEdgeCurveArgs = z.infer<
482
+ typeof PluginGeomQueryBrepGetEdgeCurveArgs
483
+ >
484
+
411
485
  /**
412
486
  * Arguments for {@linkcode PluginGeomQueryBrepApi.getVertexPosition}.
413
487
  *
@@ -389,9 +389,15 @@ export abstract class PluginDesignCreateApi {
389
389
  * Drives the wall's construction layers and default material, and — when
390
390
  * `thickness` is omitted — its total layer thickness. Default: the engine's
391
391
  * generic wall.
392
+ * @param storey - Target storey (integer: `1` ground, `2` first floor, `-1`
393
+ * basement — same convention as `design.create.space`). The walls are
394
+ * assigned to it and lifted to its base elevation, all inside the same
395
+ * single undo entry. The storey must already exist
396
+ * (`core.storeys.create` first). Default: geometry-derived — a profile
397
+ * drawn at y = 0 lands on storey 1; the ACTIVE storey is NOT consulted.
392
398
  * @returns a {@linkcode ComponentHandle}`[]` (one per wall), in profile order
393
399
  * @throws if the profile is empty, `wallType` names no wall type in the
394
- * project, or wall creation fails
400
+ * project, `storey` does not exist, or wall creation fails
395
401
  *
396
402
  * @examplePrompt Draw walls along this centerline
397
403
  * @examplePrompt Create 3m high, 200mm thick walls from these points
@@ -422,6 +428,7 @@ export abstract class PluginDesignCreateApi {
422
428
  height?: number,
423
429
  thickness?: number,
424
430
  wallType?: string,
431
+ storey?: number,
425
432
  ): PluginApiReturn<ComponentHandle[]>
426
433
 
427
434
  /**
@@ -429,12 +436,17 @@ export abstract class PluginDesignCreateApi {
429
436
  *
430
437
  * Geometry is procedurally generated from the chosen `preset`; there is no
431
438
  * footprint input. Placed on the active structure/story unless overridden.
439
+ * The promise resolves only after the staircase's asynchronous geometry union
440
+ * and creation-command capture settle; a union failure rejects the call.
432
441
  *
433
442
  * @param preset - The parametric preset to generate
434
443
  * @param position - Placement point
435
444
  * @param label - Optional label
436
445
  * @param structureId - Target structure (default active)
437
446
  * @param level - Target level name (default active/`"01"`)
447
+ * @param dimensions - Optional positive dimension overrides in Snaptrude engine
448
+ * units, plus a placement angle in 90-degree increments. Overrides are
449
+ * applied to the staircase parameters before the creation command runs.
438
450
  * @returns the {@linkcode ComponentHandle} of the created staircase
439
451
  * @throws if the preset is unknown or placement fails
440
452
  *
@@ -458,6 +470,7 @@ export abstract class PluginDesignCreateApi {
458
470
  label?: string,
459
471
  structureId?: string,
460
472
  level?: string,
473
+ dimensions?: PluginStaircaseDimensions,
461
474
  ): PluginApiReturn<ComponentHandle>
462
475
 
463
476
  /**
@@ -517,7 +530,11 @@ export abstract class PluginDesignCreateApi {
517
530
  * entry). To rotate an existing instance instead, use `design.transform.rotate`.
518
531
  *
519
532
  * @param catalogId - Library id: team `_id` or general `fullName`
520
- * @param position - Absolute world placement point
533
+ * @param position - Absolute world placement point. `position.y` is the REST
534
+ * elevation: the item is grounded so its bounding-box base sits exactly at
535
+ * `position.y` (the same surface-flush contract as interactive drag-drop) —
536
+ * pass the floor/storey elevation to stand furniture on it; never add half
537
+ * the item's height yourself.
521
538
  * @param options - Optional placement options: `label` — instance name
522
539
  * (default auto `${name}Ins${n}`); `createNewSourceMesh` — emit a
523
540
  * source-mesh creation command (default `true`)
@@ -665,6 +682,43 @@ export abstract class PluginDesignCreateApi {
665
682
  facing?: Vec3Handle,
666
683
  ): PluginApiReturn<ComponentHandle>
667
684
 
685
+ /**
686
+ * Place a catalog **door or window** into a host wall with optional size overrides.
687
+ *
688
+ * The world `position` is projected onto the host wall to locate the opening.
689
+ * All supplied dimensions use Snaptrude engine units. Window `sillHeight` is
690
+ * measured from the host wall base to the bottom of the window, not to
691
+ * its center. Placement is asynchronous and committed as one undoable creation.
692
+ *
693
+ * @param options - Opening kind, catalog and host references, placement, and
694
+ * optional facing, label, and dimensions
695
+ * @returns the {@linkcode ComponentHandle} of the placed door or window
696
+ * @throws if the options are invalid, the catalog id is unknown, the host is
697
+ * not a wall, loading fails, or the opening cannot be placed on the host
698
+ *
699
+ * @examplePrompt Add a 1m wide door to this wall here
700
+ * @examplePrompt Place a window with a 0.9m sill height on the selected wall
701
+ *
702
+ * @performance Single-opening creator — use it for one hosted opening. A plural
703
+ * API is intentionally unavailable until host placement can be atomic.
704
+ *
705
+ * # Example
706
+ * ```ts
707
+ * const opening = await snaptrude.design.create.opening({
708
+ * kind: "window",
709
+ * catalogId: windowType.id,
710
+ * hostWall: wall,
711
+ * position: await snaptrude.core.math.vec3.new(3, 0, 5),
712
+ * width: 1.2,
713
+ * height: 1.5,
714
+ * sillHeight: 0.9,
715
+ * })
716
+ * ```
717
+ */
718
+ public abstract opening(
719
+ options: PluginDesignCreateOpeningOptions,
720
+ ): PluginApiReturn<ComponentHandle>
721
+
668
722
  /**
669
723
  * Place a **smart layout** — clone an in-scene template cluster (walls,
670
724
  * furniture, doors, windows, floors) into one or more target ROOM/DEPARTMENT
@@ -908,6 +962,24 @@ export const PluginStaircasePreset = z.enum([
908
962
  ])
909
963
  export type PluginStaircasePreset = z.infer<typeof PluginStaircasePreset>
910
964
 
965
+ /**
966
+ * Optional staircase dimension overrides. Lengths use Snaptrude engine units
967
+ * and must be positive. `angleInDegrees` controls placement about the vertical
968
+ * axis and must be a multiple of 90 degrees.
969
+ */
970
+ export const PluginStaircaseDimensions = z
971
+ .object({
972
+ width: z.number().finite().positive().optional(),
973
+ tread: z.number().finite().positive().optional(),
974
+ riser: z.number().finite().positive().optional(),
975
+ landingWidth: z.number().finite().positive().optional(),
976
+ angleInDegrees: z.number().finite().multipleOf(90).optional(),
977
+ })
978
+ .strict()
979
+ export type PluginStaircaseDimensions = z.infer<
980
+ typeof PluginStaircaseDimensions
981
+ >
982
+
911
983
  // ---------------------------------------------------------------------------
912
984
  // space
913
985
  // ---------------------------------------------------------------------------
@@ -1135,18 +1207,20 @@ export type PluginDesignCreateBeamArgs = z.infer<
1135
1207
  * | `height` | `number`? | Wall height (default engine default) |
1136
1208
  * | `thickness` | `number`? | Wall thickness (default: `wallType`'s total layer thickness when given, else engine default) |
1137
1209
  * | `wallType` | `string`? | Wall type name from `design.types.list("wall")` — layers/material/thickness defaults (default generic) |
1210
+ * | `storey` | `number`? | Target storey (integer, must exist) — walls assigned + lifted to its base in the same undo entry (default: geometry-derived) |
1138
1211
  */
1139
1212
  export const PluginDesignCreateWallsArgs = z.object({
1140
1213
  profile: ProfileHandle,
1141
1214
  height: z.number().optional(),
1142
1215
  thickness: z.number().optional(),
1143
1216
  wallType: z.string().min(1).optional(),
1217
+ storey: z.number().int().optional(),
1144
1218
  })
1145
1219
  export type PluginDesignCreateWallsArgs = z.infer<
1146
1220
  typeof PluginDesignCreateWallsArgs
1147
1221
  >
1148
- // TRANSPORT: positional signature shipped — wallType is the trailing 4th arg:
1149
- // walls(profile: ProfileHandle, height?: number, thickness?: number, wallType?: string)
1222
+ // TRANSPORT: positional signature shipped — storey is the trailing 5th arg:
1223
+ // walls(profile: ProfileHandle, height?: number, thickness?: number, wallType?: string, storey?: number)
1150
1224
 
1151
1225
  // ---------------------------------------------------------------------------
1152
1226
  // staircase
@@ -1162,6 +1236,7 @@ export type PluginDesignCreateWallsArgs = z.infer<
1162
1236
  * | `label` | `string`? | Optional label |
1163
1237
  * | `structureId` | `string`? | Target structure (default active) |
1164
1238
  * | `level` | `string`? | Target level name (default active/`"01"`) |
1239
+ * | `dimensions` | {@linkcode PluginStaircaseDimensions}? | Positive dimension overrides in engine units, applied before creation |
1165
1240
  */
1166
1241
  export const PluginDesignCreateStaircaseArgs = z.object({
1167
1242
  preset: PluginStaircasePreset,
@@ -1169,6 +1244,7 @@ export const PluginDesignCreateStaircaseArgs = z.object({
1169
1244
  label: z.string().optional(),
1170
1245
  structureId: z.string().optional(),
1171
1246
  level: z.string().optional(),
1247
+ dimensions: PluginStaircaseDimensions.optional(),
1172
1248
  })
1173
1249
  export type PluginDesignCreateStaircaseArgs = z.infer<
1174
1250
  typeof PluginDesignCreateStaircaseArgs
@@ -1258,6 +1334,45 @@ export type PluginDesignCreateWindowArgs = z.infer<
1258
1334
  // TRANSPORT: positional signature shipped — facing is the trailing 5th arg:
1259
1335
  // window(catalogId: string, hostWall: ComponentHandle, position: Vec3Handle, options?: { label? }, facing?: Vec3Handle)
1260
1336
 
1337
+ // ---------------------------------------------------------------------------
1338
+ // opening
1339
+ // ---------------------------------------------------------------------------
1340
+
1341
+ const PluginOpeningBaseOptions = {
1342
+ catalogId: z.string().min(1),
1343
+ hostWall: ComponentHandle,
1344
+ position: Vec3Handle,
1345
+ facing: Vec3Handle.optional(),
1346
+ label: z.string().optional(),
1347
+ width: z.number().finite().positive().optional(),
1348
+ height: z.number().finite().positive().optional(),
1349
+ }
1350
+
1351
+ /**
1352
+ * Options for {@linkcode PluginDesignCreateApi.opening}, discriminated by
1353
+ * `kind`. Width and height are positive Snaptrude engine-unit values.
1354
+ * `sillHeight` is available only for windows, is nonnegative, and is precisely
1355
+ * the distance from the host wall base to the bottom of the window.
1356
+ */
1357
+ export const PluginDesignCreateOpeningOptions = z.discriminatedUnion("kind", [
1358
+ z
1359
+ .object({
1360
+ kind: z.literal("door"),
1361
+ ...PluginOpeningBaseOptions,
1362
+ })
1363
+ .strict(),
1364
+ z
1365
+ .object({
1366
+ kind: z.literal("window"),
1367
+ ...PluginOpeningBaseOptions,
1368
+ sillHeight: z.number().finite().nonnegative().optional(),
1369
+ })
1370
+ .strict(),
1371
+ ])
1372
+ export type PluginDesignCreateOpeningOptions = z.infer<
1373
+ typeof PluginDesignCreateOpeningOptions
1374
+ >
1375
+
1261
1376
  // ---------------------------------------------------------------------------
1262
1377
  // smartLayout
1263
1378
  // ---------------------------------------------------------------------------
@@ -23,7 +23,7 @@ import { PluginProgramAreasApi } from "./areas"
23
23
  * - {@linkcode PluginProgramApi.metrics} — Read the area-program summary
24
24
  * - {@linkcode PluginProgramApi.areas} — FAR / built-up-area rollup and groupings
25
25
  * - {@linkcode PluginProgramApi.spreadsheet} — Render program data to sheets and read it back
26
- * - {@linkcode PluginProgramApi.site} — Read the site/plot planning context
26
+ * - {@linkcode PluginProgramApi.site} — Read site/plot context and read/update Site Analysis
27
27
  * - {@linkcode PluginProgramApi.cores} — Read & predicate vertical-circulation cores (reads only)
28
28
  * - {@linkcode PluginProgramApi.classification} — Classification catalog + display tags
29
29
  *
@@ -42,7 +42,7 @@ export abstract class PluginProgramApi {
42
42
  public abstract areas: PluginProgramAreasApi
43
43
  /** Spreadsheet reports — render tables to sheets, export, list, read back. See {@linkcode PluginProgramSpreadsheetApi}. */
44
44
  public abstract spreadsheet: PluginProgramSpreadsheetApi
45
- /** Site/plot planning context total area, parcel footprints, geo rings. See {@linkcode PluginProgramSiteApi}. */
45
+ /** Site/plot context and persisted Site Analysis. See {@linkcode PluginProgramSiteApi}. */
46
46
  public abstract site: PluginProgramSiteApi
47
47
  /** Vertical-circulation cores — read & predicate (reads only). See {@linkcode PluginProgramCoresApi}. */
48
48
  public abstract cores: PluginProgramCoresApi