← All Pull Requests

Expose simulated RPM controls for component motion states #1

Open opened by John Lauer 2026-10-08
Merges fan-motion-controls → master · view live diff →

Fan component states need a continuous speed variable alongside off/on. Adds optional simulated RPM controls for the proposed adom/component-motion@1 extension. The widget emits statechange with state, variables.speed, source and validity for compatible viewer consumers; it does not create a WebGL context or pretend a static thumbnail is animated. Legacy LED events stay exactly {state} and legacy state files need no migration.

Documents per-instance rotor/animation bindings, GLB hashes, RPM-to-cycle mapping, static fallback, invalid telemetry, explicit slow-motion labeling and lifecycle requirements. Shared-schema status is explicitly proposed, not deployed. Reference implementation and fan-specific skill: https://wiki.adom.inc/adom/mf40200v1-1000u-g99

Base c787f0944f60e4733b8cb059855cd8e00c6ead1e. Four focused files. Preflight passed; syntax checked with node --check. Live Pup test: on emitted 7000 RPM, Apply emitted 3000 RPM, off emitted 0, all simulated; off input disabled; widget created zero canvas elements. Legacy LED fixture emitted exactly {state:"on"}, no RPM control. Actual canonical-viewer adapter separately verified rotating rotor with stationary housing and recorded off/3000/7000/off. No registry/publication/installed-widget claim. Maintainer should review schema, merge, release and verify the registered widget delivery.

Diff Skip to comments

