@snaptrude/plugin-core 1.1.0-dev.20261025.1 → 1.1.0-pbr-20261026.1

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": "1.1.0-dev.20261025.1",
3
+ "version": "1.1.0-pbr-20261026.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -18,6 +18,17 @@ import { NetworkFamilyContract } from "./networks"
18
18
  * compiles that into a live parametric definition the user can place, flex and
19
19
  * save to their library.
20
20
  *
21
+ * New light fittings use schema 1.3, category `Lighting Fixtures`, and
22
+ * `root.fixture.emitters`. A tube declaration generates its no-cap cylindrical emitting side
23
+ * and physical light together: key, kind:"tube", expression-valued center/axis/length/radius/
24
+ * luminousFluxLm, optional colorTemperatureK/enabled; optional numeric tint and material key.
25
+ * Read getReference() for exact grammar and a runnable example. A glowing finish alone is
26
+ * not an emitter and incomplete 1.2 fixture definitions fail before scene mutation. Existing
27
+ * 1.0/1.1 retain legacy semantics; changing only the marker is not a conversion.
28
+ * Per-instance lights.setEnabled overrides survive flex/save/reload; set/reset reject generated
29
+ * sources. Edit geometry/output using setParameters. V1 excludes end-cap emission, rectangles,
30
+ * elliptical/sheared transforms, and complete transport through a surrounding shade.
31
+ *
21
32
  * ## Generic component workflow
22
33
  *
