@vived/component-pneumatic-panel 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/COMPONENT_KNOWLEDGE.md +642 -0
  3. package/LICENSE +15 -0
  4. package/README.md +62 -23
  5. package/dist/Domain/Adapters/pneumaticPanelPMAdapter.d.ts.map +1 -1
  6. package/dist/Domain/Controllers/applyPanelState.d.ts.map +1 -1
  7. package/dist/Domain/Controllers/getPanelState.d.ts.map +1 -1
  8. package/dist/Domain/Controllers/panelStateDefaults.d.ts +0 -1
  9. package/dist/Domain/Controllers/panelStateDefaults.d.ts.map +1 -1
  10. package/dist/Domain/Entities/PneumaticPanelEntity.d.ts +0 -4
  11. package/dist/Domain/Entities/PneumaticPanelEntity.d.ts.map +1 -1
  12. package/dist/Domain/Mocks/MockPneumaticPanelSelectUC.d.ts +0 -4
  13. package/dist/Domain/Mocks/MockPneumaticPanelSelectUC.d.ts.map +1 -1
  14. package/dist/Domain/PMs/PneumaticPanelPM.d.ts +0 -3
  15. package/dist/Domain/PMs/PneumaticPanelPM.d.ts.map +1 -1
  16. package/dist/Domain/UCs/PneumaticPanelCheckPressureUC.d.ts +4 -3
  17. package/dist/Domain/UCs/PneumaticPanelCheckPressureUC.d.ts.map +1 -1
  18. package/dist/Domain/UCs/PneumaticPanelSelectUC.d.ts +0 -14
  19. package/dist/Domain/UCs/PneumaticPanelSelectUC.d.ts.map +1 -1
  20. package/dist/Frameworks/Babylon/PneumaticPanelBabylonView.d.ts +9 -12
  21. package/dist/Frameworks/Babylon/PneumaticPanelBabylonView.d.ts.map +1 -1
  22. package/dist/Frameworks/Babylon/PneumaticPanelExhaustView.d.ts +2 -2
  23. package/dist/Frameworks/Babylon/PneumaticPanelExhaustView.d.ts.map +1 -1
  24. package/dist/Frameworks/Babylon/PneumaticPanelGaugeView.d.ts +2 -2
  25. package/dist/Frameworks/Babylon/PneumaticPanelGaugeView.d.ts.map +1 -1
  26. package/dist/Frameworks/Babylon/PneumaticPanelHitboxView.d.ts +5 -7
  27. package/dist/Frameworks/Babylon/PneumaticPanelHitboxView.d.ts.map +1 -1
  28. package/dist/Frameworks/Babylon/PneumaticPanelLockView.d.ts +2 -2
  29. package/dist/Frameworks/Babylon/PneumaticPanelLockView.d.ts.map +1 -1
  30. package/dist/PneumaticPanelFacade.d.ts +1 -13
  31. package/dist/PneumaticPanelFacade.d.ts.map +1 -1
  32. package/dist/index.d.ts +1 -7
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1257 -51
  35. package/package.json +9 -2
  36. package/dist/.gitkeep +0 -0
  37. package/dist/Domain/Controllers/clearAllHighlights.d.ts +0 -9
  38. package/dist/Domain/Controllers/clearAllHighlights.d.ts.map +0 -1
  39. package/dist/Domain/Controllers/onPanelHoverEnter.d.ts +0 -7
  40. package/dist/Domain/Controllers/onPanelHoverEnter.d.ts.map +0 -1
  41. package/dist/Domain/Controllers/onPanelHoverExit.d.ts +0 -7
  42. package/dist/Domain/Controllers/onPanelHoverExit.d.ts.map +0 -1
  43. package/dist/Domain/Controllers/panelHitboxHoverEnter.d.ts +0 -7
  44. package/dist/Domain/Controllers/panelHitboxHoverEnter.d.ts.map +0 -1
  45. package/dist/Domain/Controllers/panelHitboxHoverExit.d.ts +0 -7
  46. package/dist/Domain/Controllers/panelHitboxHoverExit.d.ts.map +0 -1
  47. package/dist/Domain/Controllers/setHighlightedObject.d.ts +0 -11
  48. package/dist/Domain/Controllers/setHighlightedObject.d.ts.map +0 -1
  49. package/dist/Frameworks/Babylon/OutlinePulseController.d.ts +0 -18
  50. package/dist/Frameworks/Babylon/OutlinePulseController.d.ts.map +0 -1
  51. package/dist/Frameworks/Babylon/OverlayPulseController.d.ts +0 -17
  52. package/dist/Frameworks/Babylon/OverlayPulseController.d.ts.map +0 -1
  53. package/dist/Frameworks/Babylon/PanelOutlineController.d.ts +0 -32
  54. package/dist/Frameworks/Babylon/PanelOutlineController.d.ts.map +0 -1
  55. package/dist/Frameworks/Babylon/ScreenSpaceOutlinePulseController.d.ts +0 -17
  56. package/dist/Frameworks/Babylon/ScreenSpaceOutlinePulseController.d.ts.map +0 -1
  57. package/dist/clipPlaneFragment-BRmzi2tV.js +0 -55
  58. package/dist/clipPlaneFragment-CDdVuU0n.js +0 -55
  59. package/dist/clipPlaneVertex-CIEg9nQi.js +0 -358
  60. package/dist/clipPlaneVertex-CIarPzTc.js +0 -391
  61. package/dist/dumpTools-BUC-AUZt.js +0 -110
  62. package/dist/glowBlurPostProcess.fragment-DDhSDO38.js +0 -13
  63. package/dist/glowBlurPostProcess.fragment-fpk0xJVe.js +0 -13
  64. package/dist/glowMapGeneration.fragment-BBSNxu8K.js +0 -193
  65. package/dist/glowMapGeneration.fragment-Cl-xwsZp.js +0 -161
  66. package/dist/glowMapGeneration.vertex-CzQHgJdT.js +0 -85
  67. package/dist/glowMapGeneration.vertex-DErxE57o.js +0 -85
  68. package/dist/glowMapMerge.vertex-BQosy0g5.js +0 -14
  69. package/dist/glowMapMerge.vertex-tVYqLRGm.js +0 -13
  70. package/dist/index-CmNnQLDH.js +0 -36863
  71. package/dist/pass.fragment-D3pYn4R1.js +0 -10
  72. package/dist/pneumaticPanel.glb +0 -0
  73. package/dist/selection.fragment-CGZK1kVt.js +0 -45
  74. package/dist/selection.fragment-DjWRsu7R.js +0 -45
  75. package/dist/selection.vertex-DY_1B2lX.js +0 -84
  76. package/dist/selection.vertex-PhS5tOTA.js +0 -83
  77. package/dist/selectionOutline.fragment-Bp__J_pC.js +0 -53
  78. package/dist/selectionOutline.fragment-DmyQJACa.js +0 -54
  79. package/dist/studio.env +0 -0
  80. package/dist/thinEngine-dZnz-Owz.js +0 -4