SKILL.md+4
@@ -224,3 +224,7 @@ After activation, verify off, offscreen, hidden-tab, disconnection and cancelled Fit iframe height from intrinsic widget content, coalesce callbacks and only emit changed heights. Never repeatedly add padding to documentElement.scrollHeight: the iframe viewport can become its own ever-growing input. Test within a parent that really applies readme-height, including repeated status updates and scrolling.  For every widget, record cold-load and scroll-only network/DOM/context evidence before clicking, then an activation and teardown cycle. Same-browser siblings can dominate the page's memory even when this widget reports zero. Source edits, shared package publication, registry script delivery, and already-open browser tabs are distinct deployment states.++## Motion states for fans and other moving components++Read [component motion](https://wiki.adom.inc/adom/component-widgets/files/docs/component-motion.md) for the proposed additive `adom/component-motion@1` binding within `adom/component-states@1`. Keep static image/model fallbacks. The states widget exposes simulated RPM controls and emits `statechange` with state, variables.speed, source and validity; it does not itself load or animate a GLB. A compatible existing Adom 3D viewer binds that event to the component-instance rotor animation. No WebGL is created by scrolling the widget. Never represent simulated RPM as measured, silently discard unsupported motion, or send visualization controls to hardware.
docs/component-motion.mdadded+19
@@ -0,0 +1,19 @@+# Component motion extension (proposal v1)++This is a tested fan prototype and proposed additive extension to `adom/component-states@1`, not a claim that all installed wiki or probing viewers support motion. Existing LED state files remain valid. Static STEP geometry is retained for mechanical use. Continuous animation and playback controls live in GLB and the runtime.++A manifest may add `variables` and a top-level `motion` object with schema `adom/component-motion@1`. Keep ordinary `states[].models` and `image` entries for old consumers. Off should link a static GLB; on can link the animated GLB. Unsupported consumers display static fallback and explicitly report motion unsupported. Never infer motor speed from supply voltage or an on command.++The motion object names the model and SHA-256, required capability, unique assembly/housing/rotor node names, and bindings. Each binding specifies a unique animation name, target node name, local axis and pivot in metres, variable and unit, cycle duration in seconds, revolutions per cycle, and `mapping: rpm-to-cycle-rate`. These are data fields, never executable formulas. Resolve nodes/groups within the imported component instance, not across the whole scene; repeated fan instances must not drive each other. Reject missing or ambiguous names, wrong hashes, empty animations, and channels outside the declared rotor subtree. Keep reference overlays and stationary housing outside the rotating branch.++For this fan, `Fan.Rotor.Revolution` contains one negative rotation about local Z in one second. Five normalized quaternion keyframes at 0, 0.25, 0.5, 0.75 and 1 seconds avoid interpolating an identical start/end quaternion as no motion. glTF LINEAR rotation interpolation uses spherical interpolation. The rotor parent is at the shaft axis; asset units are metres and the mechanical source is Z-up. Viewer unit/axis conversions belong on the assembly root, never on individual blades. Direction and blade shape remain illustrative until physical comparison.++Playback rate = RPM / 60 × cycleDurationSeconds / revolutionsPerCycle. A one-second revolution at 6000 RPM has a rate of 100. A labeled 0.02× slow-motion preview uses 2 while still reporting the supplied 6000 RPM. Never silently cap RPM to make a prettier animation. At high RPM, display sampling can cause apparent reversal; the numeric RPM and source are authoritative for the visualization. This does not validate physical cooling.++State controls supply `{state, variables:{speed}, source, valid}`. Source is `simulated` or `measured`; default is simulated. Real telemetry requires an explicit valid flag and a caller-enforced freshness window. Invalid/stale measurements pause motion and display UNKNOWN, not 0 RPM or verified OFF. `off` requires speed 0 and pauses at the current phase. `on` with speed 0 can represent a stalled rotor; animation is stopped without claiming the supply is off. Start/stop inertia and airflow are not modeled. Reset is explicit, not a side effect of every state update.++The reference adapter `states/controller.mjs` uses the existing Adom Babylon viewer's imported animation group. It has no device I/O. Applying visualization state must never arm hardware, close a relay or set a DAC. Pause rendering when hidden, stop/dispose on teardown, and respect reduced-motion preferences. Downloading the manifest or scrolling a wiki page must not create a WebGL context.++Acceptance: validate GLB node/animation bindings and hash; check stationary transforms while the rotor moves; test 0/3000/7000 RPM and invalid input; compare separate instances; verify off freezes phase; label slow motion and simulation; inspect top/oblique views; record a short action clip. Physical tach testing is a separate hardware test.++Reference: [Khronos glTF animation sampler schema](https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/animation.sampler.schema.json).
skills/component-widgets/SKILL.md+4
@@ -224,3 +224,7 @@ After activation, verify off, offscreen, hidden-tab, disconnection and cancelled Fit iframe height from intrinsic widget content, coalesce callbacks and only emit changed heights. Never repeatedly add padding to documentElement.scrollHeight: the iframe viewport can become its own ever-growing input. Test within a parent that really applies readme-height, including repeated status updates and scrolling.  For every widget, record cold-load and scroll-only network/DOM/context evidence before clicking, then an activation and teardown cycle. Same-browser siblings can dominate the page's memory even when this widget reports zero. Source edits, shared package publication, registry script delivery, and already-open browser tabs are distinct deployment states.++## Motion states for fans and other moving components++Read [component motion](https://wiki.adom.inc/adom/component-widgets/files/docs/component-motion.md) for the proposed additive `adom/component-motion@1` binding within `adom/component-states@1`. Keep static image/model fallbacks. The states widget exposes simulated RPM controls and emits `statechange` with state, variables.speed, source and validity; it does not itself load or animate a GLB. A compatible existing Adom 3D viewer binds that event to the component-instance rotor animation. No WebGL is created by scrolling the widget. Never represent simulated RPM as measured, silently discard unsupported motion, or send visualization controls to hardware.
widgets/states-widget.js+20−2
@@ -19,7 +19,7 @@  */ (() => {   const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));-  const KIND = { led: 'LED', relay: 'Relay', switch: 'Switch', display: 'Display', lamp: 'Lamp' };+  const KIND = { led: 'LED', relay: 'Relay', switch: 'Switch', display: 'Display', lamp: 'Lamp', fan: 'Fan' };    class AdomStates extends HTMLElement {     async connectedCallback() {@@ -38,11 +38,24 @@       this._cur = this.getAttribute('state') || this._d.default || this._d.states?.[0]?.id;       this._render();       this.shadowRoot.addEventListener('click', e => {+        if(e.target.closest('button[data-apply]')) {+          const input=this.shadowRoot.querySelector('[data-speed]');+          if(input&&!input.reportValidity())return;+          const detail=this._stateDetail();if(!Number.isFinite(detail.variables.speed))return;+          this.dispatchEvent(new CustomEvent('statechange',{detail,bubbles:true,composed:true}));return;+        }         const b = e.target.closest('button[data-s]'); if (!b) return;         this._cur = b.dataset.s; this._render();-        this.dispatchEvent(new CustomEvent('statechange', { detail: { state: this._cur }, bubbles: true, composed: true }));+        this.dispatchEvent(new CustomEvent('statechange', { detail: this._stateDetail(), bubbles: true, composed: true }));       });     }+    _stateDetail() {+      if(this._d.motion?.schema!=='adom/component-motion@1')return {state:this._cur};+      const st=(this._d.states||[]).find(s=>s.id===this._cur)||{};+      const variable=this._d.variables?.speed;+      const speed=this._cur==='off'?0:Number(this.shadowRoot.querySelector('[data-speed]')?.value ?? st.values?.speed ?? variable?.default ?? 0);+      return {state:this._cur,variables:{speed},source:'simulated',valid:true,motionSchema:this._d.motion?.schema||null};+    }     _abs(p) { return p ? new URL(p, this._base).href : ''; }     _dl(p) { return this._api ? `${this._api}/files/${p.split('/').map(encodeURIComponent).join('/')}` : this._abs(p); }     _render() {@@ -52,12 +65,16 @@       const img = st.image ? `<img src="${esc(this._abs(st.image))}" alt="${esc(d.mpn)} ${esc(st.label || st.id)}" loading="lazy">` : `<div class="nothumb">no image for this state</div>`;       const files = Object.entries(st.models || {}).map(([fmt, p]) => `<a class="file" href="${esc(this._dl(p))}" download="${esc(p.split('/').pop())}"><span class="chip fmt ${esc(fmt)}">${esc(fmt.toUpperCase())}</span><code>${esc(p.split('/').pop())}</code></a>`).join('');       const e = d.electrical || {};+      const motion=d.motion?.schema==='adom/component-motion@1'?d.motion:null;+      const speed=d.variables?.speed;+      const motionUi=motion&&speed&&Number.isFinite(speed.min)&&Number.isFinite(speed.max)?`<div class="motion"><b>Motion · simulated controls</b><label>Speed (RPM) <input data-speed type="number" min="${esc(speed.min)}" max="${esc(speed.max)}" value="${esc(st.values?.speed??speed.default??0)}" ${st.id==='off'?'disabled':''}></label><button data-apply>Apply speed</button><p>Animation: <code>${esc(motion.bindings?.[0]?.animation)}</code>. These controls emit statechange for a compatible viewer; this image is static and does not show measured RPM.</p></div>`:'';       const elec = [e.vf_V != null && `Vf ${e.vf_V} V`, e.if_max_mA != null && `If max ${e.if_max_mA} mA`, e.lambda_d_nm != null && `λd ${e.lambda_d_nm} nm`].filter(Boolean).join(' · ');       this._paint(`<div class="h">States<span class="part">${esc(d.mpn || '')} · ${esc(KIND[d.kind] || d.kind || 'component')}</span></div>         <div class="sub">One 3D model per state, so a viewer or a simulation shows the part as it looks ${esc(d.kind === 'led' ? 'off and lit' : 'in each state')}.</div>         <div class="tabs" role="group" aria-label="State">${tabs}</div>         <div class="stage${glow ? ' lit' : ''}" style="${glow ? `--glow:${esc(glow)}` : ''}">${img}<span class="vlabel">${esc(st.label || st.id)}${st.id === d.default ? ' · default' : ''}</span></div>         <div class="body">+          ${motionUi}           ${st.summary ? `<div class="sum">${esc(st.summary)}</div>` : ''}           ${st.when?.text ? `<div class="when"><b>When:</b> ${esc(st.when.text)}</div>` : ''}           ${files ? `<div class="files">${files}</div>` : ''}@@ -88,6 +105,7 @@       code{font-family:var(--mono);font-size:12px;color:var(--body)}       .chip{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid var(--line);color:var(--body)}       .chip.fmt.step{color:var(--accent);border-color:var(--accent)}.chip.fmt.glb{color:var(--blue);border-color:var(--blue)}+      .motion{border:1px solid var(--line);padding:10px;border-radius:8px}.motion label{display:block;margin:8px 0}.motion input{width:100px}.motion p{font-size:12px;color:var(--dim)}       .meta{font-size:12px;color:var(--dim)}       a{color:var(--accent)}`;     }

Comments

No comments yet.

Log in to comment.