23
34
  * ```
@@ -7,6 +7,7 @@ import { PluginDesignDoorsApi } from "./doors"
7
7
  import { PluginDesignWindowsApi } from "./windows"
8
8
  import { PluginDesignFurnitureApi } from "./furniture"
9
9
  import { PluginDesignMaterialsApi } from "./materials"
10
+ import { PluginDesignLightsApi } from "./lights"
10
11
  import { PluginDesignEraseApi } from "./erase"
11
12
  import { PluginDesignDeleteApi } from "./delete"
12
13
  import { PluginDesignBooleanApi } from "./boolean"
@@ -36,6 +37,7 @@ import {
36
37
  * - {@linkcode PluginDesignApi.doors} / {@linkcode PluginDesignApi.windows} — fenestration reads + edits
37
38
  * - {@linkcode PluginDesignApi.furniture} — furniture catalog reads
38
39
  * - {@linkcode PluginDesignApi.materials} — material library + assignment
40
+ * - {@linkcode PluginDesignApi.lights} — physical point/spot lights on furniture and family containers
39
41
  * - {@linkcode PluginDesignApi.boolean} — CSG boolean ops (union / subtract / intersect)
40
42
  * - {@linkcode PluginDesignApi.erase} — plan-level adjacency-edge erase (NOT hard delete)
41
43
  * - {@linkcode PluginDesignApi.delete} — hard entity removal
@@ -63,6 +65,8 @@ export abstract class PluginDesignApi {
63
65
  public abstract furniture: PluginDesignFurnitureApi
64
66
  /** Material library + assignment. See {@linkcode PluginDesignMaterialsApi}. */
65
67
  public abstract materials: PluginDesignMaterialsApi
68
+ /** Physical lights attached to furniture and family containers. See {@linkcode PluginDesignLightsApi}. */
69
+ public abstract lights: PluginDesignLightsApi
66
70
  /** Plan-level adjacency-edge erase (NOT hard delete). See {@linkcode PluginDesignEraseApi}. */
67
71
  public abstract erase: PluginDesignEraseApi
68
72
  /** Hard entity removal. See {@linkcode PluginDesignDeleteApi}. */
@@ -286,6 +290,7 @@ export * from "./doors"
286
290
  export * from "./windows"
287
291
  export * from "./furniture"
288
292
  export * from "./materials"
293
+ export * from "./lights"
289
294
  export * from "./lock"
290
295
  export * from "./erase"
291
296
  export * from "./delete"
@@ -0,0 +1,406 @@
1
+ import * as z from "zod"
2
+ import { PluginApiReturn } from "../../../types"
3
+ import { ComponentHandle } from "../../../handles"
4
+ import { PluginDesignChangeResult } from "../lock"
5
+
6
+ /**
7
+ * Most lights one component can store. This is a **storage quota**, enforced by the host:
8
+ * a well-formed list longer than this is refused with `RESOURCE_QUOTA_EXCEEDED` (it is not
9
+ * a schema rule, so the schemas below do not reject it as malformed `VALIDATION`). How many
10
+ * lights the renderer shades in a frame (and how many shadow layers it can afford) is a
11
+ * separate, renderer-wide budget; lights over that budget are simply not drawn, they are
12
+ * never rejected here.
13
+ */
14
+ export const PLUGIN_MAX_COMPONENT_LIGHTS = 64
15
+
16
+ /** A light's `key`: 1-32 characters, lower-case start, unique within its component. */
17
+ export const PLUGIN_LIGHT_KEY_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/
18
+
19
+ const finite = z.number().refine(Number.isFinite, "must be a finite number")
20
+
21
+ /**
22
+ * A light's strength. Exactly one of:
23
+ * - `{ luminousFluxLm }` — total luminous flux in lumens. For a spot this is the flux of
24
+ * the whole shaded cone (full intensity inside the inner cone, smooth falloff to zero
25
+ * at the outer cone), not the flux of a uniform cone.
26
+ * - `{ luminousIntensityCd }` — on-axis luminous intensity in candela (a spot's peak).
27
+ *
28
+ * Values are physical (not unit-converted): `[0, 10_000_000]`.
29
+ */
30
+ export const PluginLightIntensity = z.union([
31
+ z.strictObject({ luminousFluxLm: finite.min(0).max(1e7) }),
32
+ z.strictObject({ luminousIntensityCd: finite.min(0).max(1e7) }),
33
+ ])
34
+ export type PluginLightIntensity = z.infer<typeof PluginLightIntensity>
35
+
36
+ /**
37
+ * A spot's cone in degrees. `outerHalfAngleDeg` is in `(0, 90)`; `innerHalfAngleDeg`
38
+ * (default 70 % of the outer) is where full intensity ends, in `[0, outerHalfAngleDeg]`.
39
+ */
40
+ export const PluginLightCone = z.strictObject({
41
+ outerHalfAngleDeg: finite.gt(0).lt(90),
42
+ innerHalfAngleDeg: finite.min(0).optional(),
43
+ }).refine(
44
+ (c) => c.innerHalfAngleDeg === undefined || c.innerHalfAngleDeg <= c.outerHalfAngleDeg,
45
+ { message: "innerHalfAngleDeg must not exceed outerHalfAngleDeg" }
46
+ )
47
+ export type PluginLightCone = z.infer<typeof PluginLightCone>
48
+
49
+ const Vec3 = z.tuple([finite, finite, finite])
50
+
51
+ /**
52
+ * One light stored on a component (a family container or a furniture item).
53
+ *
54
+ * The light lives in the component's **local frame** and follows its host transform:
55
+ * moving, rotating, scaling, mirroring or copying the component carries the light, the
56
+ * offset scales with the component, and a spot's direction is rotated/mirrored by the
57
+ * full host transform. Photometric values are physical; lengths are metres, never
58
+ * engine units.
59
+ *
60
+ * | Property | Type | Description |
61
+ * |---|---|---|
62
+ * | `key` | `string` | Unique within the component; `^[a-z][a-z0-9_-]{0,31}$`. Names this light in `setEnabled`. |
63
+ * | `kind` | `"point" \| "spot"` | A point radiates in all directions; a spot needs a `cone`. |
64
+ * | `offsetMeters` | `[x, y, z]` | Position in the component's local frame, metres. |
65
+ * | `directionLocal` | `[x, y, z]` | Spot axis in the local frame (default straight down `[0, -1, 0]`); need not be unit length. |
66
+ * | `intensity` | {@linkcode PluginLightIntensity} | `{ luminousFluxLm }` or `{ luminousIntensityCd }`. |
67
+ * | `colorTemperatureK` | `number` | 1000-40000 K (default 2700). |
68
+ * | `tint` | `[r, g, b]` | Linear multipliers in `[0, 4]` on the temperature colour (default white). |
69
+ * | `cone` | {@linkcode PluginLightCone} | Required for a spot. |
70
+ * | `sourceRadiusMeters` | `number` | `[0, 1]` m (default 0.02): soft-shadow penumbra and highlight size. |
71
+ * | `castShadow` | `boolean` | Default `true`; `false` lets light pass through walls. |
72
+ * | `enabled` | `boolean` | Default `true`; `false` is lamp-off without losing the description. |
73
+ */
74
+ const PointSpotLight = z
75
+ .strictObject({
76
+ key: z.string().regex(PLUGIN_LIGHT_KEY_PATTERN, "key must match ^[a-z][a-z0-9_-]{0,31}$"),
77
+ kind: z.enum(["point", "spot"]),
78
+ offsetMeters: Vec3,
79
+ directionLocal: Vec3.optional(),
80
+ intensity: PluginLightIntensity,
81
+ colorTemperatureK: finite.min(1000).max(40000).optional(),
82
+ tint: z.tuple([finite.min(0).max(4), finite.min(0).max(4), finite.min(0).max(4)]).optional(),
83
+ cone: PluginLightCone.optional(),
84
+ sourceRadiusMeters: finite.min(0).max(1).optional(),
85
+ castShadow: z.boolean().optional(),
86
+ enabled: z.boolean().optional(),
87
+ })
88
+ .superRefine((light, ctx) => {
89
+ if (light.kind === "spot" && !light.cone) {
90
+ ctx.addIssue({ code: "custom", path: ["cone"], message: "a spot needs a cone" })
91
+ }
92
+ if (light.directionLocal && Math.hypot(...light.directionLocal) < 1e-6) {
93
+ ctx.addIssue({
94
+ code: "custom",
95
+ path: ["directionLocal"],
96
+ message: "directionLocal must be a non-zero vector",
97
+ })
98
+ }
99
+ })
100
+ /** An outward-emitting circular cylindrical SIDE, never a point with a wider highlight.
101
+ * End caps do not emit. Dimensions are local metres; the host preserves final total lumens
102
+ * under circular radial/axial scale and reflection. Shear/elliptical transforms are unsupported.
103
+ * Tube tint is chromaticity: normalized after tint so output remains luminousFluxLm. A zero
104
+ * tint is invalid; use enabled=false for lamp-off. Surrounding shade transport is not solved.
105
+ * Prefer schema 1.3 family.fixture.emitters: creation then owns side geometry and light together.
106
+ */
107
+ export const PluginFixtureTube = z.strictObject({
108
+ key: z.string().regex(PLUGIN_LIGHT_KEY_PATTERN),
109
+ kind: z.literal("tube"),
110
+ offsetMeters: Vec3,
111
+ directionLocal: Vec3.refine((v) => Math.hypot(...v) >= 1e-6, "tube axis must be nonzero"),
112
+ lengthMeters: finite.gt(0),
113
+ radiusMeters: finite.gt(0),
114
+ intensity: z.strictObject({ luminousFluxLm: finite.min(0).max(1e7) }),
115
+ colorTemperatureK: finite.min(1000).max(40000).optional(),
116
+ tint: z.tuple([finite.min(0).max(4), finite.min(0).max(4), finite.min(0).max(4)])
117
+ .refine((v) => v.some((channel) => channel > 0), "tube tint must have nonzero luminance").optional(),
118
+ castShadow: z.boolean().optional(),
119
+ enabled: z.boolean().optional(),
120
+ })
121
+ export type PluginFixtureTube = z.infer<typeof PluginFixtureTube>
122
+
123
+ /** Stored authored light, discriminated by kind; tube geometry and flux cannot be omitted. */
124
+ export const PluginFixtureLight = z.union([PointSpotLight, PluginFixtureTube])
125
+ export type PluginFixtureLight = z.infer<typeof PluginFixtureLight>
126
+
127
+ /**
128
+ * A component's whole light list, replace-all, unique keys. Its length is not limited here:
129
+ * the {@linkcode PLUGIN_MAX_COMPONENT_LIGHTS} quota is the host's to enforce and report as
130
+ * `RESOURCE_QUOTA_EXCEEDED`.
131
+ */
132
+ export const PluginFixtureLightList = z.array(PluginFixtureLight).superRefine((lights, ctx) => {
133
+ const seen = new Set<string>()
134
+ lights.forEach((l, i) => {
135
+ if (seen.has(l.key)) {
136
+ ctx.addIssue({
137
+ code: "custom",
138
+ path: [i, "key"],
139
+ message: `duplicate light key "${l.key}"`,
140
+ })
141
+ }
142
+ seen.add(l.key)
143
+ })
144
+ })
145
+ export type PluginFixtureLightList = z.infer<typeof PluginFixtureLightList>
146
+
147
+ /**
148
+ * Arguments for {@linkcode PluginDesignLightsApi.set}.
149
+ *
150
+ * | Property | Type | Description |
151
+ * |---|---|---|
152
+ * | `component` | {@linkcode ComponentHandle} | A furniture item or family container |
153
+ * | `lights` | {@linkcode PluginFixtureLight}`[]` | The complete new list (replaces the old one); at most 64 |
154
+ */
155
+ export const PluginDesignLightsSetArgs = z.object({
156
+ component: ComponentHandle,
157
+ lights: PluginFixtureLightList,
158
+ })
159
+ export type PluginDesignLightsSetArgs = z.infer<typeof PluginDesignLightsSetArgs>
160
+
161
+ /**
162
+ * Arguments for {@linkcode PluginDesignLightsApi.setEnabled}.
163
+ *
164
+ * | Property | Type | Description |
165
+ * |---|---|---|
166
+ * | `component` | {@linkcode ComponentHandle} | The component that owns the light |
167
+ * | `key` | `string` | The light's key |
168
+ * | `enabled` | `boolean` | `true` = on, `false` = off |
169
+ */
170
+ export const PluginDesignLightsSetEnabledArgs = z.object({
171
+ component: ComponentHandle,
172
+ key: z.string().regex(PLUGIN_LIGHT_KEY_PATTERN),
173
+ enabled: z.boolean(),
174
+ })
175
+ export type PluginDesignLightsSetEnabledArgs = z.infer<typeof PluginDesignLightsSetEnabledArgs>
176
+
177
+ /** Arguments for {@linkcode PluginDesignLightsApi.reset}: the components to clear. */
178
+ export const PluginDesignLightsResetArgs = z.object({
179
+ components: z.array(ComponentHandle),
180
+ })
181
+ export type PluginDesignLightsResetArgs = z.infer<typeof PluginDesignLightsResetArgs>
182
+
183
+ /**
184
+ * Options for {@linkcode PluginDesignLightsApi.list}.
185
+ *
186
+ * | Property | Type | Description |
187
+ * |---|---|---|
188
+ * | `includeDisabled` | `boolean` | Keep lights whose `enabled` is `false` (default `true`). `false` drops them, and components left with none. |
189
+ */
190
+ export const PluginDesignLightsListOptions = z.object({
191
+ includeDisabled: z.boolean().optional(),
192
+ })
193
+ export type PluginDesignLightsListOptions = z.infer<typeof PluginDesignLightsListOptions>
194
+
195
+ /** One entry of {@linkcode PluginDesignLightsApi.list}: a component and its lights. */
196
+ export const PluginComponentLights = z.object({
197
+ component: ComponentHandle,
198
+ lights: z.array(PluginFixtureLight),
199
+ })
200
+ export type PluginComponentLights = z.infer<typeof PluginComponentLights>
201
+
202
+ /**
203
+ * The kind of light fitting a component is recognised as, which gives it default lights while it
204
+ * has no stored list (see {@linkcode PluginDesignLightsApi.effective}).
205
+ */
206
+ export const PluginLightFitting = z.enum([
207
+ "floor-lamp",
208
+ "table-lamp",
209
+ "pendant",
210
+ "pendant-cluster",
211
+ "downlight",
212
+ "linear-batten",
213
+ "wall-sconce",
214
+ "luminaire",
215
+ ])
216
+ export type PluginLightFitting = z.infer<typeof PluginLightFitting>
217
+
218
+ /**
219
+ * What a component lights the model with ({@linkcode PluginDesignLightsApi.effective}).
220
+ *
221
+ * | Property | Type | Description |
222
+ * |---|---|---|
223
+ * | `source` | `'stored' \| 'default' \| 'none'` | `stored`: its stored list (an empty list included: explicitly no lights). `default`: nothing stored and it is a light fitting, so it lights with its fitting's defaults. `none`: neither |
224
+ * | `fitting` | {@linkcode PluginLightFitting} `\| null` | The fitting it is recognised as, or `null` |
225
+ * | `lights` | {@linkcode PluginFixtureLight}`[]` | The lights it lights with |
226
+ */
227
+ export const PluginEffectiveLights = z.object({
228
+ source: z.enum(["stored", "default", "none"]),
229
+ fitting: PluginLightFitting.nullable(),
230
+ lights: z.array(PluginFixtureLight),
231
+ })
232
+ export type PluginEffectiveLights = z.infer<typeof PluginEffectiveLights>
233
+
234
+ /**
235
+ * `snaptrude.design.lights` — physical point, spot and cylindrical-side tube sources.
236
+ * New fixtures should be schema 1.3 families with fixture.emitters, not glow-only meshes.
237
+ * Generated source geometry and output are one recipe: set/reset reject recipe-owned lights
238
+ * with PRECONDITION_FAILED. Edit family parameters instead. setEnabled stores a keyed override
239
+ * surviving flex/save/reload; removed recipe keys lose the override, and undo restores it.
240
+ *
241
+ * A light belongs to a **furniture item or a parametric family container** and is
242
+ * stored with it: it is saved, reloaded, copied, pasted and restored by undo with the
243
+ * component, and follows its transform (offsets scale with the component, a spot's
244
+ * direction follows the full rotation/mirroring). Build a light fitting as a family
245
+ * (or place a furniture item), then attach its lights to the returned
246
+ * {@linkcode ComponentHandle}. Other kinds (walls, masses, spaces) are rejected with
247
+ * `PRECONDITION_FAILED`.
248
+ *
249
+ * Light fittings (lamps, pendants, downlights, battens, sconces) light by **default** while
250
+ * nothing is stored on them; {@linkcode PluginDesignLightsApi.effective} reads those defaults.
251
+ *
252
+ * Lists are **replace-all**: {@linkcode PluginDesignLightsApi.set} sends the whole list.
253
+ * `get` and `list` return exactly what was stored (no defaults are filled in). Each mutation is one
254
+ * undo step; writing the value already stored is a no-op. A component holds at most 64
255
+ * lights (more is `RESOURCE_QUOTA_EXCEEDED`, not `VALIDATION`); how many the renderer draws in one frame is a separate budget and never
256
+ * rejects a write.
257
+ *
258
+ * Errors: bad light data → `VALIDATION` (`details` lists the issues); unknown/stale
259
+ * handle → `HANDLE_INVALID`; unsupported component kind, locked component or missing
260
+ * geometry → `PRECONDITION_FAILED`; more than 64 lights → `RESOURCE_QUOTA_EXCEEDED`.
261
+ */
262
+ export abstract class PluginDesignLightsApi {
263
+ constructor() {}
264
+
265
+ /**
266
+ * Read the lights stored on a component, exactly as written (`[]` when it has none).
267
+ *
268
+ * @param component The furniture item or family container
269
+ * @returns The component's {@linkcode PluginFixtureLight}`[]`
270
+ *
271
+ * @examplePrompt What lights does this lamp have?
272
+ * @examplePrompt Show me the light settings on the pendant I just built
273
+ * @examplePrompt Is there a light attached to this fixture?
274
+ * @examplePrompt Read the lighting of the selected furniture
275
+ *
276
+ * # Example
277
+ * ```ts
278
+ * const lights = await snaptrude.design.lights.get(lamp)
279
+ * console.log(lights.map((l) => l.key))
280
+ * ```
281
+ */
282
+ public abstract get(component: ComponentHandle): PluginApiReturn<PluginFixtureLight[]>
283
+
284
+ /**
285
+ * Read what a component lights the model with: its stored lights when it has a stored list
286
+ * (an empty one included), else the default lights of the light fitting it is (a floor lamp,
287
+ * a pendant, a downlight...), else none. Defaults are derived, never stored. Read-only.
288
+ *
289
+ * @param component The furniture item or family container
290
+ * @returns {@linkcode PluginEffectiveLights}
291
+ *
292
+ * @examplePrompt Does this lamp light the room?
293
+ * @examplePrompt What light does the pendant give off by default?
294
+ * @examplePrompt Which lights in this room come from the fittings' defaults?
295
+ * @examplePrompt Show the effective lighting of the selected fixture
296
+ *
297
+ * # Example
298
+ * ```ts
299
+ * const { source, fitting, lights } = await snaptrude.design.lights.effective(lamp)
300
+ * console.log(source, fitting, lights.length)
301
+ * ```
302
+ */
303
+ public abstract effective(component: ComponentHandle): PluginApiReturn<PluginEffectiveLights>
304
+
305
+ /**
306
+ * List every component that has lights, with its lights. Disabled lights are included
307
+ * unless `includeDisabled` is `false`.
308
+ *
309
+ * @param options Optional {@linkcode PluginDesignLightsListOptions}
310
+ * @returns {@linkcode PluginComponentLights}`[]`
311
+ *
312
+ * @examplePrompt Which fixtures in the model have lights?
313
+ * @examplePrompt List all the lamps that are switched on
314
+ * @examplePrompt How many lights are in this project?
315
+ * @examplePrompt Show every light fixture and its settings
316
+ *
317
+ * # Example
318
+ * ```ts
319
+ * const lit = await snaptrude.design.lights.list({ includeDisabled: false })
320
+ * console.log(lit.length, "components are lighting the model")
321
+ * ```
322
+ */
323
+ public abstract list(
324
+ options?: PluginDesignLightsListOptions
325
+ ): PluginApiReturn<PluginComponentLights[]>
326
+
327
+ /**
328
+ * Replace a component's lights with `lights` (replace-all; `[]` removes them all).
329
+ * Recipe-owned fixture sources reject this operation; edit the family parameters instead.
330
+ * One undo step. Writing the list already stored does nothing. The whole list is
331
+ * validated first; on any problem nothing changes.
332
+ *
333
+ * @param component The furniture item or family container to light
334
+ * @param lights The complete new list, at most 64, unique keys
335
+ * @returns The affected component
336
+ *
337
+ * @examplePrompt Add a warm 1600 lumen bulb to the pendant lamp
338
+ * @examplePrompt Put a 38 degree downlight spot in this ceiling fixture
339
+ * @examplePrompt Make this lamp glow at 3000K
340
+ * @examplePrompt Give the table lamp a shadow-casting point light
341
+ *
342
+ * # Example
343
+ * ```ts
344
+ * await snaptrude.design.lights.set(pendant, [
345
+ * {
346
+ * key: "bulb",
347
+ * kind: "point",
348
+ * offsetMeters: [0, -0.3, 0],
349
+ * intensity: { luminousFluxLm: 1600 },
350
+ * colorTemperatureK: 2700,
351
+ * },
352
+ * ])
353
+ * ```
354
+ */
355
+ public abstract set(
356
+ component: ComponentHandle,
357
+ lights: PluginFixtureLight[]
358
+ ): PluginApiReturn<PluginDesignChangeResult>
359
+
360
+ /**
361
+ * Switch one light on or off without resending its description. One undo step; a
362
+ * no-op when it already has that state.
363
+ *
364
+ * @param component The component that owns the light
365
+ * @param key The light's `key`
366
+ * @param enabled `true` = on, `false` = off
367
+ * @returns The affected component
368
+ *
369
+ * @examplePrompt Turn off the bulb in this lamp
370
+ * @examplePrompt Switch the downlight back on
371
+ * @examplePrompt Dim the room by turning off the pendant light
372
+ * @examplePrompt Toggle the lamp
373
+ *
374
+ * # Example
375
+ * ```ts
376
+ * await snaptrude.design.lights.setEnabled(pendant, "bulb", false)
377
+ * ```
378
+ */
379
+ public abstract setEnabled(
380
+ component: ComponentHandle,
381
+ key: string,
382
+ enabled: boolean
383
+ ): PluginApiReturn<PluginDesignChangeResult>
384
+
385
+ /**
386
+ * Remove all lights from the given components. Every component is checked first and
387
+ * recipe-owned fixture sources reject the entire batch before any component changes.
388
+ * nothing changes if any is invalid; components without lights are skipped. One undo
389
+ * step for the whole call.
390
+ *
391
+ * @param components The components to clear
392
+ * @returns The components that were affected
393
+ *
394
+ * @examplePrompt Remove the lights from this fixture
395
+ * @examplePrompt Clear all the lamps' lighting
396
+ * @examplePrompt Take the light off the selected pendants
397
+ * @examplePrompt Reset the lights on these components
398
+ *
399
+ * # Example
400
+ * ```ts
401
+ * const lit = await snaptrude.design.lights.list()
402
+ * await snaptrude.design.lights.reset(lit.map((e) => e.component))
403
+ * ```
404
+ */
405
+ public abstract reset(components: ComponentHandle[]): PluginApiReturn<PluginDesignChangeResult>
406
+ }
@@ -21,6 +21,12 @@ import { ComponentHandle, MaterialHandle } from "../../../handles"
21
21
  * is durable only while the component's BREP topology (face count/order) is unchanged.
22
22
  *
23
23
  * `copy` (needs an undo wrapper) lands in a later pass.
24
+ *
25
+ * A material is a reusable finish, not a fixture source. Emissive appearance alone does not
26
+ * create a physical light. Author new fixtures through schema 1.3 family.fixture.emitters:
27
+ * generated side geometry, total lumens, temperature and enabled state belong to the placed
28
+ * fixture, not its shared material. Its visible emission is derived from the same photometry
29
+ * used by direct and DDGI lighting. Repainting a source must not change output or another lamp.
24
30
  */
25
31
  export abstract class PluginDesignMaterialsApi {
26
32
  constructor() {}
@@ -48,16 +54,20 @@ export abstract class PluginDesignMaterialsApi {
48
54
 
49
55
  /**
50
56
  * List the preset material library — the catalog the app's material browser
51
- * shows (the built-in finishes plus the workspace's uploaded materials),
52
- * fetched from the material library service. Library entries are **not**
53
- * scene materials and carry no {@linkcode MaterialHandle}; each is a named
54
- * texture record. To use one, feed it to
57
+ * shows: the deployment PBR catalogue plus the server library and workspace uploads.
58
+ * Library entries are **not** scene materials and carry no {@linkcode MaterialHandle}.
59
+ * PBR names include an identity-qualified suffix `[catalogId@catalogVersion]`:
60
+ * preserve the EXACT returned name and pass `{ name: preset.name }` to create/apply.
61
+ * The host resolves all authored PBR maps, physical coverage and glass properties;
62
+ * do not strip the suffix, substitute an image-only finish, or override color/alpha.
63
+ * Scalar paints and clear glass can have an empty textureUrl and are still valid.
64
+ * For legacy image entries, feed the returned record to
55
65
  * {@linkcode PluginDesignMaterialsApi.create} /
56
66
  * {@linkcode PluginDesignMaterialsApi.apply} as a
57
67
  * {@linkcode PluginMaterialSpec}: `{ name, textureUrl, materialType: category }`.
58
68
  *
59
- * @returns The library as {@linkcode PluginPresetMaterial}`[]` (`[]` when the
60
- * library service is unreachable)
69
+ * @returns The library as {@linkcode PluginPresetMaterial}`[]`. Missing/malformed
70
+ * deployment PBR data rejects; the legacy server library may return `[]` on failure.
61
71
  *
62
72
  * @examplePrompt Show me the preset materials library
63
73
  * @examplePrompt What wood finishes are available in the material library?
@@ -167,6 +177,25 @@ export abstract class PluginDesignMaterialsApi {
167
177
  save?: boolean,
168
178
  ): PluginApiReturn<PluginMaterialResult>
169
179
 
180
+ /**
181
+ * Paint only glazing or frame slots, never the whole window/door. This preserves
182
+ * clear panes when painting metal frames. Families change with their linked copies;
183
+ * legacy instanced types change every instance of that type. BREP components are
184
+ * refused (use applyToFaces), as are locked targets or locked linked/type members.
185
+ * Slot roles come from the authored appearance, not a previously applied paint.
186
+ * Always one undo step. Catalogue selectors use the exact listPresets name.
187
+ * @param components Components whose family/type slots to paint
188
+ * @param slot Authored glazing slots or every other (frame/hardware) slot
189
+ * @param material Existing handle or inline spec, e.g. `{ name: preset.name }`
190
+ * @returns Count of targets whose family/type had at least one matching slot
191
+ * @examplePrompt Paint only the window frames with a catalogue metal finish
192
+ */
193
+ public abstract applyToSlot(
194
+ components: ComponentHandle[],
195
+ slot: "glazing" | "frame",
196
+ material: MaterialHandle | PluginMaterialSpec,
197
+ ): PluginApiReturn<PluginMaterialResult>
198
+
170
199
  /**
171
200
  * Reset one or more components back to the default material for their type,
172
201
  * removing any applied finish. The inverse of `apply`; undoable when `save`
@@ -319,9 +348,11 @@ export abstract class PluginDesignMaterialsApi {
319
348
  * @examplePrompt Make a material from this texture image URL
320
349
  * @examplePrompt Add a semi-transparent glass material to the project
321
350
  * @examplePrompt Define a dark grey concrete finish I can reuse
351
+ * @examplePrompt Make the lampshade a linen that glows when the lamp is on
322
352
  *
323
353
  * # Example
324
354
  * ```ts