@@ -0,0 +1,642 @@
1
+ # Pneumatic Panel — VIVED Smart Component
2
+
3
+ ## Summary
4
+
5
+ A 3D pneumatic control panel for VIVED slide apps featuring an exhaust knob, safety lock, and pressure gauge. Developers can toggle exhaust and lock states, check pressure, and snapshot/restore panel state for guided training or assessment interactions. Use it when you need a compact industrial interaction model with built-in lockout/tagout constraints and pressure behavior. The component is presentation-free (ADR-0009, ADR-0010): it owns geometry, domain, and identity, while the Host owns all hover/hint/selection feedback and every dialog — reading `view.highlightGroupsByObjectId` to outline the panel itself, and rendering the pressure readout from the VM.
6
+
7
+ The canonical host-facing surface is the **`PneumaticPanelFacade`** — a single typed object (conforming to the structural `SmartComponent` contract) that drives an instance through commands, typed events, lifecycle, and persisted state. The flat controllers and PM adapter remain available as the underlying mechanism the facade delegates to (and which the Babylon view boundary still calls directly).
8
+
9
+ - **Package**: @vived/component-pneumatic-panel
10
+ - **Version**: 3.0.0
11
+ - **Interface version**: 1
12
+ - **GitHub**: vivedlearning/component-pneumatic-panel
13
+
14
+ ## Discovery
15
+
16
+ - **Category**: Industrial Equipment / Pneumatics
17
+ - **Tags**: pneumatic, panel, exhaust, valve, lock, lockout, tagout, pressure, gauge, PSI, industrial controls, safety training, assessment interaction, babylon, smart component
18
+ - **Visual description**: A compact pneumatic panel assembly with a rotatable exhaust knob, lock body, ghost-lock preview mesh, and circular pressure gauge. The lock and knob support click interactions, and visual guidance can be toggled for training or assessment.
19
+ - **Multi-instance**: Yes
20
+
21
+ ## Quick Start
22
+
23
+ 1. **Install** — package and peer dependencies.
24
+
25
+ ```bash
26
+ npm install @vived/component-pneumatic-panel @vived/core @babylonjs/core @vived/app
27
+ ```
28
+
29
+ 2. **Register the feature factory** — wire the domain layer into your app.
30
+
31
+ ```typescript
32
+ import { makeAppObjectRepo, makeDomainFactoryRepo } from "@vived/core";
33
+ import { makePneumaticPanelFeatureFactory } from "@vived/component-pneumatic-panel";
34
+
35
+ const appObjects = makeAppObjectRepo();
36
+ const factoryRepo = makeDomainFactoryRepo(appObjects);
37
+
38
+ makePneumaticPanelFeatureFactory(appObjects);
39
+ factoryRepo.setupDomain();
40
+ ```
41
+
42
+ 3. **Create an instance and load the 3D model** — use the facade, the canonical host-facing surface.
43
+
44
+ ```typescript
45
+ import { PneumaticPanelFacade } from "@vived/component-pneumatic-panel";
46
+
47
+ const panel = new PneumaticPanelFacade("panel-1", appObjects); // creates the domain stack
48
+ await panel.load(); // attaches and loads the Babylon view
49
+ ```
50
+
51
+ 4. **Basic interaction** — drive the instance through facade methods and typed events.
52
+
53
+ ```typescript
54
+ panel.toggleExhaust();
55
+ panel.toggleLock();
56
+ panel.checkPressure();
57
+
58
+ const unsubscribe = panel.onEvent("exhaustOpened", () => {
59
+ console.log("exhaust opened");
60
+ });
61
+ ```
62
+
63
+ > Need the raw `AppObject` (e.g. to parent the root node or register shadow casters)? Use the
64
+ > `createBabylonPneumaticPanel` composition entry point instead — it creates the same domain
65
+ > stack and view and returns the `AppObject`. See the mounting recipe below.
66
+
67
+ ## Facade (host-facing surface)
68
+
69
+ The **`PneumaticPanelFacade`** is the one surface a host uses to drive an instance — commands, events, reactive view model, lifecycle, and persisted state through a single typed object. The flat controllers and PM adapter are the underlying mechanism it delegates to (documented under Internal / Advanced); host code should not call them directly.
70
+
71
+ ### Structural contract (Contract v1)
72
+
73
+ Every VIVED facade implements the same eight mandatory members with this exact shape, so a host can hold mixed component types behind one uniform `SmartComponent` reference. This facade's **`interfaceVersion` is `1`**.
74
+
75
+ | Member | Signature | Notes |
76
+ | --- | --- | --- |
77
+ | `id` | `readonly string` | The instance identifier (matches the AppObject id). |
78
+ | `interfaceVersion` | `readonly number` | The `SmartComponent` contract version this facade implements (`1`). |
79
+ | `onEvent` | `<K extends keyof PneumaticPanelEvents>(event: K, cb: PneumaticPanelEvents[K]) => () => void` | Subscribe to a typed event. Returns an unsubscribe function — there is no separate `off`. |
80
+ | `onViewModel` | `(cb: (vm: PneumaticPanelVM) => void) => () => void` | Subscribe to the reactive VM; emits the current VM immediately, then on every change. Returns an unsubscribe function. |
81
+ | `load` | `(variant?: string) => Promise<void>` | The only async member; the only place view/asset work happens. |
82
+ | `destroy` | `() => void` | Tear down the component and release all resources. |
83
+ | `getState` | `() => PneumaticPanelState` | Versioned snapshot of authored configuration. |
84
+ | `applyState` | `(state: PneumaticPanelState) => void` | Restore authored configuration from a snapshot. |
85
+
86
+ ### Lifecycle (two-phase)
87
+
88
+ - **Construction is synchronous and idempotent.** `new PneumaticPanelFacade(id, appObjects)` builds the entire domain stack (Entity, Use Cases, PM, adapter) with no Babylon dependency — it is headless-testable. It uses `getOrCreate` semantics: constructing a facade for an existing `id` returns a handle to the same instance, never a duplicate.
89
+ - **`load(variant?)` is the only async phase** and the only step that touches the Babylon view / GLB asset.
90
+ - **Everything else works before `load()`.** All command methods, `onEvent`, `onViewModel`, `getState`, and `applyState` are fully functional the moment the facade is constructed — `load()` is required only for the 3D model to render.
91
+
92
+ ### Command methods
93
+
94
+ Return-value convention: a command a domain rule can **block** returns `boolean` (`true` = took effect, `false` = blocked); a fire-and-forget command returns `void`. Commands never return domain data — read state via `getState()` or `onViewModel`.
95
+
96
+ | Command | Signature | Description |
97
+ | --- | --- | --- |
98
+ | `toggleExhaust` | `() => boolean` | Toggle exhaust open/closed. `false` if blocked by lock state. |
99
+ | `toggleLock` | `() => boolean` | Toggle lock. Can only engage when exhaust is open; `false` if exhaust is closed. Always succeeds when unlocking. |
100
+ | `checkPressure` | `() => void` | Register a pressure check. Fires `pressureChecked`; renders nothing — the Host shows any dialog, reading `pressure` from the VM (ADR-0010). |
101
+ | `setShowGhostLockHint` | `(show: boolean) => void` | Enable/disable always-visible ghost-lock hint behavior. |
102
+ | `enableHitbox` | `() => void` | Enable whole-panel selection mode. Sub-component clicks are disabled while active. |
103
+ | `disableHitbox` | `() => void` | Disable whole-panel selection mode. Restore sub-component interactivity. |
104
+
105
+ ### Event catalog (`PneumaticPanelEvents`)
106
+
107
+ All event callbacks are payload-free `() => void`; the host reacts by reading `getState()` or the VM. `onEvent` returns an unsubscribe function.
108
+
109
+ | Event | Fires when |
110
+ | --- | --- |
111
+ | `exhaustOpened` | exhaust valve transitioned to open |
112
+ | `exhaustClosed` | exhaust valve transitioned to closed |
113
+ | `exhaustCloseBlockedByLock` | a close attempt was blocked because the panel is locked |
114
+ | `panelLocked` | panel transitioned to locked |
115
+ | `panelUnlocked` | panel transitioned to unlocked |
116
+ | `panelSelected` | the whole-panel hitbox was clicked |
117
+ | `pressureChecked` | a pressure check was triggered |
118
+
119
+ ### Reactive view model (`PneumaticPanelVM`)
120
+
121
+ The live render picture streamed by `onViewModel`. **Distinct from the state snapshot** — the VM includes transient/derived fields (live `pressure`, derived `subComponentsInteractive`) and must not be merged with `PneumaticPanelState`.
122
+
123
+ | Field | Type | Notes |
124
+ | --- | --- | --- |
125
+ | `exhaustOpen` | `boolean` | |
126
+ | `isLocked` | `boolean` | |
127
+ | `pressure` | `number` | Live value — derived from exhaust state (0 open, 60 closed). |
128
+ | `showGhostLockHint` | `boolean` | |
129
+ | `panelHitboxEnabled` | `boolean` | |
130
+ | `subComponentsInteractive` | `boolean` | Derived: always `!panelHitboxEnabled`. |
131
+
132
+ ### State snapshot (`PneumaticPanelState`)
133
+
134
+ The versioned, persistable subset of **authored configuration** captured by `getState()` and restored by `applyState()`. Excludes transient/derived fields that live only in the VM (e.g. `pressure`). Current `version` is **`2`**.
135
+
136
+ | Field | Type |
137
+ | --- | --- |
138
+ | `version` | `number` (currently `2`) |
139
+ | `exhaustOpen` | `boolean` |
140
+ | `isLocked` | `boolean` |
141
+ | `showGhostLockHint` | `boolean` |
142
+ | `panelHitboxEnabled` | `boolean` |
143
+
144
+ `applyState` is **best-effort forward-compatible** (ADR-0008): it applies the fields present and falls back to entity defaults for absent ones; it never throws on version mismatch. Older v1 snapshots (which carried a since-removed `highlightedObject` field) still restore cleanly — the unknown field is ignored.
145
+
146
+ ### Variants and the objectId interaction map
147
+
148
+ `load(variant?)` accepts an optional variant string; an omitted or unrecognized variant falls back to the default asset (best-effort — no variants are implemented yet). The real contract between the domain and a GLB is the **objectId interaction map** — the named interactive nodes the view binds to — so any asset honoring this map can serve as a variant:
149
+
150
+ | objectId | Role |
151
+ | --- | --- |
152
+ | `exhaust_knob` | Click to toggle exhaust; rotates with exhaust state. |
153
+ | `lock` | Lock transform and child meshes; click to toggle lock state. |
154
+ | `ghost_lock` | Hover-reveal lock helper and click target for lock action. |
155
+ | `gauge` | Click to invoke pressure check. |
156
+ | `panel_hitbox` | Invisible full-panel bounding mesh; active only when `panelHitboxEnabled` is true. Keyed in `highlightGroupsByObjectId` to every visible panel mesh. |
157
+
158
+ ## API Reference
159
+
160
+ ### Public API (use these)
161
+
162
+ The **`PneumaticPanelFacade`** is THE host-facing surface for driving an instance. Its constructor synchronously creates the full domain stack (Entity, Use Cases, PM, adapter); `load()` attaches the Babylon view. For scenarios that need the raw `AppObject`, `createBabylonPneumaticPanel` is the equivalent composition entry point. The controllers and PM adapter below are the underlying mechanism the facade delegates to, and remain exported for direct use (the framework/view boundary calls them directly).
163
+
164
+ #### `PneumaticPanelFacade` (primary)
165
+
166
+ **The facade is THE way to drive an instance — see the [Facade](#facade-host-facing-surface) section above for the full host-facing surface:** the eight-member Contract v1, two-phase lifecycle, command methods, event catalog, reactive VM, state snapshot, and variants, all with inline signatures. `makePneumaticPanelFeatureFactory` (below) is the only other Public API entry point — call it once at startup before `setupDomain()`.
167
+
168
+ #### Composition entry point & controllers
169
+
170
+ | Function | Signature | Description |
171
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
172
+ | createBabylonPneumaticPanel | (id: string, appObjects: AppObjectRepo) => Promise\<AppObject \| undefined\> | Composition entry point. Creates the full domain stack and Babylon view, loads the asset, and returns the AppObject. |
173
+ | makePneumaticPanelFeatureFactory | (appObjects: AppObjectRepo) => PneumaticPanelFeatureFactory | Registers the feature factory. Call once at app startup before `setupDomain()`. |
174
+ | tryToggleExhaust | (id: string, appObjects: AppObjectRepo) => boolean | Toggles exhaust open/closed. Returns false if blocked by lock state. |
175
+ | tryToggleLock | (id: string, appObjects: AppObjectRepo) => boolean | Toggles lock state. Can only engage when exhaust is open; returns false if exhaust is closed. Always succeeds when unlocking. |
176
+ | checkPressure | (id: string, appObjects: AppObjectRepo) => void | Registers a pressure check and notifies `pressureChecked` subscribers. Renders nothing. |
177
+ | setShowGhostLockHint | (id: string, appObjects: AppObjectRepo, on: boolean) => void | Enables/disables always-visible ghost-lock hint behavior. |
178
+ | enablePanelHitbox | (id: string, appObjects: AppObjectRepo) => void | Enables whole-panel selection mode. Sub-component clicks are disabled while active. |
179
+ | disablePanelHitbox | (id: string, appObjects: AppObjectRepo) => void | Disables whole-panel selection mode. Restores sub-component interactivity. |
180
+ | getPanelState | (id: string, appObjects: AppObjectRepo, version: number) => PneumaticPanelState | Returns a versioned snapshot of panel state. (Facade: `getState()`.) |
181
+ | applyPanelState | (id: string, appObjects: AppObjectRepo, state: PneumaticPanelState) => void | Restores panel state from a snapshot. (Facade: `applyState()`.) |
182
+ | onExhaustOpened | (id: string, appObjects: AppObjectRepo, callback: () => void) => () => void | Subscribes to exhaust-opened transitions. Returns an unsubscribe function. |
183
+ | onExhaustClosed | (id: string, appObjects: AppObjectRepo, callback: () => void) => () => void | Subscribes to exhaust-closed transitions. Returns an unsubscribe function. |
184
+ | onExhaustCloseBlockedByLock | (id: string, appObjects: AppObjectRepo, callback: () => void) => () => void | Subscribes to blocked exhaust-close attempts (exhaust is locked). Returns an unsubscribe function. |
185
+ | onPanelLocked | (id: string, appObjects: AppObjectRepo, callback: () => void) => () => void | Subscribes to panel-locked transitions. Returns an unsubscribe function. |
186
+ | onPanelUnlocked | (id: string, appObjects: AppObjectRepo, callback: () => void) => () => void | Subscribes to panel-unlocked transitions. Returns an unsubscribe function. |
187
+ | onPanelSelected | (id: string, appObjects: AppObjectRepo, callback: () => void) => () => void | Subscribes to whole-panel hitbox clicks. Returns an unsubscribe function. |
188
+ | onCheckPressure | (id: string, appObjects: AppObjectRepo, callback: () => void) => () => void | Subscribes to pressure-check requests (gauge click). Returns an unsubscribe function. |
189
+
190
+ > **Reactive view model:** subscribe via the facade's `onViewModel(cb)` — the
191
+ > canonical host-facing surface for VM updates. The raw `pneumaticPanelPMAdapter`
192
+ > export still exists for the framework/view boundary and advanced use, but is no
193
+ > longer the advertised consumer path (see Internal / Advanced).
194
+
195
+ ### Accessors (read after creation)
196
+
197
+ Use these to read state or retrieve the view from an already-created instance.
198
+
199
+ | Accessor | Returns | Description |
200
+ | ------------------------------------- | -------------------------------------- | ------------------------------------------------------------- |
201
+ | PneumaticPanelBabylonView.get(appObj) | PneumaticPanelBabylonView \| undefined | Retrieve the Babylon view from an AppObject after creation. |
202
+ | view.rootTransformNode | TransformNode \| undefined | Root transform for positioning/parenting in scene. |
203
+ | view.shadowCasters | AbstractMesh[] | Meshes suitable for shadow registration. |
204
+ | view.nodesByObjectId | ReadonlyMap\<string, Node\> | Scene-node lookup keyed by lowercased glTF objectId metadata. |
205
+ | view.highlightGroupsByObjectId | ReadonlyMap\<string, AbstractMesh[]\> | Highlight groups keyed by semantic objectId. `panel_hitbox` maps to every visible panel mesh (so the Host can outline the whole panel when the invisible hitbox is the pointer target); other objectIds map to a one-element array. Populated after `load()`. |
206
+
207
+ ### Internal / Advanced (do not call directly)
208
+
209
+ > **Warning:** These are exported for extensibility and testing only. They are created automatically by `PneumaticPanelFacade` / `createBabylonPneumaticPanel`. Calling them directly without first creating the full domain stack will produce a partially-initialized object whose behavior silently does not work.
210
+
211
+ | Export | Description |
212
+ | --------------------------------- | -------------------------------------------------------------- |
213
+ | pneumaticPanelPMAdapter | Raw PM adapter (`subscribe`/`unsubscribe`). Used by the framework/view boundary; prefer the facade's `onViewModel(cb)` for host code. |
214
+ | makePneumaticPanelSelectUC | Creates panel-select UC only. No entity or view. |
215
+ | makePneumaticPanelBabylonView | Creates the Babylon view only — no Entity/UC/PM. Testing only. |
216
+ | makePneumaticPanelEntity | Creates entity only. No UCs, PM, or view. |
217
+ | makePneumaticPanelRepo | Creates the entity repo singleton. |
218
+ | makePneumaticPanelPM | Creates PM only. No entity or UCs. |
219
+ | makePneumaticPanelToggleExhaustUC | Creates exhaust UC only. No entity or view. |
220
+ | makePneumaticPanelToggleLockUC | Creates lock UC only. No entity or view. |
221
+ | makePneumaticPanelCheckPressureUC | Creates pressure-check UC only. No entity or view. |
222
+ | panelHitboxClicked | Internal boundary controller — called by the Babylon view on hitbox pick. |
223
+ | MockPneumaticPanelPM | Mock PM for consumer integration tests. |
224
+ | MockPneumaticPanelToggleExhaustUC | Mock exhaust UC for consumer tests. |
225
+ | MockPneumaticPanelToggleLockUC | Mock lock UC for consumer tests. |
226
+ | MockPneumaticPanelCheckPressureUC | Mock pressure-check UC for consumer tests. |
227
+ | MockPneumaticPanelSelectUC | Mock panel-select UC for consumer tests. |
228
+ | componentConfig | Metadata constant (name, version, asset IDs). |
229
+
230
+ ### Types
231
+
232
+ ```typescript
233
+ // SmartComponent structural contract the facade conforms to (Contract v1, per ADR-0006).
234
+ // Eight mandatory members, shape-identical across every VIVED smart component.
235
+ interface SmartComponent {
236
+ readonly id: string;
237
+ readonly interfaceVersion: number;
238
+ onEvent(event: string, cb: (...args: never[]) => void): () => void;
239
+ onViewModel(cb: (vm: PneumaticPanelVM) => void): () => void;
240
+ load(variant?: string): Promise<void>;
241
+ destroy(): void;
242
+ getState(): PneumaticPanelState;
243
+ applyState(state: PneumaticPanelState): void;
244
+ }
245
+
246
+ // Typed event catalog for the facade's onEvent(...) method
247
+ type PneumaticPanelEvents = {
248
+ exhaustOpened: () => void;
249
+ exhaustClosed: () => void;
250
+ exhaustCloseBlockedByLock: () => void;
251
+ panelLocked: () => void;
252
+ panelUnlocked: () => void;
253
+ panelSelected: () => void;
254
+ pressureChecked: () => void;
255
+ };
256
+
257
+ // Versioned, persistable panel state snapshot.
258
+ // Version 2 (per ADR-0009) removed the `highlightedObject` field; older v1
259
+ // snapshots still restore cleanly via ADR-0008 best-effort apply (unknown
260
+ // fields ignored, absent fields defaulted).
261
+ const PNEUMATIC_PANEL_STATE_VERSION = 2;
262
+
263
+ type PneumaticPanelState = {
264
+ version: number;
265
+ exhaustOpen: boolean;
266
+ isLocked: boolean;
267
+ showGhostLockHint: boolean;
268
+ panelHitboxEnabled: boolean;
269
+ };
270
+
271
+ interface PneumaticPanelVM {
272
+ exhaustOpen: boolean;
273
+ isLocked: boolean;
274
+ pressure: number;
275
+ showGhostLockHint: boolean;
276
+ panelHitboxEnabled: boolean;
277
+ subComponentsInteractive: boolean; // derived: always !panelHitboxEnabled
278
+ }
279
+ ```
280
+
281
+ > **Note:** `PneumaticPanelState` does not include `pressure` — pressure is derived
282
+ > automatically from exhaust state on restore (0 when open, max when closed).
283
+
284
+ ## 3D View
285
+
286
+ ### Asset
287
+
288
+ | Name | Asset ID | File |
289
+ | ------- | ------------------------------------ | ------------------ |
290
+ | default | db90a1dd-8cb9-4f56-954d-7a72610281a7 | pneumaticPanel.glb |
291
+
292
+ **The published package ships no 3D assets.** The GLB is fetched at load time by asset id through `getAssetBlobURL`, so `dist/` contains code only. The `pneumaticPanel.glb` and `studio.env` files in the repo's `public/` directory exist for the dev playground and are deliberately excluded from the build (`publicDir: false` in `vite.config.ts`). A host does not need to serve or vendor either file.
293
+
294
+ ### Exposed Transform Nodes
295
+
296
+ The facade does not expose scene nodes directly. To position, parent, or register shadow casters, retrieve the view from the AppObject (the facade's `id` matches the AppObject id):
297
+
298
+ ```typescript
299
+ import {
300
+ PneumaticPanelFacade,
301
+ PneumaticPanelBabylonView,
302
+ } from "@vived/component-pneumatic-panel";
303
+
304
+ const panel = new PneumaticPanelFacade("panel-1", appObjects);
305
+ await panel.load();
306
+
307
+ const appObject = appObjects.get(panel.id);
308
+ const view = appObject ? PneumaticPanelBabylonView.get(appObject) : undefined;
309
+ if (view?.rootTransformNode) {
310
+ view.rootTransformNode.position.set(1, 0, -2);
311
+ }
312
+ ```
313
+
314
+ ### Interactive objectId mapping
315
+
316
+ The GLB includes these objectId metadata values:
317
+
318
+ | objectId | Role |
319
+ | ------------ | ------------------------------------------------------------------------------------------------- |
320
+ | exhaust_knob | Click to toggle exhaust; rotates with exhaust state. |
321
+ | lock | Lock transform and child meshes; click to toggle lock state. |
322
+ | ghost_lock | Hover-reveal lock helper and click target for lock action. |
323
+ | gauge | Click to invoke pressure check. |
324
+ | panel_hitbox | Invisible full-panel bounding mesh; active only when `panelHitboxEnabled` is true. |
325
+
326
+ ## Recipes
327
+
328
+ ### Mounting / parenting into a host scene
329
+
330
+ The facade drives behavior but does not expose scene nodes. When you need the root transform
331
+ node (to parent the panel into a host scene) or shadow casters, use the
332
+ `createBabylonPneumaticPanel` composition entry point, which returns the `AppObject`:
333
+
334
+ ```typescript
335
+ import {
336
+ createBabylonPneumaticPanel,
337
+ PneumaticPanelBabylonView,
338
+ } from "@vived/component-pneumatic-panel";
339
+
340
+ const appObject = await createBabylonPneumaticPanel("panel-1", appObjects);
341
+ if (appObject) {
342
+ const view = PneumaticPanelBabylonView.get(appObject);
343
+ if (view?.rootTransformNode) {
344
+ // Parent to an existing scene node (e.g. a workbench or equipment mount)
345
+ view.rootTransformNode.parent = parentTransformNode;
346
+ view.rootTransformNode.position.set(0, 1.2, 0);
347
+ }
348
+
349
+ // Register shadow casters with a ShadowGenerator
350
+ for (const mesh of view?.shadowCasters ?? []) {
351
+ shadowGenerator.addShadowCaster(mesh);
352
+ }
353
+ }
354
+ ```
355
+
356
+ ### Snapshot and restore panel state
357
+
358
+ ```typescript
359
+ import { PneumaticPanelFacade } from "@vived/component-pneumatic-panel";
360
+
361
+ const panel = new PneumaticPanelFacade("panel-1", appObjects);
362
+ await panel.load();
363
+
364
+ // Capture a versioned snapshot (e.g. to persist learner progress)
365
+ const snapshot = panel.getState();
366
+ localStorage.setItem("panel-1-state", JSON.stringify(snapshot));
367
+
368
+ // Restore later — pressure is recomputed from exhaust state automatically
369
+ const saved = JSON.parse(localStorage.getItem("panel-1-state")!);
370
+ panel.applyState(saved);
371
+ ```
372
+
373
+ ### Training vs assessment lock hint mode
374
+
375
+ ```typescript
376
+ // Training mode: ghost lock is visible when eligible.
377
+ panel.setShowGhostLockHint(true);
378
+
379
+ // Assessment mode: ghost lock is invisible unless the Host reveals it.
380
+ panel.setShowGhostLockHint(false);
381
+ ```
382
+
383
+ `showGhostLockHint` is authored domain state — the Activity deciding whether this
384
+ learner is shown where the lock goes. It is not hover state: with the hint off the
385
+ ghost lock stays enabled and pickable but invisible, and the Host reveals it on
386
+ hover if it wants to (ADR-0010). A Host that wires nothing shows no reveal.
387
+
388
+ ### Host-owned highlighting (outline the panel from the hitbox)
389
+
390
+ The component is presentation-free for interaction state (ADR-0009): it no longer
391
+ renders its own hover outline or hint glow. Instead the Host reads
392
+ `view.highlightGroupsByObjectId` and drives its own highlight system. The
393
+ `panel_hitbox` entry maps to every visible panel mesh, so the Host can outline the
394
+ whole visible panel even though the pointer target is the invisible hitbox.
395
+
396
+ ```typescript
397
+ const appObject = appObjects.get(panel.id);
398
+ const view = appObject ? PneumaticPanelBabylonView.get(appObject) : undefined;
399
+
400
+ // Outline the whole panel when the invisible hitbox is hovered/selected.
401
+ const panelMeshes = view?.highlightGroupsByObjectId.get("panel_hitbox") ?? [];
402
+ for (const mesh of panelMeshes) {
403
+ hostHighlightManager.add(mesh);
404
+ }
405
+
406
+ // Every other objectId is registered as a single-mesh group, so the same map is
407
+ // how the Host reaches the ghost lock to reveal it on hover (ADR-0010).
408
+ const [ghostLock] = view?.highlightGroupsByObjectId.get("ghost_lock") ?? [];
409
+ if (ghostLock) {
410
+ ghostLock.visibility = isHoveringGhostLock ? 1 : 0;
411
+ }
412
+ ```
413
+
414
+ ### Subscribe to events (facade)
415
+
416
+ ```typescript
417
+ import { PneumaticPanelFacade } from "@vived/component-pneumatic-panel";
418
+
419
+ const panel = new PneumaticPanelFacade("panel-1", appObjects);
420
+ await panel.load();
421
+
422
+ const unsubscribeOpened = panel.onEvent("exhaustOpened", () => {
423
+ console.log("exhaust opened");
424
+ });
425
+ const unsubscribeLocked = panel.onEvent("panelLocked", () => {
426
+ console.log("panel locked");
427
+ });
428
+ // The component renders no dialog (ADR-0010). Track the VM, then present the
429
+ // value however the Host wants. There is no getter — `onViewModel` emits the
430
+ // current VM immediately on subscribe and on every change.
431
+ let latestVM: PneumaticPanelVM | undefined;
432
+ const unsubscribeVM = panel.onViewModel((vm) => {
433
+ latestVM = vm;
434
+ });
435
+
436
+ const unsubscribePressure = panel.onEvent("pressureChecked", () => {
437
+ hostShowAlertUC.showAlert({
438
+ title: "Pneumatic Panel Pressure",
439
+ message: `${latestVM?.pressure ?? 0} PSI`,
440
+ closeButtonLabel: "OK",
441
+ closeCallback: () => {},
442
+ });
443
+ });
444
+
445
+ // Later — clean up subscriptions
446
+ unsubscribeOpened();
447
+ unsubscribeLocked();
448
+ unsubscribePressure();
449
+ unsubscribeVM();
450
+ ```
451
+
452
+ ### Subscribe to VM state updates (facade)
453
+
454
+ Use the facade's `onViewModel` — it emits the current VM immediately, then on every
455
+ change, and returns an unsubscribe function. This is the canonical reactive surface
456
+ (it wraps `pneumaticPanelPMAdapter`, which remains available for the view boundary /
457
+ advanced use).
458
+
459
+ ```typescript
460
+ import {
461
+ PneumaticPanelFacade,
462
+ type PneumaticPanelVM,
463
+ } from "@vived/component-pneumatic-panel";
464
+
465
+ const panel = new PneumaticPanelFacade("panel-1", appObjects);
466
+ await panel.load();
467
+
468
+ const unsubscribe = panel.onViewModel((vm: PneumaticPanelVM) => {
469
+ console.log(vm.exhaustOpen, vm.isLocked, vm.pressure, vm.panelHitboxEnabled);
470
+ });
471
+
472
+ // Later
473
+ unsubscribe();
474
+ ```
475
+
476
+ In React, `onViewModel` returns the unsubscribe directly, so `useEffect` consumes it:
477
+
478
+ ```tsx
479
+ useEffect(() => panel.onViewModel(setVm), [panel]);
480
+ ```
481
+
482
+ ### Whole-panel selection mode
483
+
484
+ Enable the panel hitbox to treat the whole panel as one clickable target (e.g. for a "select a panel" interaction in an activity). Hover feedback is the Host's responsibility (ADR-0009) — the component emits only the `panelSelected` click event.
485
+
486
+ ```typescript
487
+ import { PneumaticPanelFacade } from "@vived/component-pneumatic-panel";
488
+
489
+ const panel = new PneumaticPanelFacade("panel-1", appObjects);
490
+ await panel.load();
491
+
492
+ // Enable — panel becomes a single click target; sub-components deactivate
493
+ panel.enableHitbox();
494
+
495
+ const unsubscribe = panel.onEvent("panelSelected", () => {
496
+ console.log("panel was selected");
497
+ // Restore sub-component interactions when done
498
+ panel.disableHitbox();
499
+ unsubscribe();
500
+ });
501
+ ```
502
+
503
+ ### Creating multiple instances
504
+
505
+ ```typescript
506
+ import { PneumaticPanelFacade } from "@vived/component-pneumatic-panel";
507
+
508
+ const panel1 = new PneumaticPanelFacade("panel-1", appObjects);
509
+ const panel2 = new PneumaticPanelFacade("panel-2", appObjects);
510
+ await Promise.all([panel1.load(), panel2.load()]);
511
+ ```
512
+
513
+ ## Common Mistakes
514
+
515
+ ### Calling `makePneumaticPanelBabylonView` directly
516
+
517
+ ❌ **Wrong** — creates the view only, with no Entity, Use Cases, or PM. Click interactions silently do nothing.
518
+
519
+ ```typescript
520
+ import { makePneumaticPanelBabylonView } from "@vived/component-pneumatic-panel";
521
+
522
+ const appObject = appObjects.getOrCreate("panel-1");
523
+ const view = makePneumaticPanelBabylonView(appObject);
524
+ await view.load(); // Renders, but toggling exhaust/lock silently fails
525
+ ```
526
+
527
+ ✅ **Correct** — use the `PneumaticPanelFacade` (or `createBabylonPneumaticPanel`), which create the full domain stack AND the view.
528
+
529
+ ```typescript
530
+ import { PneumaticPanelFacade } from "@vived/component-pneumatic-panel";
531
+
532
+ const panel = new PneumaticPanelFacade("panel-1", appObjects);
533
+ await panel.load();
534
+ ```
535
+
536
+ ### Guessing an AppObject ID to grab an existing instance
537
+
538
+ ❌ **Wrong** — constructing IDs by naming convention to retrieve a pre-existing AppObject skips the factory and results in a missing domain stack.
539
+
540
+ ```typescript
541
+ const appObject = appObjects.get("panel-1");
542
+ const view = PneumaticPanelBabylonView.get(appObject!); // may be undefined or partial
543
+ ```
544
+
545
+ ✅ **Correct** — always create instances through the facade or `createBabylonPneumaticPanel`. After creation, the facade's `id` (or the returned AppObject) is the supported handle.
546
+
547
+ ### Reaching into view internals instead of using accessors
548
+
549
+ ❌ **Wrong** — accessing private meshes or nodes directly from sub-views.
550
+
551
+ ✅ **Correct** — use `PneumaticPanelBabylonView.get(appObject)` to access the public view surface (`rootTransformNode`, `shadowCasters`, `nodesByObjectId`, `highlightGroupsByObjectId`).
552
+
553
+ ## Constraints & Defaults
554
+
555
+ ### Default state
556
+
557
+ | Property | Default |
558
+ | ----------------- | ------- |
559
+ | exhaustOpen | false |
560
+ | isLocked | false |
561
+ | pressure | 60 |
562
+ | showGhostLockHint | false |
563
+ | panelHitboxEnabled | false |
564
+
565
+ ### Automatic behaviors
566
+
567
+ - **Pressure management**: Pressure is automatically set to 0 when exhaust opens and restored to max pressure (60) when exhaust closes. Developers do not need to manage pressure values manually — and `PneumaticPanelState` therefore omits `pressure`, recomputing it from exhaust state on restore.
568
+ - **Lock constraint**: The lock can only be engaged when the exhaust is open (lockout/tagout safety). Attempting to lock with exhaust closed returns false.
569
+ - **Exhaust constraint**: The exhaust cannot be toggled while locked. Attempting to toggle returns false.
570
+ - **Ghost lock visibility**: In hint mode (`showGhostLockHint: true`), the ghost lock mesh is visible whenever eligible (exhaust open, not locked). With the hint off it is enabled and pickable but invisible — the component detects no hover and reveals nothing (ADR-0010).
571
+ - **Panel hitbox / sub-component interactivity**: When `panelHitboxEnabled` is true, sub-component clicks (exhaust, lock, gauge) are disabled and the whole panel acts as a single target. `subComponentsInteractive` in the VM is always derived as `!panelHitboxEnabled`.
572
+ - **Presentation-free interaction state (ADR-0009, ADR-0010)**: The component does not render hover outlines, hint glows, selection feedback, or dialogs, and detects no hover. The Host owns all of it, reading `view.highlightGroupsByObjectId` to map semantic objectIds to meshes.
573
+
574
+ ### Known limitations
575
+
576
+ - `checkPressure` displays nothing. A Host that does not subscribe to `pressureChecked` gives the learner no feedback when the gauge is clicked.
577
+ - The ghost lock does not reveal on hover. A Host that does not drive `ghost_lock` visibility gives the learner no hint affordance in non-hint mode.
578
+ - The GLB asset must include `objectId` metadata on the expected nodes (`exhaust_knob`, `lock`, `ghost_lock`, `gauge`, `panel_hitbox`) for click interactions and highlight grouping to work.
579
+ - `PneumaticPanelState` is versioned (`PNEUMATIC_PANEL_STATE_VERSION = 2`). Snapshots are restored best-effort (ADR-0008): unknown fields are ignored and absent fields default, so older v1 snapshots (which carried `highlightedObject`) still apply cleanly.
580
+
581
+ ## Repository
582
+
583
+ | Field | Value |
584
+ | -------------- | --------------------------------------- |
585
+ | Package | @vived/component-pneumatic-panel |
586
+ | GitHub | vivedlearning/component-pneumatic-panel |
587
+ | Version | 3.0.0 |
588
+ | Interface version | 1 |
589
+ | State schema version | 2 |
590
+ | Multi-instance | Yes |
591
+
592
+ ### Full export list
593
+
594
+ #### Public API (use these)
595
+
596
+ - `PneumaticPanelFacade` — **primary host-facing surface** (class; drives commands, events, state, lifecycle)
597
+ - `createBabylonPneumaticPanel` — composition entry point (full domain + view; returns AppObject)
598
+ - `makePneumaticPanelFeatureFactory` — factory registration
599
+ - `tryToggleExhaust` — controller
600
+ - `tryToggleLock` — controller
601
+ - `checkPressure` — controller
602
+ - `setShowGhostLockHint` — controller
603
+ - `enablePanelHitbox` — controller
604
+ - `disablePanelHitbox` — controller
605
+ - `getPanelState` — state snapshot controller (facade: `getState()`)
606
+ - `applyPanelState` — state restore controller (facade: `applyState()`)
607
+ - `onExhaustOpened` — event subscription controller
608
+ - `onExhaustClosed` — event subscription controller
609
+ - `onExhaustCloseBlockedByLock` — event subscription controller
610
+ - `onPanelLocked` — event subscription controller
611
+ - `onPanelUnlocked` — event subscription controller
612
+ - `onPanelSelected` — event subscription controller
613
+ - `onCheckPressure` — event subscription controller
614
+ - `PNEUMATIC_PANEL_STATE_VERSION` — state version constant
615
+
616
+ #### Accessors (read after creation)
617
+
618
+ - `PneumaticPanelBabylonView` (class with static `.get()`) — retrieve the view from an AppObject
619
+ - `PneumaticPanelVM` (type) — view model interface
620
+ - `PneumaticPanelState` (type) — versioned state snapshot interface
621
+ - `SmartComponent` (type) — structural facade contract
622
+ - `PneumaticPanelEvents` (type) — facade event catalog
623
+
624
+ #### Internal / Advanced (extensibility/testing only — not for normal use)
625
+
626
+ - `PneumaticPanelEntity`, `makePneumaticPanelEntity`
627
+ - `PneumaticPanelRepo`, `makePneumaticPanelRepo`, `PneumaticPanelEntityFactory` (type)
628
+ - `PneumaticPanelToggleExhaustUC`, `makePneumaticPanelToggleExhaustUC`
629
+ - `PneumaticPanelToggleLockUC`, `makePneumaticPanelToggleLockUC`
630
+ - `PneumaticPanelCheckPressureUC`, `makePneumaticPanelCheckPressureUC`
631
+ - `PneumaticPanelSelectUC`, `makePneumaticPanelSelectUC`
632
+ - `PneumaticPanelPM`, `makePneumaticPanelPM`
633
+ - `PneumaticPanelFeatureFactory` (class)
634
+ - `makePneumaticPanelBabylonView` — creates view only, no domain stack
635
+ - `pneumaticPanelPMAdapter` — raw PM adapter; used by the view boundary. Host code should prefer the facade's `onViewModel(cb)`.
636
+ - `panelHitboxClicked` — internal boundary controller (called by Babylon view on hitbox pick)
637
+ - `MockPneumaticPanelPM` — mock for consumer tests
638
+ - `MockPneumaticPanelToggleExhaustUC` — mock for consumer tests
639
+ - `MockPneumaticPanelToggleLockUC` — mock for consumer tests
640
+ - `MockPneumaticPanelCheckPressureUC` — mock for consumer tests
641
+ - `MockPneumaticPanelSelectUC` — mock for consumer tests
642
+ - `componentConfig` — metadata constant
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ Copyright © 2026 VIVED Learning. All rights reserved.
2
+
3
+ This software and its associated documentation (the "Software") are the
4
+ proprietary and confidential property of VIVED Learning. The Software is
5
+ intended for internal use with VIVED Learning's player and infrastructure only.
6
+
7
+ No license, right, or permission is granted to any party to use, copy, modify,
8
+ merge, publish, distribute, sublicense, or sell the Software, in whole or in
9
+ part, except as expressly authorized in writing by VIVED Learning. Publication
10
+ of this package to a public registry does not constitute such authorization or
11
+ grant any license to it.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.