355
+ * const linen = await snaptrude.design.materials.create({ name: "Linen Shade", color: "#efe6d4", transmission: 0.3 })
325
356
  * const glass = await snaptrude.design.materials.create({ name: "Frosted Glass", color: "#cfe4e8", alpha: 0.4 })
326
357
  * const walls = await snaptrude.design.query.listWalls({ storeys: [1] })
327
358
  * await snaptrude.design.materials.apply(walls, glass)
@@ -425,6 +456,7 @@ export abstract class PluginDesignMaterialsApi {
425
456
  * | `alpha` | `number` | Opacity `0..1` |
426
457
  * | `materialType` | `string` | Material type tag (optional) |
427
458
  * | `textureUrl` | `string` | Diffuse texture URL (optional) |
459
+ * | `transmission` | `number` | Diffuse transmission `0..1` (optional; absent when none) |
428
460
  */
429
461
  export const PluginMaterialInfo = z.object({
430
462
  name: z.string(),
@@ -433,6 +465,7 @@ export const PluginMaterialInfo = z.object({
433
465
  alpha: z.number(),
434
466
  materialType: z.string().optional(),
435
467
  textureUrl: z.string().optional(),
468
+ transmission: z.number().optional(),
436
469
  })
437
470
  export type PluginMaterialInfo = z.infer<typeof PluginMaterialInfo>
438
471
 
@@ -441,11 +474,18 @@ export type PluginMaterialInfo = z.infer<typeof PluginMaterialInfo>
441
474
  *
442
475
  * | Property | Type | Description |
443
476
  * |---|---|---|
444
- * | `name` | `string` | Name (optional; generated if omitted) |
477
+ * | `name` | `string` | Name (optional; generated if omitted); an exact identity-qualified listPresets name selects the full authored PBR finish |
445
478
  * | `color` | `string` | Diffuse color `#rrggbb` (optional) |
446
479
  * | `textureUrl` | `string` | Diffuse texture URL (optional; wins over color) |
447
480
  * | `alpha` | `number` | Opacity `0..1` (optional) |
448
481
  * | `materialType` | `string` | Material type tag (optional) |
482
+ * | `transmission` | `number` | Diffuse transmission `0..1` (optional): the share of a fixture light the material passes through, scattered |
483
+ *
484
+ * `transmission` is for lampshades and lenses: a fabric or paper shade (linen ~0.3, paper ~0.5)
485
+ * or an opal diffuser (~0.6). A component painted with it glows from the fixture lights
486
+ * (`design.lights`) behind it and passes that share of their light into the room. Fixture
487
+ * lights only: daylight does not pass. Leave it out for opaque finishes. Not combinable with a
488
+ * catalog (listPresets) name.
449
489
  */
450
490
  export const PluginMaterialSpec = z.object({
451
491
  name: z.string().optional(),
@@ -453,6 +493,7 @@ export const PluginMaterialSpec = z.object({
453
493
  textureUrl: z.string().optional(),
454
494
  alpha: z.number().optional(),
455
495
  materialType: z.string().optional(),
496
+ transmission: z.number().min(0).max(1).optional(),
456
497
  })
457
498
  export type PluginMaterialSpec = z.infer<typeof PluginMaterialSpec>
458
499
 
@@ -544,6 +585,16 @@ export type PluginDesignMaterialsApplyArgs = z.infer<
544
585
  typeof PluginDesignMaterialsApplyArgs
545
586
  >
546
587
 
588
+ /** Arguments for the existing host slot-paint contract. */
589
+ export const PluginDesignMaterialsApplyToSlotArgs = z.object({
590
+ components: z.array(ComponentHandle),
591
+ slot: z.enum(["glazing", "frame"]),
592
+ material: z.union([MaterialHandle, PluginMaterialSpec]),
593
+ })
594
+ export type PluginDesignMaterialsApplyToSlotArgs = z.infer<
595
+ typeof PluginDesignMaterialsApplyToSlotArgs
596
+ >
597
+
547
598
  /** Arguments for {@linkcode PluginDesignMaterialsApi.reset}. */
548
599
  export const PluginDesignMaterialsResetArgs = z.object({
549
600
  components: z.array(ComponentHandle),