dsh-motion-ui 0.6.0 → 0.7.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/CHANGELOG.md +88 -0
- package/README.md +55 -1
- package/VERIFICATION.md +46 -0
- package/client.js +2136 -1095
- package/examples/mechanism-assembly.json +50 -0
- package/examples/mechanism-crank.json +103 -0
- package/examples/mechanism-gears.json +103 -0
- package/index.js +132 -106
- package/lib/assets/standalone-runtime.js +3 -1
- package/lib/assets/visual.js +3 -1
- package/lib/author-expression.js +9 -8
- package/lib/authoring-guide.js +3 -16
- package/lib/build-info.js +1 -1
- package/lib/canvas-design.js +2 -2
- package/lib/canvas-system.js +1 -1
- package/lib/catalog.js +5 -6
- package/lib/components.js +2 -2
- package/lib/delivery.js +1 -1
- package/lib/export-runtime-bundle.js +1 -1
- package/lib/expressive-examples.js +6 -0
- package/lib/fence-feedback.js +8 -2
- package/lib/freeform.js +4 -9
- package/lib/inspection.js +11 -6
- package/lib/mechanism-catalog.js +24 -0
- package/lib/mechanism-css.js +13 -0
- package/lib/mechanism-entry.js +20 -0
- package/lib/mechanism-geometry.js +62 -0
- package/lib/mechanism-render.js +24 -0
- package/lib/mechanism-runtime-bundle.js +2 -0
- package/lib/mechanism-scenes.js +20 -0
- package/lib/motion-library.js +17 -0
- package/lib/normalize-authoring.js +15 -2
- package/lib/prompt-contract.js +22 -0
- package/lib/protocol.js +26 -11
- package/lib/refinement.js +3 -2
- package/lib/sandbox.js +2 -0
- package/lib/scene-entry.js +41 -0
- package/lib/style-system.js +28 -0
- package/lib/text-guide.js +9 -13
- package/lib/third-party/anatomy-LICENSE.txt +21 -0
- package/lib/third-party/anatomy-NOTICE.md +13 -0
- package/lib/third-party/anatomy-iso.mjs +674 -0
- package/lib/third-party/lucian-uxui-LICENSE.txt +21 -0
- package/lib/third-party/lucian-uxui-NOTICE.md +1 -0
- package/lib/third-party/taste-skill-NOTICE.md +10 -0
- package/lib/tool-schema.js +9 -9
- package/lib/ui-intent.js +4 -15
- package/lib/ui-skill.js +19 -25
- package/lib/visual-audit.js +15 -4
- package/lib/visual-styles.js +134 -22
- package/package.json +92 -92
- package/skills/intelligent-ui/SKILL.md +22 -102
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Original Motion UI documents applying the reviewed Lucian interaction patterns.
|
|
2
|
+
export const EXPRESSIVE_EXAMPLES=[
|
|
3
|
+
{id:'action',name:'渐变磁吸按钮',description:'实色主按钮、渐变扩散与文字换位;实际打开网页',document:{version:1,title:'继续探索',fallback:'点击按钮打开 GitHub 文档。',presentation:{preset:'brief'},nodes:[{id:'heading',type:'heading',text:'从一个可验证的结果开始',size:24},{id:'text',type:'text',text:'所有风格下,悬停或键盘聚焦可体验渐变扩散;点击实际打开网页。'},{id:'open',type:'link',text:'查看开发指南',href:'https://docs.github.com/',variant:'primary',icon:'arrow'}]}},
|
|
4
|
+
{id:'quantity',name:'数量步进与滚动',description:'原生数字输入与增减按钮,共享精确计算',document:{version:1,title:'数量与费用 · 示例',fallback:'示例单价19元,按数量计算。',presentation:{preset:'brief'},state:{quantity:{type:'number',default:2,min:1,max:10,step:1}},derived:{total:{op:'mul',args:[{const:19},{ref:'state.quantity'}]}},nodes:[{id:'quantity',type:'input',label:'数量',bind:'state.quantity'},{id:'sum',type:'metric',label:'示例总价',value:{ref:'derived.total'},unit:'元',decimals:0},{id:'note',type:'text',text:'示例价格,不是真实报价。所有风格均提供增减按钮与滚动强调。'}]}},
|
|
5
|
+
{id:'elastic',name:'弹性开关联动',description:'点击或拖拽,真实状态驱动形变与费用变化',document:{version:1,title:'配送费用 · 示例',fallback:'基础费用29元,加急费用5元。',presentation:{preset:'brief'},state:{urgent:{type:'boolean',default:false}},derived:{fee:{op:'add',args:[{const:29},{op:'if',args:[{ref:'state.urgent'},{const:5},{const:0}]}]}},nodes:[{id:'urgent',type:'toggle',label:'加急配送',bind:'state.urgent'},{id:'fee',type:'metric',label:'示例合计',value:{ref:'derived.fee'},unit:'元',decimals:0},{id:'note',type:'text',text:'仅是费用计算示例,不会创建订单或请求配送。'}]}}
|
|
6
|
+
];
|
package/lib/fence-feedback.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export const REPAIR_GUIDANCE=Object.freeze({
|
|
2
|
+
empty:'Complete the user task with a visible answer. If UI helps, actually render it or write a complete source fence; otherwise answer in plain text. Do not end with only a promise. This notice does not require UI or limit reasoning/output.',
|
|
3
|
+
tool:'Motion UI delivery is incomplete. Correct the failed tool arguments and resubmit the complete document, or give a useful text fallback. Do not repeat successful UIs. Consult ui_catalog/validate_ui only for unknown syntax. Do not finish with a promise; this notice does not limit reasoning/output.',
|
|
4
|
+
fence:'Correct only the failed source fence, preserving its intended content. Do not duplicate successful UIs or render an already successful fence again. Consult ui_catalog/validate_ui for unknown syntax; explain unresolved defects with useful text. This notice does not limit reasoning/output.'
|
|
5
|
+
});
|
|
1
6
|
import {randomUUID,createHash} from 'node:crypto';
|
|
2
7
|
import {extractUiFences} from './fences.js';
|
|
3
8
|
import {resolveSourceFence} from './delivery.js';
|
|
@@ -22,7 +27,7 @@ export function registerFenceFeedback(ctx,enabled=()=>true){
|
|
|
22
27
|
if(event.type==='assistant/message'){state.lastText=textOf(event.data.message?.content).slice(0,262144);state.blocks.set(event.data.step??0,state.lastText)}
|
|
23
28
|
if(event.type==='tool/call')state.seenToolCall=true;
|
|
24
29
|
if(event.type==='user/message'&&event.data.source?.kind==='user'){state.request=textOf(event.data.content).slice(0,8192);state.silentRequested=/(?:不用|不必|无需|不要)回复|do not (?:respond|reply)|no (?:response|reply) (?:needed|required)/i.test(state.request)}
|
|
25
|
-
if(event.type==='tool/call'&&['render_text','render_ui','render_canvas','update_ui','patch_ui'].includes(event.data.name))state.calls.set(event.data.callId,event.data.name);
|
|
30
|
+
if(event.type==='tool/call'&&['render_text','render_ui','render_canvas','render_mechanism','render_scene','update_ui','patch_ui'].includes(event.data.name))state.calls.set(event.data.callId,event.data.name);
|
|
26
31
|
if(event.type==='tool/result'){
|
|
27
32
|
const message=event.data.message,name=state.calls.get(message?.toolCallId);
|
|
28
33
|
if(name){if(message.isError){const diagnostic=name+': '+textOf(message.content).slice(0,1200);state.toolFailures.set(fingerprint(diagnostic),diagnostic)}else state.delivered=true}
|
|
@@ -53,7 +58,8 @@ export function registerFenceFeedback(ctx,enabled=()=>true){
|
|
|
53
58
|
if(!state.silentRequested&&!state.delivered&&isUiDeliveryAnnouncement(visible,state.request)&&!state.corrected.has(promiseKey))failures.push({key:promiseKey,empty:true,diagnostic:'The visible answer only announces a UI, but no UI was delivered. Complete the promised work instead of ending with a delivery announcement.'});
|
|
54
59
|
if(!failures.length)return;
|
|
55
60
|
const chosen=failures.slice(0,4);state.count++;chosen.forEach(f=>state.corrected.add(f.key));
|
|
56
|
-
const
|
|
61
|
+
const kind=chosen.some(f=>f.empty)?'empty':chosen.some(f=>f.tool)?'tool':'fence';
|
|
62
|
+
const text=chosen.map(f=>'[motion-repair:'+f.key+'] '+f.diagnostic).join('\n')+'\n'+REPAIR_GUIDANCE[kind];
|
|
57
63
|
const source=(agent.session.header?.version??4)>=4?{kind:'plugin:motion-ui',form:'notice',summary:'Motion UI delivery repair'}:{kind:'plugin',plugin:'motion-ui',form:'notice',summary:'Motion UI delivery repair'};
|
|
58
64
|
const id=randomUUID();state.notices.add(id);
|
|
59
65
|
try{agent.steer({id,role:'user',content:[{type:'text',text}],source})}catch(e){ctx.logger?.warn?.('Motion UI correction could not be queued: '+e.message)}
|
package/lib/freeform.js
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import { toolJson } from './tool-json.js';
|
|
2
2
|
import { validateDocument } from './protocol.js';
|
|
3
|
-
import {
|
|
4
|
-
export function canvasDocument({title,fallback,html,css,script,height,state,data,derived}){
|
|
3
|
+
import {MODEL_CONTRACT} from './prompt-contract.js';
|
|
4
|
+
export function canvasDocument({title,fallback,html,css,script,height,libraries,state,data,derived}){
|
|
5
5
|
const values={state:toolJson(state??{},'state'),data:toolJson(data??{},'data'),derived:toolJson(derived??{},'derived')};
|
|
6
6
|
for(const [key,value] of Object.entries(values))if(!value||typeof value!=='object'||Array.isArray(value))throw new Error('DSH UI: '+key+' must be an object');
|
|
7
|
-
return validateDocument({version:1,title,fallback,...values,nodes:[{id:'canvas',type:'sandbox',title,fallback,html,...(css===undefined?{}:{css}),...(script===undefined?{}:{script}),...(height===undefined?{}:{height}),stateKeys:Object.keys(values.state),dataKeys:Object.keys(values.data),derivedKeys:Object.keys(values.derived)}]});
|
|
7
|
+
return validateDocument({version:1,title,fallback,...values,nodes:[{id:'canvas',type:'sandbox',title,fallback,html,...(css===undefined?{}:{css}),...(script===undefined?{}:{script}),...(height===undefined?{}:{height}),...(libraries===undefined?{}:{libraries}),stateKeys:Object.keys(values.state),dataKeys:Object.keys(values.data),derivedKeys:Object.keys(values.derived)}]});
|
|
8
8
|
}
|
|
9
|
-
export const FREE_UI_CONTRACT=
|
|
10
|
-
Call render_canvas({title,fallback,html,css?,script?,height?,state?,data?,derived?}) directly, not inside PTC/run_code. A stable inert HTML/SVG preview appears progressively during generation; after the whole assistant turn ends, parameter restoration enables controls. Prefer title/fallback/css before html for early styled previews. Do not claim interaction is active merely because the tool succeeded. Source field order does not affect validation. Optional host .ui-* primitives exist but no composition is required. html is a body fragment supporting HTML/SVG; put all CSS in css and JavaScript in script, use addEventListener rather than HTML event attributes. Each source field max 49152 characters, whole document max128KiB, height160..1000. Code runs only after commit and parameter restoration. No external resources, imports, eval or host APIs; the isolated frame is not a CPU/memory hard limit. The existing frame is updated with inspect_ui/patch_ui, never arbitrary host access.
|
|
11
|
-
The user-facing content and all controls may be authored inside your canvas. State important to the user belongs in state, e.g. {speed:{type:"number",default:1,min:0.5,max:3,step:0.5},running:{type:"boolean",default:false}}. Strings require type:"string",default; options:[string,...] is optional. Structured lists/objects use type:"json",default:[]/{} (16KiB per value). Bind custom controls through DSHUI.setState({speed:2}); use DSHUI.subscribe(snapshot=>...) to draw snapshot.state/data/derived. Do not write state from each animation frame or echo subscribe updates back into setState. State updates are validated and persisted. Reload may reset transient animation phase.
|
|
12
|
-
Prefer DSHUI.animate(({time,delta,reducedMotion,visible})=>...) for motion; seconds, delta capped0.05, returns unsubscribe. Integrate delta only while running; reducedMotion needs a meaningful static view. Stop/replacement disposes the frame. DSHUI.formatNumber(value, decimals=2) formats finite numbers with 0..6 decimal places and comma grouping, exactly matching native metrics and fallback; use it for shared readouts instead of mixing toFixed rounding. Theme is in snapshot.theme and CSS variables --canvas-bg/soft/ink/muted/line/accent. Use readable text and named keyboard-operable controls, adapt to narrow and wide widths; ensure information remains available in a useful fallback. Be truthful about data, assumptions and simulation accuracy.
|
|
13
|
-
If native controls or mixed component layouts would help, render_ui and ui_catalog remain available; query the catalog for their exact contract when needed. They are optional, not the default layout.
|
|
14
|
-
inspect_ui defaults to a compact summary; detail:"nodes",nodeIds:["canvas"] reads canvas source. patch_ui requires documentId,current documentRevision,changes:[{id:"canvas",props:{html/css/script/...}}]. It preserves declared state and other nodes; replacing source restarts that canvas. Observations are heuristic and scoped to recorded viewport/theme/revision, not a beauty or safety score. Empty reports mean unobserved, do not poll or invent success. Limit automatic correction to two rounds per user request, then simplify or explain unresolved problems. Explicit new user edits start a new request. This is a behavior budget, not a provider inference timeout.`;
|
|
9
|
+
export const FREE_UI_CONTRACT='No design template, visual style, palette, typography scale, composition or example is prescribed in free mode. Choose them yourself; native components and canvas helpers are optional interfaces.\n\n'+MODEL_CONTRACT;
|
package/lib/inspection.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
+
import {componentFields} from './components.js';
|
|
1
2
|
// Keep the default observation small; request exact node source only when editing it.
|
|
2
|
-
export function inspectRecord(record,state,derived,audits,{detail='summary',nodeIds=[]}={}){
|
|
3
|
+
export function inspectRecord(record,state,derived,audits,{detail='summary',nodeIds=[],query=''}={}){
|
|
3
4
|
if(!['summary','nodes','full'].includes(detail))throw new Error('DSH UI: invalid inspection detail');
|
|
4
|
-
|
|
5
|
+
if(typeof query!=='string'||query.length>200)throw new Error('DSH UI: inspection query must be text of at most200 characters');
|
|
6
|
+
const locations=new Map();const flat=(list,parentId=null,path='nodes')=>list.flatMap((n,index)=>{locations.set(n.id,{path:path+'['+index+']',parentId,position:index+1});return[n,...flat(n.children??[],n.id,path+'['+index+'].children')]});const nodes=flat(record.document.nodes);
|
|
7
|
+
const matches=query.trim()?nodes.filter(n=>[n.id,n.type,n.title,n.label,n.text,n.code,n.type==='visual'?JSON.stringify(n.spec):''].some(v=>typeof v==='string'&&v.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()))):nodes;
|
|
5
8
|
if(!Array.isArray(nodeIds)||nodeIds.length>16||nodeIds.some(id=>typeof id!=='string'||!nodes.some(n=>n.id===id)))throw new Error('DSH UI: nodeIds requires up to 16 existing IDs');
|
|
6
9
|
const inferred=detail==='nodes'&&!nodeIds.length;
|
|
7
|
-
if(
|
|
10
|
+
if(query.trim()&&nodeIds.length)throw new Error('DSH UI: choose query OR nodeIds, not both');
|
|
11
|
+
if(inferred)nodeIds=matches.slice(0,16).map(n=>n.id);
|
|
8
12
|
const bounded=value=>Buffer.byteLength(JSON.stringify(value??{}))<=8192?value:{omitted:true,reason:'Value exceeds 8KiB summary budget; request full inspection.',keys:Object.keys(value??{})};
|
|
9
|
-
const result={documentId:record.documentId,documentRevision:record.documentRevision??0,title:record.document.title,state:detail==='full'?state:bounded(state),derived:detail==='full'?derived:bounded(derived),audits,detail};
|
|
13
|
+
const result={documentId:record.documentId,documentRevision:record.documentRevision??0,title:record.document.title,state:detail==='full'?state:bounded(state),derived:detail==='full'?derived:bounded(derived),audits,detail,visualCheck:{status:audits.length?(audits.some(a=>a.issues.length)?'issues-observed':'no-issues-in-covered-checks'):'unobserved',observedViews:audits.length,issueCount:audits.reduce((n,a)=>n+a.issues.length,0),note:'Heuristic client observations only; absence is not a pass. Targets are scoped to this UI/canvas, not the host page.'}};
|
|
14
|
+
if(query.trim())result.search={query:query.trim(),matched:matches.length,returned:Math.min(matches.length,16),targets:matches.slice(0,16).map(n=>({id:n.id,type:n.type,...locations.get(n.id)}))};
|
|
10
15
|
if(detail==='full')result.document=record.document;
|
|
11
|
-
else if(detail==='nodes'){result.nodes=nodes.filter(n=>nodeIds.includes(n.id));if(inferred&&
|
|
12
|
-
else result.nodes=nodes.map(n=>({id:n.id,type:n.type,...Object.fromEntries(['title','label','text','bind'].filter(k=>typeof n[k]==='string').map(k=>[k,n[k].slice(0,160)])),...(n.type==='sandbox'?{sourceCharacters:{html:n.html.length,css:n.css?.length??0,script:n.script?.length??0}}:{})}));
|
|
16
|
+
else if(detail==='nodes'){result.nodes=nodes.filter(n=>nodeIds.includes(n.id));if(inferred&&matches.length>16)result.selection={returned:16,total:matches.length,remainingIds:matches.slice(16).map(n=>n.id),hint:'Pass nodeIds to inspect additional nodes.'};}
|
|
17
|
+
else result.nodes=(query.trim()?matches.slice(0,16):nodes).map(n=>({id:n.id,type:n.type,...locations.get(n.id),editableProps:componentFields(n.type).filter(k=>!['id','type','children','bind','action'].includes(k)),...Object.fromEntries(['title','label','text','bind'].filter(k=>typeof n[k]==='string').map(k=>[k,n[k].slice(0,160)])),...(n.type==='sandbox'?{sourceCharacters:{html:n.html.length,css:n.css?.length??0,script:n.script?.length??0}}:{})}));
|
|
13
18
|
return result;
|
|
14
19
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {assemblyScene,gearsScene,crankScene} from './mechanism-scenes.js';
|
|
2
|
+
import {sceneSvg} from './mechanism-render.js';
|
|
3
|
+
import {MECHANISM_CSS} from './mechanism-css.js';
|
|
4
|
+
import {validateDocument} from './protocol.js';
|
|
5
|
+
export const MECHANISM_GUIDE=`Mechanism illustrations: when internal structure, assembly order, power transfer or rotary-to-linear motion helps explain the question, prefer render_mechanism({scene:"assembly"/"gears"/"crank",rpm?:20,teeth?:32,radius?:20,running?:false,angle?:0,explode?:0,title?:string}) for the complete working diagram. No source code or catalog lookup is necessary for these three scenes. Use the optional local mechanism library for custom structures. ui_catalog({topic:"mechanism",scene:"assembly"/"gears"/"crank"}) returns a compact render_mechanism call; customize its initial parameters and explanation without copying source. Do not search installation source, reproduce a huge SVG engine, require dozens of parts or animate unrelated decoration. libraries:["mechanism"] enables DSHUI.mechanism inside a sandbox only. Keep informative inert HTML/SVG for previews, controls bound to declared state, and all continuous motion on DSHUI.animate. A moving picture must express a real relation: external gear RPM is negative inputRPM * driverTeeth / drivenTeeth; slider-crank keeps rod length constant. Explain units and limits; these are educational shapes and kinematics, not validated engineering drawings or force/contact/collision simulations.`;
|
|
6
|
+
export const MECHANISM_SCENES=[{id:'assembly',title:'分层装配与部件职责',description:'22个部件、按组选择、连续拆解、隐藏外壳。'},{id:'gears',title:'齿轮传动与速比',description:'改变从动齿数、输入转速,观察反向转动与精确速比。'},{id:'crank',title:'曲柄连杆与往复运动',description:'改变曲柄半径和角度,连杆长度保持不变,观察滑块位置与行程。'}];
|
|
7
|
+
const n=(value,min,max,step=1)=>({type:'number',default:value,min,max,step});
|
|
8
|
+
export function mechanismExample(name){
|
|
9
|
+
if(!MECHANISM_SCENES.some(s=>s.id===name))throw Error('Motion mechanism: scene must be assembly/gears/crank');
|
|
10
|
+
const scene=({assembly:assemblyScene,gears:gearsScene,crank:crankScene})[name]();
|
|
11
|
+
const state={explode:n(name==='assembly'?.45:0,0,1,.01),selected:{type:'string',default:'all'},cutaway:{type:'boolean',default:name==='gears'}};
|
|
12
|
+
const bind={explode:'explode',selected:'selected',cutaway:'cutaway'},nodes=[];
|
|
13
|
+
if(name!=='assembly'){
|
|
14
|
+
Object.assign(state,{rpm:n(20,0,90),running:{type:'boolean',default:false},angle:n(0,0,360)});Object.assign(bind,{rpm:'rpm',running:'running',angle:'angle'});
|
|
15
|
+
nodes.push({id:'rpm',type:'slider',label:'输入转速(rpm)',bind:'state.rpm'},{id:'angle',type:'slider',label:'手动角度(°,暂停时调节)',bind:'state.angle'},{id:'running',type:'toggle',label:'运行机械示意',bind:'state.running'});
|
|
16
|
+
}
|
|
17
|
+
if(name==='gears'){state.teeth=n(32,16,48);bind.teeth='teeth';nodes.unshift({id:'teeth',type:'slider',label:'从动轮齿数(主动轮16齿)',bind:'state.teeth'})}
|
|
18
|
+
if(name==='crank'){state.radius=n(20,10,28);bind.radius='radius';nodes.unshift({id:'radius',type:'slider',label:'曲柄半径(示意长度单位)',bind:'state.radius'})}
|
|
19
|
+
const fallback=name==='assembly'?'底座支撑功能板,处理模块上方为散热层,外壳保护内部。拆解表示装配关系,不表示受力运动。':name==='gears'?'外啮合齿轮反向转动。主动轮16齿、20 rpm,从动轮默认32齿、-10 rpm,中心距由模数和齿数确定。齿形为简化示意,不用于加工。':'旋转曲柄带动定长连杆,滑块沿导轨往复。默认半径20、杆长70,完整行程40,位置由几何约束计算。非动力学仿真。';
|
|
20
|
+
nodes.unshift({id:'mechanism',type:'sandbox',title:scene.title,fallback,height:560,libraries:['mechanism'],stateKeys:Object.keys(state),html:'<div id="diagram"><section class="mui-mechanism">'+sceneSvg(scene,{explode:state.explode.default,cutaway:state.cutaway.default,preview:true})+'<p>'+fallback+'</p></section></div>',css:MECHANISM_CSS,script:'const diagram=DSHUI.mechanism.mountDemo("#diagram",'+JSON.stringify(name)+',{title:"",bind:'+JSON.stringify(bind)+'});addEventListener("pagehide",()=>diagram.destroy(),{once:true});'});
|
|
21
|
+
nodes.push({id:'accuracy',type:'text',text:fallback});
|
|
22
|
+
return validateDocument({version:1,title:scene.title,fallback,state,nodes});
|
|
23
|
+
}
|
|
24
|
+
export function mechanismCatalog(scene){return{topic:'mechanism',guide:MECHANISM_GUIDE,scenes:MECHANISM_SCENES,...(scene?(MECHANISM_SCENES.some(s=>s.id===scene)?{tool:'render_mechanism',args:{scene},example:'For a custom scene enable libraries:["mechanism"] and call DSHUI.mechanism.mount/mountDemo in script; full standalone documents are shipped in examples/mechanism-*.json.'}:(()=>{throw Error('Motion mechanism: scene must be assembly/gears/crank')})()):{}),api:{enable:'sandbox.libraries:["mechanism"] or render_canvas libraries:["mechanism"]',mountDemo:'DSHUI.mechanism.mountDemo(target,"assembly"|"gears"|"crank",{bind:{explode,selected,cutaway,rpm,teeth,running,angle,radius},driverTeeth?:16,module?:1.5,rod?:70}) — bind values are declared state key names; omit unused bindings. Returns update/pose/stats/destroy.',mount:'DSHUI.mechanism.mount(target,{title?,parts:[...],groups?:[{id,label,description?}],bounds?:[minXYZ,maxXYZ],azimuth?,elevation?,bind?:{explode,selected,cutaway},onSelect?}) returns update({explode:0..1,selected:"all"|partId|groupId,cutaway:boolean,pose:{partId:{at?,rotation?,from?,to?}},readout?}), pose(), stats(), destroy(). Rotations in radians; bounds include full exploded positions.',parts:'1..96 unique identifiers. box:{id,type:"box",size:[x,y,z],at?,round?}; cylinder:{id,type:"cylinder",radius,height?,inner?,axis?:"x"|"y"|"z",at?}; gear:{id,type:"gear",radius,teeth:6..64,module?,height?,inner?,at?}; rod/spring:{id,type:"rod"|"spring",from:[x,y,z],to:[x,y,z],radius?,turns?}. All accept label?,description?,group?,material?:metal/dark/accent/warm/cool,explode?:[x,y,z],cover?:boolean,rotation?:radians.',math:'gearPair({driverTeeth,drivenTeeth,rpm,angle,module}) returns driverAngle/drivenAngle/driverRpm/drivenRpm/ratio/radii/centerDistance; sliderCrank({angle,radius,rod,z?}) returns crank/piston/position/stroke. follow(value,target,delta,tau?) returns exponential easing. Use DSHUI.animate for custom movement; do not write shared state per frame.',limits:'No external resources or extra permissions. 48Ki characters per model source; 128KiB document. Helpers inject only for opted-in frames. Six guided styles propagate from host; free mode has neutral helper defaults and allows custom CSS. Cover hide is an illustrative cutaway, not a solid clipping plane. Face depth sorting is approximate; not an engineering collision or occlusion audit.'}}}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const MECHANISM_CSS=`
|
|
2
|
+
.mui-mechanism{font:15px/1.7 system-ui,sans-serif;color:var(--canvas-ink);background:var(--canvas-bg);border:1px solid var(--canvas-line);border-radius:24px;padding:20px;max-width:100%;box-sizing:border-box}.mui-mechanism *{box-sizing:border-box}.mui-mechanism h2{font-size:22px;line-height:1.35;margin:0 0 8px;font-weight:750}.mui-mechanism p{margin:0;color:var(--canvas-muted)}
|
|
3
|
+
.mui-mechanism-svg{display:block;width:100%;height:auto;max-height:420px;touch-action:pan-y}.mui-mechanism-svg path{stroke:color-mix(in srgb,var(--canvas-ink) 38%,var(--canvas-bg));stroke-width:.8;stroke-linejoin:round;vector-effect:non-scaling-stroke;cursor:pointer;fill:var(--mech-base,var(--canvas-soft))}.mui-mechanism-svg [data-material=metal]{--mech-base:color-mix(in srgb,var(--canvas-muted) 32%,var(--canvas-bg))}.mui-mechanism-svg [data-material=dark]{--mech-base:color-mix(in srgb,var(--canvas-ink) 78%,var(--canvas-bg))}.mui-mechanism-svg [data-material=accent]{--mech-base:var(--canvas-accent)}.mui-mechanism-svg [data-material=cool]{--mech-base:color-mix(in srgb,#22998a 70%,var(--canvas-bg))}.mui-mechanism-svg [data-material=warm]{--mech-base:color-mix(in srgb,#dd9c42 75%,var(--canvas-bg))}
|
|
4
|
+
.mui-mechanism-svg [data-tone="0"]{fill:color-mix(in srgb,var(--mech-base) 74%,var(--canvas-bg))}.mui-mechanism-svg [data-tone="1"]{fill:var(--mech-base)}.mui-mechanism-svg [data-tone="2"]{fill:color-mix(in srgb,var(--mech-base) 85%,var(--canvas-ink))}.mui-mechanism-svg [data-tone="3"]{fill:color-mix(in srgb,var(--mech-base) 68%,var(--canvas-ink))}.mui-mechanism-svg [data-line]{fill:none;stroke:var(--mech-base);stroke-width:2}.mui-mechanism-svg [data-active=false]{opacity:.2}.mui-mechanism-svg [data-active=true]{opacity:1}
|
|
5
|
+
.mui-mechanism-nav{display:flex;flex-wrap:wrap;gap:7px;margin:8px 0 12px}.mui-mechanism button{font:inherit;color:var(--canvas-ink);background:var(--canvas-bg);border:1px solid var(--canvas-line);border-radius:999px;padding:6px 13px;cursor:pointer;min-height:36px}.mui-mechanism button[aria-pressed=true]{color:var(--canvas-bg);background:var(--canvas-ink);border-color:var(--canvas-ink)}.mui-mechanism button:focus-visible,.mui-mechanism input:focus-visible{outline:2px solid var(--canvas-accent);outline-offset:3px}.mui-mechanism button:disabled{opacity:.5;cursor:default}
|
|
6
|
+
.mui-mechanism-controls{display:flex;align-items:center;flex-wrap:wrap;gap:14px;padding:12px 14px;border-radius:16px;background:var(--canvas-soft);margin:8px 0}.mui-mechanism-controls label{display:flex;align-items:center;gap:9px;flex:1 1 180px;min-width:0}.mui-mechanism-controls input[type=range]{flex:1;min-width:70px;accent-color:var(--canvas-accent)}.mui-mechanism-controls input[type=checkbox]{accent-color:var(--canvas-accent)}.mui-mechanism-controls output{font-variant-numeric:tabular-nums;min-width:40px;text-align:right;font-weight:650}.mui-mechanism-readout{display:block;padding:10px 0;font-weight:700;font-variant-numeric:tabular-nums;color:var(--canvas-ink)}.mui-mechanism-description{min-height:0;font-size:14px}
|
|
7
|
+
.mui-mechanism[data-style=editorial]{border:0;border-top:1px solid var(--canvas-line);border-radius:0;padding:16px 0}.mui-mechanism[data-style=editorial] h2{font-family:ui-serif,Georgia,serif;font-weight:650}.mui-mechanism[data-style=editorial] .mui-mechanism-controls{border-radius:0;background:transparent;border-block:1px solid var(--canvas-line)}
|
|
8
|
+
.mui-mechanism[data-style=technical]{border-radius:10px}.mui-mechanism[data-style=technical] button,.mui-mechanism[data-style=technical] .mui-mechanism-controls{border-radius:6px}.mui-mechanism[data-style=technical] .mui-mechanism-readout{font-family:ui-monospace,Consolas,monospace}
|
|
9
|
+
.mui-mechanism[data-style=soft]{border:0;background:var(--canvas-soft);border-radius:28px 28px 28px 10px}.mui-mechanism[data-style=soft] .mui-mechanism-controls{background:var(--canvas-bg);border-radius:22px}.mui-mechanism[data-style=expressive] h2{font-size:28px;font-weight:850;border-left:5px solid var(--canvas-accent);padding-left:14px}.mui-mechanism[data-style=expressive]{border:0;border-radius:0}.mui-mechanism[data-style=prismframe]{border:1.5px solid var(--canvas-line);box-shadow:4px 5px 0 color-mix(in srgb,var(--canvas-accent) 14%,var(--canvas-bg))}.mui-mechanism[data-style=aurora]{border-radius:28px;box-shadow:0 16px 30px -25px #30204d66}.mui-mechanism[data-style=aurora] .mui-mechanism-controls{border-radius:24px}
|
|
10
|
+
@media(max-width:500px){.mui-mechanism{padding:14px;border-radius:20px}.mui-mechanism h2{font-size:20px}.mui-mechanism-controls{padding:10px}.mui-mechanism-nav{gap:5px}.mui-mechanism button{padding:6px 10px;font-size:14px}}
|
|
11
|
+
@media(prefers-reduced-motion:reduce){.mui-mechanism *{animation:none!important;transition:none!important}}
|
|
12
|
+
@media(forced-colors:active){.mui-mechanism-svg path{fill:Canvas;stroke:CanvasText}.mui-mechanism button[aria-pressed=true]{background:Highlight;color:HighlightText}}
|
|
13
|
+
`;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import{mechanismExample}from'./mechanism-catalog.js';import{validateDocument}from'./protocol.js';import{assemblyScene,gearsScene,crankScene}from'./mechanism-scenes.js';import{sceneSvg}from'./mechanism-render.js';import{formatNumber}from'./format-number.js';
|
|
2
|
+
export function mechanismDocument(args={}){
|
|
3
|
+
for(const key of Object.keys(args))if(!['scene','title','rpm','teeth','radius','running','angle','explode'].includes(key))throw Error('DSH UI: unknown render_mechanism argument '+key);
|
|
4
|
+
for(const key of['teeth','radius','angle'])if(args[key]!==undefined&&!Number.isInteger(args[key]))throw Error('DSH UI: '+key+' must be an integer');
|
|
5
|
+
const doc=mechanismExample(args.scene),node=doc.nodes[0];
|
|
6
|
+
for(const key of['rpm','teeth','radius','running','angle','explode'])if(args[key]!==undefined){if(!doc.state[key])throw Error('DSH UI: '+key+' is not used by '+args.scene);doc.state[key].default=args[key]}
|
|
7
|
+
// Validate defaults before calculating or producing a preview; the same ranges
|
|
8
|
+
// also govern all subsequent native/iframe state updates.
|
|
9
|
+
validateDocument(doc);
|
|
10
|
+
if(args.title!==undefined){doc.title=args.title;node.title=args.title}
|
|
11
|
+
const s=Object.fromEntries(Object.entries(doc.state).map(([k,v])=>[k,v.default]));
|
|
12
|
+
const fallback=args.scene==='gears'?`外啮合齿轮反向转动。主动轮16齿、${s.rpm} rpm;从动轮${s.teeth}齿、${formatNumber(-s.rpm*16/s.teeth,2)} rpm。速度比由齿数确定,齿形为简化示意,不用于加工。`:args.scene==='crank'?`曲柄旋转带动定长连杆,滑块沿导轨往复。曲柄半径${s.radius}、连杆长度70,完整行程${s.radius*2},长度为示意单位,非动力学仿真。`:doc.fallback;
|
|
13
|
+
doc.fallback=node.fallback='初始参数示意:'+fallback;
|
|
14
|
+
doc.nodes.at(-1).text=args.scene==='gears'?'外啮合齿轮转向相反;从动转速 = −主动转速 × 主动齿数 ÷ 从动齿数。当前参数与数值见图中实时读数。简化齿形,不用于加工。':args.scene==='crank'?'连杆长度固定为70,滑块沿导轨往复;完整行程等于曲柄半径的两倍。当前半径、位置和行程见实时读数。长度为示意单位,非动力学仿真。':fallback;
|
|
15
|
+
const scene=args.scene==='assembly'?assemblyScene():args.scene==='gears'?gearsScene({drivenTeeth:s.teeth}):crankScene({radius:s.radius,angle:s.angle*Math.PI/180});
|
|
16
|
+
if(args.scene==='gears'){const phase=s.angle*Math.PI/180;scene.parts.find(p=>p.id==='driverGear').rotation=phase;scene.parts.find(p=>p.id==='drivenGear').rotation=-phase*16/s.teeth+Math.PI-Math.PI/s.teeth}
|
|
17
|
+
node.html='<div id="diagram"><section class="mui-mechanism">'+sceneSvg(scene,{explode:s.explode,cutaway:s.cutaway,preview:true})+'<p>'+fallback+'</p></section></div>';
|
|
18
|
+
return validateDocument(doc);
|
|
19
|
+
}
|
|
20
|
+
export function previewMechanism(raw){try{if(typeof raw!=='string'||raw.length>2048)return null;return mechanismDocument(JSON.parse(raw))}catch{return null}}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Pure explanatory geometry and kinematics. Projection/contours use Anatomy (MIT).
|
|
2
|
+
import {iso,depthOf,fitProjection,roundedPlan,circleRing} from './third-party/anatomy-iso.mjs';
|
|
3
|
+
export {iso,depthOf,fitProjection};
|
|
4
|
+
export const MECHANISM_LIMITS=Object.freeze({parts:96,teeth:64,faces:8192});
|
|
5
|
+
const finite=v=>typeof v==='number'&&Number.isFinite(v);
|
|
6
|
+
const fail=s=>{throw Error('Motion mechanism: '+s)};
|
|
7
|
+
const num=(v,label,min=-10000,max=10000)=>{if(!finite(v)||v<min||v>max)fail(label+' must be a finite number in '+min+'..'+max);return v};
|
|
8
|
+
const vector=(v,label)=>{if(!Array.isArray(v)||v.length!==3)fail(label+' must be [x,y,z]');return v.map((x,i)=>num(x,label+'['+i+']'))};
|
|
9
|
+
const id=v=>typeof v==='string'&&/^[\p{L}_][\p{L}\p{M}\p{N}_]{0,63}$/u.test(v)&&!['constructor','prototype','__proto__'].includes(v);
|
|
10
|
+
const kinds=['box','cylinder','gear','rod','spring'];
|
|
11
|
+
export const add=(a,b)=>a.map((v,i)=>v+b[i]),sub=(a,b)=>a.map((v,i)=>v-b[i]),mul=(a,t)=>a.map(v=>v*t),dot=(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0),cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
|
|
12
|
+
const unit=a=>{const len=Math.hypot(...a);if(len<1e-6)fail('zero-length axis');return mul(a,1/len)};
|
|
13
|
+
export function validateScene(spec){
|
|
14
|
+
if(!spec||typeof spec!=='object'||Array.isArray(spec))fail('scene must be an object');
|
|
15
|
+
if(!Array.isArray(spec.parts)||!spec.parts.length||spec.parts.length>MECHANISM_LIMITS.parts)fail('parts requires 1..96 objects');
|
|
16
|
+
const seen=new Set(),groups=new Set();
|
|
17
|
+
for(const [i,p]of spec.parts.entries()){
|
|
18
|
+
const at='parts['+i+']';if(!p||typeof p!=='object'||!id(p.id)||seen.has(p.id))fail(at+'.id must be a unique identifier');seen.add(p.id);
|
|
19
|
+
if(!kinds.includes(p.type))fail(at+'.type must be '+kinds.join('/'));
|
|
20
|
+
if(p.group!==undefined){if(!id(p.group))fail(at+'.group must be an identifier');groups.add(p.group)}
|
|
21
|
+
for(const k of['label','description'])if(p[k]!==undefined&&(typeof p[k]!=='string'||p[k].length>1024))fail(at+'.'+k+' must be short text');
|
|
22
|
+
if(p.cover!==undefined&&typeof p.cover!=='boolean')fail(at+'.cover must be boolean');
|
|
23
|
+
if(p.material!==undefined&&!['metal','dark','accent','warm','cool'].includes(p.material))fail(at+'.material must be metal/dark/accent/warm/cool');
|
|
24
|
+
if(p.at!==undefined)vector(p.at,at+'.at');if(p.explode!==undefined)vector(p.explode,at+'.explode');if(p.rotation!==undefined)num(p.rotation,at+'.rotation',-1e9,1e9);
|
|
25
|
+
if(p.axis!==undefined&&!['x','y','z'].includes(p.axis))fail(at+'.axis must be x/y/z');
|
|
26
|
+
if(p.type==='box'){const size=vector(p.size,at+'.size');if(size.some(v=>v<=0||v>2000))fail(at+'.size must be positive and <=2000');if(p.round!==undefined)num(p.round,at+'.round',0,Math.min(size[0],size[1])/2)}
|
|
27
|
+
if(['cylinder','gear'].includes(p.type)){num(p.radius,at+'.radius',.1,1000);num(p.height??8,at+'.height',.1,1000);if(p.inner!==undefined)num(p.inner,at+'.inner',0,p.radius-.01)}
|
|
28
|
+
if(p.type==='gear'){const teeth=num(p.teeth,at+'.teeth',6,64);if(!Number.isInteger(teeth))fail(at+'.teeth must be an integer');if(p.module!==undefined)num(p.module,at+'.module',.1,p.radius/3)}
|
|
29
|
+
if(['rod','spring'].includes(p.type)){vector(p.from,at+'.from');vector(p.to,at+'.to');if(Math.hypot(...sub(p.to,p.from))<.1)fail(at+' endpoints must differ');num(p.radius??2,at+'.radius',.1,200)}
|
|
30
|
+
if(p.type==='spring'){const turns=num(p.turns??8,at+'.turns',2,24);if(!Number.isInteger(turns))fail(at+'.turns must be integer')}
|
|
31
|
+
}
|
|
32
|
+
if(spec.groups!==undefined){if(!Array.isArray(spec.groups)||spec.groups.length>12)fail('groups must be an array of <=12 labels');const ids=new Set();for(const g of spec.groups){if(!g||!id(g.id)||ids.has(g.id)||!groups.has(g.id)||typeof g.label!=='string'||g.label.length>128)fail('groups must name unique existing part groups');ids.add(g.id)}}
|
|
33
|
+
if(spec.bounds!==undefined){if(!Array.isArray(spec.bounds)||spec.bounds.length!==2)fail('bounds must be [minXYZ,maxXYZ]');const [a,b]=spec.bounds.map((v,i)=>vector(v,'bounds['+i+']'));if(a.some((v,i)=>v>=b[i]))fail('bounds min must be below max on every axis')}
|
|
34
|
+
if(spec.azimuth!==undefined)num(spec.azimuth,'azimuth',10,80);if(spec.elevation!==undefined)num(spec.elevation,'elevation',15,70);
|
|
35
|
+
if(spec.title!==undefined&&(typeof spec.title!=='string'||spec.title.length>256))fail('title must be short text');
|
|
36
|
+
if(spec.bind!==undefined){if(!spec.bind||typeof spec.bind!=='object'||Array.isArray(spec.bind))fail('bind must be an object');for(const[k,v]of Object.entries(spec.bind))if(!['explode','selected','cutaway'].includes(k)||!id(v))fail('bind only accepts declared state keys for explode/selected/cutaway')}
|
|
37
|
+
return spec;
|
|
38
|
+
}
|
|
39
|
+
function frame(axis){if(axis==='x')return [[0,1,0],[0,0,1],[1,0,0]];if(axis==='y')return [[0,0,1],[1,0,0],[0,1,0]];return [[1,0,0],[0,1,0],[0,0,1]]}
|
|
40
|
+
const point=(f,x,y,z)=>add(add(mul(f[0],x),mul(f[1],y)),mul(f[2],z));
|
|
41
|
+
function sweep(ring,height,f,inner){
|
|
42
|
+
const low=ring.map(([x,y])=>point(f,x,y,-height/2)),high=ring.map(([x,y])=>point(f,x,y,height/2)),faces=[];
|
|
43
|
+
const topInner=inner?.map(([x,y])=>point(f,x,y,height/2)),bottomInner=inner?.map(([x,y])=>point(f,x,y,-height/2));
|
|
44
|
+
faces.push({points:high,holes:topInner?[topInner.slice().reverse()]:[],normal:f[2]},{points:low.slice().reverse(),holes:bottomInner?[bottomInner]:[],normal:mul(f[2],-1)});
|
|
45
|
+
for(let i=0;i<ring.length;i++){const j=(i+1)%ring.length;if(Math.hypot(...sub(low[j],low[i]))<1e-6)continue;faces.push({points:[low[i],low[j],high[j],high[i]],normal:unit(cross(sub(low[j],low[i]),f[2]))});if(inner)faces.push({points:[bottomInner[j],bottomInner[i],topInner[i],topInner[j]],normal:unit(cross(sub(bottomInner[i],bottomInner[j]),f[2]))})}
|
|
46
|
+
return faces;
|
|
47
|
+
}
|
|
48
|
+
export function partGeometry(p){
|
|
49
|
+
let f=frame(p.axis),ring,height,faces;
|
|
50
|
+
if(p.type==='box'){ring=roundedPlan({x:-p.size[0]/2,y:-p.size[1]/2,w:p.size[0],d:p.size[1],r:p.round??Math.min(2,p.size[0]/2,p.size[1]/2)},3);height=p.size[2]}
|
|
51
|
+
if(p.type==='cylinder'){ring=circleRing(0,0,p.radius,24);height=p.height??8}
|
|
52
|
+
if(p.type==='gear'){const step=Math.PI*2/p.teeth,m=p.module??p.radius*2/p.teeth;ring=[];for(let i=0;i<p.teeth;i++)for(const[t,r]of[[-.5,p.radius-m*.85],[-.32,p.radius-m*.85],[-.17,p.radius+m*.7],[.17,p.radius+m*.7],[.32,p.radius-m*.85]]){const a=(i+t)*step;ring.push([Math.cos(a)*r,Math.sin(a)*r])}height=p.height??8}
|
|
53
|
+
if(p.type==='rod'){const w=unit(sub(p.to,p.from)),u=unit(cross(w,Math.abs(w[2])<.9?[0,0,1]:[0,1,0]));f=[u,cross(w,u),w];ring=circleRing(0,0,p.radius??2,16);height=Math.hypot(...sub(p.to,p.from))}
|
|
54
|
+
if(p.type==='spring'){const w=unit(sub(p.to,p.from)),u=unit(cross(w,Math.abs(w[2])<.9?[0,0,1]:[0,1,0])),v=cross(w,u),points=[];for(let i=0;i<=128;i++){const t=i/128,a=t*(p.turns??8)*Math.PI*2,fade=Math.min(1,t*12,(1-t)*12);points.push(add(add(p.from,mul(sub(p.to,p.from),t)),add(mul(u,Math.cos(a)*(p.radius??6)*fade),mul(v,Math.sin(a)*(p.radius??6)*fade))))}return {line:points,points}}
|
|
55
|
+
const rotation=p.rotation??0,c=Math.cos(rotation),s=Math.sin(rotation);ring=ring.map(([x,y])=>[x*c-y*s,x*s+y*c]);const inner=p.inner?ring.map(([x,y])=>{const a=Math.atan2(y,x);return [Math.cos(a)*p.inner,Math.sin(a)*p.inner]}):undefined;
|
|
56
|
+
faces=sweep(ring,height,f,inner);const center=p.type==='rod'?mul(add(p.from,p.to),.5):p.at??[0,0,0];faces=faces.map(face=>({...face,points:face.points.map(v=>add(v,center)),holes:face.holes?.map(h=>h.map(v=>add(v,center)))}));return {faces,points:faces.flatMap(f=>f.points)};
|
|
57
|
+
}
|
|
58
|
+
export function sceneBounds(spec){if(spec.bounds){const[a,b]=spec.bounds;return Array.from({length:8},(_,i)=>[i&1?b[0]:a[0],i&2?b[1]:a[1],i&4?b[2]:a[2]])}return spec.parts.flatMap(p=>{const geometry=partGeometry(p);return geometry.points.flatMap(v=>[v,add(v,p.explode??[0,0,0])])})}
|
|
59
|
+
export function gearPair({driverTeeth=16,drivenTeeth=32,rpm=30,angle=0,module=2}={}){for(const[k,v]of Object.entries({driverTeeth,drivenTeeth}))if(!Number.isInteger(v)||v<6||v>64)fail(k+' must be integer 6..64');num(rpm,'rpm',-600,600);num(angle,'angle',-1e9,1e9);num(module,'module',.1,10);return {driverRadius:driverTeeth*module/2,drivenRadius:drivenTeeth*module/2,centerDistance:(driverTeeth+drivenTeeth)*module/2,driverAngle:angle,drivenAngle:-angle*driverTeeth/drivenTeeth+Math.PI-Math.PI/drivenTeeth,driverRpm:rpm,drivenRpm:-rpm*driverTeeth/drivenTeeth,ratio:drivenTeeth/driverTeeth}}
|
|
60
|
+
export function sliderCrank({angle=0,radius=20,rod=70,z=20}={}){num(angle,'angle',-1e9,1e9);num(radius,'radius',.1,200);num(rod,'rod',.1,1000);num(z,'z');if(rod<=radius)fail('connecting rod length must exceed crank radius');const crank=[radius*Math.cos(angle),radius*Math.sin(angle),z],x=crank[0]+Math.sqrt(rod*rod-crank[1]*crank[1]);return {crank,piston:[x,0,z],position:x,stroke:radius*2,angle}}
|
|
61
|
+
export function follow(value,target,delta,tau=.16){num(value,'value',-1e9,1e9);num(target,'target',-1e9,1e9);num(delta,'delta',0,.1);num(tau,'tau',.01,10);return value+(target-value)*(1-Math.exp(-delta/tau))}
|
|
62
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {iso,depthOf,fitProjection,validateScene,sceneBounds,partGeometry,add,mul,dot} from './mechanism-geometry.js';
|
|
2
|
+
const TAU=Math.PI*2;
|
|
3
|
+
export function projectionFor(spec,options){validateScene(spec);const points=options&&!spec.bounds?spec.parts.filter(p=>!options.cutaway||!p.cover).flatMap(p=>partGeometry(p).points.map(v=>add(v,mul(p.explode??[0,0,0],options.explode??0)))):sceneBounds(spec);return fitProjection(points.length?points:[[0,0,0],[1,1,1]],600,340,{pad:24,azimuth:spec.azimuth??45,elevation:spec.elevation??30})}
|
|
4
|
+
const path=points=>points.map(([x,y],i)=>(i?'L':'M')+(Math.round(x*10)/10)+' '+(Math.round(y*10)/10)).join('')+'Z';
|
|
5
|
+
export function renderFaces(spec,projection,{explode=0,selected='all',cutaway=false,pose={}}={},cache=new Map()){
|
|
6
|
+
if(typeof explode!=='number'||!Number.isFinite(explode)||explode<0||explode>1)throw Error('Motion mechanism: explode must be 0..1');
|
|
7
|
+
if(typeof selected!=='string'||typeof cutaway!=='boolean')throw Error('Motion mechanism: selected must be text and cutaway boolean');
|
|
8
|
+
if(!pose||typeof pose!=='object'||Array.isArray(pose))throw Error('Motion mechanism: pose must be an object');
|
|
9
|
+
for(const id of Object.keys(pose))if(!spec.parts.some(p=>p.id===id))throw Error('Motion mechanism: unknown pose part '+id);
|
|
10
|
+
const a=(projection.azimuth??45)*TAU/360,e=(projection.elevation??30)*TAU/360,view=[Math.cos(a)*Math.cos(e),Math.sin(a)*Math.cos(e),Math.sin(e)],faces=[];
|
|
11
|
+
for(const part of spec.parts){
|
|
12
|
+
if(cutaway&&part.cover)continue;const override=pose[part.id]??{};for(const k of Object.keys(override))if(!['at','rotation','from','to'].includes(k))throw Error('Motion mechanism: unsupported pose field '+k);
|
|
13
|
+
const p={...part,...override},key=JSON.stringify(p);let geometry=cache.get(part.id);if(geometry?.key!==key){validateScene({parts:[p]});geometry={key,...partGeometry(p)};cache.set(part.id,geometry)}
|
|
14
|
+
const offset=mul(p.explode??[0,0,0],explode),floor=Math.min(...geometry.points.map(v=>v[2]))+offset[2],active=selected==='all'||selected===part.id||selected===part.group;
|
|
15
|
+
if(geometry.line){const points=geometry.line.map(v=>add(v,offset));faces.push({floor,key:p.id+'_line',part:p.id,group:p.group,d:points.map((v,i)=>(i?'L':'M')+iso(v,projection).join(' ')).join(''),depth:depthOf(points[Math.floor(points.length/2)],projection),line:true,tone:1,material:p.material??'metal',active});continue}
|
|
16
|
+
for(const [index,face]of geometry.faces.entries()){
|
|
17
|
+
const facing=dot(face.normal,view);if(facing<=.0001)continue;
|
|
18
|
+
const points=face.points.map(v=>add(v,offset)),holes=face.holes?.map(h=>h.map(v=>add(v,offset))),center=mul(points.reduce((s,v)=>add(s,v),[0,0,0]),1/points.length),light=dot(face.normal,[-.25,-.4,.88]);
|
|
19
|
+
faces.push({floor,key:p.id+'_'+index,part:p.id,group:p.group,d:path(points.map(v=>iso(v,projection)))+(holes??[]).map(h=>path(h.map(v=>iso(v,projection)))).join(''),depth:depthOf(center,projection),line:false,tone:light>.5?0:light>.1?1:light>-.4?2:3,material:p.material??'metal',active});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if(faces.length>8192)throw Error('Motion mechanism: face budget exceeded');return faces.sort((a,b)=>a.floor-b.floor||a.depth-b.depth||a.key.localeCompare(b.key));
|
|
23
|
+
}
|
|
24
|
+
export function sceneSvg(spec,options={}){const projection=projectionFor(spec,options),faces=renderFaces(spec,projection,options).filter(f=>!options.preview||f.tone===0||f.line);const runs=[];for(const f of faces){const last=runs.at(-1);if(last&&last.material===f.material&&last.tone===f.tone&&last.line===f.line)last.d+=f.d;else runs.push({...f})}const shapes=runs.map(f=>'<path data-material="'+f.material+'" data-tone="'+f.tone+'" d="'+f.d+'" fill-rule="evenodd"'+(f.line?' data-line=""':'')+'/>').join('');return '<svg class="mui-mechanism-svg" viewBox="0 0 600 340" role="img" aria-label="'+(options.preview?'结构轮廓预览;完成后显示立体细节':'结构图解预览')+'">'+shapes+'</svg>'}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Generated from Motion UI integration and Anatomy MIT projection helpers.
|
|
2
|
+
export const MECHANISM_RUNTIME="var __MotionMechanism=(()=>{var it=Object.defineProperty;var Ot=Object.getOwnPropertyDescriptor;var Et=Object.getOwnPropertyNames;var It=Object.prototype.hasOwnProperty;var zt=(t,a)=>{for(var n in a)it(t,n,{get:a[n],enumerable:!0})},Pt=(t,a,n,o)=>{if(a&&typeof a==\"object\"||typeof a==\"function\")for(let e of Et(a))!It.call(t,e)&&e!==n&&it(t,e,{get:()=>a[e],enumerable:!(o=Ot(a,e))||o.enumerable});return t};var _t=t=>Pt(it({},\"__esModule\",{value:!0}),t);var Tt={};zt(Tt,{createMechanismAPI:()=>Ft});var st=Math.PI/180,Ct=Math.sqrt(1.6);function yt({scale:t,azimuth:a=45,elevation:n=30}){let o=a*st,e=n*st;return{sinA:Math.sin(o),cosA:Math.cos(o),sinE:Math.sin(e),cosE:Math.cos(e),k:t*Ct}}function q([t,a,n],o){let{origin:e}=o,{sinA:i,cosA:r,sinE:p,cosE:d,k:m}=yt(o);return[e[0]+(t*i-a*r)*m,e[1]+((t*r+a*i)*p-n*d)*m]}function tt([t,a,n],o){let{sinA:e,cosA:i,sinE:r,cosE:p}=yt(o);return(t*i+a*e)*p+n*r}function vt({x:t,y:a,w:n,d:o,r:e},i=8){let r=Math.max(0,Math.min(e,n/2,o/2)),p=[[t+n-r,a+r,-90],[t+n-r,a+o-r,0],[t+r,a+o-r,90],[t+r,a+r,180]],d=[];for(let[m,s,c]of p){let u=r>0?i:0;for(let f=0;f<=u;f++){let g=(c+(u?90*f/u:45))*st;d.push([m+r*Math.cos(g),s+r*Math.sin(g)])}}return d}function lt(t,a,n,o=40){return Array.from({length:o},(e,i)=>{let r=i/o*Math.PI*2;return[t+n*Math.cos(r),a+n*Math.sin(r)]})}function ct(t,a,n,{pad:o=24,headroom:e=0,azimuth:i=45,elevation:r=30}={}){let p={origin:[0,0],scale:1,azimuth:i,elevation:r},d=t.map(M=>q(M,p)),m=d.map(([M])=>M),s=d.map(([,M])=>M),c=Math.min(...m),u=Math.max(...m),f=Math.min(...s),g=Math.max(...s),h=Math.min((a-o*2)/(u-c),(n-o*2-e)/(g-f));return{origin:[a/2-(c+u)/2*h,e+(n-e)/2-(f+g)/2*h],scale:h,azimuth:i,elevation:r}}var Nt=Object.freeze({parts:96,teeth:64,faces:8192}),Ht=t=>typeof t==\"number\"&&Number.isFinite(t),y=t=>{throw Error(\"Motion mechanism: \"+t)},v=(t,a,n=-1e4,o=1e4)=>((!Ht(t)||t<n||t>o)&&y(a+\" must be a finite number in \"+n+\"..\"+o),t),U=(t,a)=>((!Array.isArray(t)||t.length!==3)&&y(a+\" must be [x,y,z]\"),t.map((n,o)=>v(n,a+\"[\"+o+\"]\"))),et=t=>typeof t==\"string\"&&/^[\\p{L}_][\\p{L}\\p{M}\\p{N}_]{0,63}$/u.test(t)&&![\"constructor\",\"prototype\",\"__proto__\"].includes(t),kt=[\"box\",\"cylinder\",\"gear\",\"rod\",\"spring\"],I=(t,a)=>t.map((n,o)=>n+a[o]),B=(t,a)=>t.map((n,o)=>n-a[o]),_=(t,a)=>t.map(n=>n*a),dt=(t,a)=>t.reduce((n,o,e)=>n+o*a[e],0),V=(t,a)=>[t[1]*a[2]-t[2]*a[1],t[2]*a[0]-t[0]*a[2],t[0]*a[1]-t[1]*a[0]],J=t=>{let a=Math.hypot(...t);return a<1e-6&&y(\"zero-length axis\"),_(t,1/a)};function Y(t){(!t||typeof t!=\"object\"||Array.isArray(t))&&y(\"scene must be an object\"),(!Array.isArray(t.parts)||!t.parts.length||t.parts.length>Nt.parts)&&y(\"parts requires 1..96 objects\");let a=new Set,n=new Set;for(let[o,e]of t.parts.entries()){let i=\"parts[\"+o+\"]\";(!e||typeof e!=\"object\"||!et(e.id)||a.has(e.id))&&y(i+\".id must be a unique identifier\"),a.add(e.id),kt.includes(e.type)||y(i+\".type must be \"+kt.join(\"/\")),e.group!==void 0&&(et(e.group)||y(i+\".group must be an identifier\"),n.add(e.group));for(let r of[\"label\",\"description\"])e[r]!==void 0&&(typeof e[r]!=\"string\"||e[r].length>1024)&&y(i+\".\"+r+\" must be short text\");if(e.cover!==void 0&&typeof e.cover!=\"boolean\"&&y(i+\".cover must be boolean\"),e.material!==void 0&&![\"metal\",\"dark\",\"accent\",\"warm\",\"cool\"].includes(e.material)&&y(i+\".material must be metal/dark/accent/warm/cool\"),e.at!==void 0&&U(e.at,i+\".at\"),e.explode!==void 0&&U(e.explode,i+\".explode\"),e.rotation!==void 0&&v(e.rotation,i+\".rotation\",-1e9,1e9),e.axis!==void 0&&![\"x\",\"y\",\"z\"].includes(e.axis)&&y(i+\".axis must be x/y/z\"),e.type===\"box\"){let r=U(e.size,i+\".size\");r.some(p=>p<=0||p>2e3)&&y(i+\".size must be positive and <=2000\"),e.round!==void 0&&v(e.round,i+\".round\",0,Math.min(r[0],r[1])/2)}if([\"cylinder\",\"gear\"].includes(e.type)&&(v(e.radius,i+\".radius\",.1,1e3),v(e.height??8,i+\".height\",.1,1e3),e.inner!==void 0&&v(e.inner,i+\".inner\",0,e.radius-.01)),e.type===\"gear\"){let r=v(e.teeth,i+\".teeth\",6,64);Number.isInteger(r)||y(i+\".teeth must be an integer\"),e.module!==void 0&&v(e.module,i+\".module\",.1,e.radius/3)}if([\"rod\",\"spring\"].includes(e.type)&&(U(e.from,i+\".from\"),U(e.to,i+\".to\"),Math.hypot(...B(e.to,e.from))<.1&&y(i+\" endpoints must differ\"),v(e.radius??2,i+\".radius\",.1,200)),e.type===\"spring\"){let r=v(e.turns??8,i+\".turns\",2,24);Number.isInteger(r)||y(i+\".turns must be integer\")}}if(t.groups!==void 0){(!Array.isArray(t.groups)||t.groups.length>12)&&y(\"groups must be an array of <=12 labels\");let o=new Set;for(let e of t.groups)(!e||!et(e.id)||o.has(e.id)||!n.has(e.id)||typeof e.label!=\"string\"||e.label.length>128)&&y(\"groups must name unique existing part groups\"),o.add(e.id)}if(t.bounds!==void 0){(!Array.isArray(t.bounds)||t.bounds.length!==2)&&y(\"bounds must be [minXYZ,maxXYZ]\");let[o,e]=t.bounds.map((i,r)=>U(i,\"bounds[\"+r+\"]\"));o.some((i,r)=>i>=e[r])&&y(\"bounds min must be below max on every axis\")}if(t.azimuth!==void 0&&v(t.azimuth,\"azimuth\",10,80),t.elevation!==void 0&&v(t.elevation,\"elevation\",15,70),t.title!==void 0&&(typeof t.title!=\"string\"||t.title.length>256)&&y(\"title must be short text\"),t.bind!==void 0){(!t.bind||typeof t.bind!=\"object\"||Array.isArray(t.bind))&&y(\"bind must be an object\");for(let[o,e]of Object.entries(t.bind))(![\"explode\",\"selected\",\"cutaway\"].includes(o)||!et(e))&&y(\"bind only accepts declared state keys for explode/selected/cutaway\")}return t}function Lt(t){return t===\"x\"?[[0,1,0],[0,0,1],[1,0,0]]:t===\"y\"?[[0,0,1],[1,0,0],[0,1,0]]:[[1,0,0],[0,1,0],[0,0,1]]}var at=(t,a,n,o)=>I(I(_(t[0],a),_(t[1],n)),_(t[2],o));function Rt(t,a,n,o){let e=t.map(([m,s])=>at(n,m,s,-a/2)),i=t.map(([m,s])=>at(n,m,s,a/2)),r=[],p=o?.map(([m,s])=>at(n,m,s,a/2)),d=o?.map(([m,s])=>at(n,m,s,-a/2));r.push({points:i,holes:p?[p.slice().reverse()]:[],normal:n[2]},{points:e.slice().reverse(),holes:d?[d]:[],normal:_(n[2],-1)});for(let m=0;m<t.length;m++){let s=(m+1)%t.length;Math.hypot(...B(e[s],e[m]))<1e-6||(r.push({points:[e[m],e[s],i[s],i[m]],normal:J(V(B(e[s],e[m]),n[2]))}),o&&r.push({points:[d[s],d[m],p[m],p[s]],normal:J(V(B(d[m],d[s]),n[2]))}))}return r}function ot(t){let a=Lt(t.axis),n,o,e;if(t.type===\"box\"&&(n=vt({x:-t.size[0]/2,y:-t.size[1]/2,w:t.size[0],d:t.size[1],r:t.round??Math.min(2,t.size[0]/2,t.size[1]/2)},3),o=t.size[2]),t.type===\"cylinder\"&&(n=lt(0,0,t.radius,24),o=t.height??8),t.type===\"gear\"){let s=Math.PI*2/t.teeth,c=t.module??t.radius*2/t.teeth;n=[];for(let u=0;u<t.teeth;u++)for(let[f,g]of[[-.5,t.radius-c*.85],[-.32,t.radius-c*.85],[-.17,t.radius+c*.7],[.17,t.radius+c*.7],[.32,t.radius-c*.85]]){let h=(u+f)*s;n.push([Math.cos(h)*g,Math.sin(h)*g])}o=t.height??8}if(t.type===\"rod\"){let s=J(B(t.to,t.from)),c=J(V(s,Math.abs(s[2])<.9?[0,0,1]:[0,1,0]));a=[c,V(s,c),s],n=lt(0,0,t.radius??2,16),o=Math.hypot(...B(t.to,t.from))}if(t.type===\"spring\"){let s=J(B(t.to,t.from)),c=J(V(s,Math.abs(s[2])<.9?[0,0,1]:[0,1,0])),u=V(s,c),f=[];for(let g=0;g<=128;g++){let h=g/128,M=h*(t.turns??8)*Math.PI*2,O=Math.min(1,h*12,(1-h)*12);f.push(I(I(t.from,_(B(t.to,t.from),h)),I(_(c,Math.cos(M)*(t.radius??6)*O),_(u,Math.sin(M)*(t.radius??6)*O))))}return{line:f,points:f}}let i=t.rotation??0,r=Math.cos(i),p=Math.sin(i);n=n.map(([s,c])=>[s*r-c*p,s*p+c*r]);let d=t.inner?n.map(([s,c])=>{let u=Math.atan2(c,s);return[Math.cos(u)*t.inner,Math.sin(u)*t.inner]}):void 0;e=Rt(n,o,a,d);let m=t.type===\"rod\"?_(I(t.from,t.to),.5):t.at??[0,0,0];return e=e.map(s=>({...s,points:s.points.map(c=>I(c,m)),holes:s.holes?.map(c=>c.map(u=>I(u,m)))})),{faces:e,points:e.flatMap(s=>s.points)}}function wt(t){if(t.bounds){let[a,n]=t.bounds;return Array.from({length:8},(o,e)=>[e&1?n[0]:a[0],e&2?n[1]:a[1],e&4?n[2]:a[2]])}return t.parts.flatMap(a=>ot(a).points.flatMap(o=>[o,I(o,a.explode??[0,0,0])]))}function W({driverTeeth:t=16,drivenTeeth:a=32,rpm:n=30,angle:o=0,module:e=2}={}){for(let[i,r]of Object.entries({driverTeeth:t,drivenTeeth:a}))(!Number.isInteger(r)||r<6||r>64)&&y(i+\" must be integer 6..64\");return v(n,\"rpm\",-600,600),v(o,\"angle\",-1e9,1e9),v(e,\"module\",.1,10),{driverRadius:t*e/2,drivenRadius:a*e/2,centerDistance:(t+a)*e/2,driverAngle:o,drivenAngle:-o*t/a+Math.PI-Math.PI/a,driverRpm:n,drivenRpm:-n*t/a,ratio:a/t}}function X({angle:t=0,radius:a=20,rod:n=70,z:o=20}={}){v(t,\"angle\",-1e9,1e9),v(a,\"radius\",.1,200),v(n,\"rod\",.1,1e3),v(o,\"z\"),n<=a&&y(\"connecting rod length must exceed crank radius\");let e=[a*Math.cos(t),a*Math.sin(t),o],i=e[0]+Math.sqrt(n*n-e[1]*e[1]);return{crank:e,piston:[i,0,o],position:i,stroke:a*2,angle:t}}function Mt(t,a,n,o=.16){return v(t,\"value\",-1e9,1e9),v(a,\"target\",-1e9,1e9),v(n,\"delta\",0,.1),v(o,\"tau\",.01,10),t+(a-t)*(1-Math.exp(-n/o))}var $t=Math.PI*2;function nt(t,a){Y(t);let n=a&&!t.bounds?t.parts.filter(o=>!a.cutaway||!o.cover).flatMap(o=>ot(o).points.map(e=>I(e,_(o.explode??[0,0,0],a.explode??0)))):wt(t);return ct(n.length?n:[[0,0,0],[1,1,1]],600,340,{pad:24,azimuth:t.azimuth??45,elevation:t.elevation??30})}var At=t=>t.map(([a,n],o)=>(o?\"L\":\"M\")+Math.round(a*10)/10+\" \"+Math.round(n*10)/10).join(\"\")+\"Z\";function mt(t,a,{explode:n=0,selected:o=\"all\",cutaway:e=!1,pose:i={}}={},r=new Map){if(typeof n!=\"number\"||!Number.isFinite(n)||n<0||n>1)throw Error(\"Motion mechanism: explode must be 0..1\");if(typeof o!=\"string\"||typeof e!=\"boolean\")throw Error(\"Motion mechanism: selected must be text and cutaway boolean\");if(!i||typeof i!=\"object\"||Array.isArray(i))throw Error(\"Motion mechanism: pose must be an object\");for(let c of Object.keys(i))if(!t.parts.some(u=>u.id===c))throw Error(\"Motion mechanism: unknown pose part \"+c);let p=(a.azimuth??45)*$t/360,d=(a.elevation??30)*$t/360,m=[Math.cos(p)*Math.cos(d),Math.sin(p)*Math.cos(d),Math.sin(d)],s=[];for(let c of t.parts){if(e&&c.cover)continue;let u=i[c.id]??{};for(let $ of Object.keys(u))if(![\"at\",\"rotation\",\"from\",\"to\"].includes($))throw Error(\"Motion mechanism: unsupported pose field \"+$);let f={...c,...u},g=JSON.stringify(f),h=r.get(c.id);h?.key!==g&&(Y({parts:[f]}),h={key:g,...ot(f)},r.set(c.id,h));let M=_(f.explode??[0,0,0],n),O=Math.min(...h.points.map($=>$[2]))+M[2],N=o===\"all\"||o===c.id||o===c.group;if(h.line){let $=h.line.map(P=>I(P,M));s.push({floor:O,key:f.id+\"_line\",part:f.id,group:f.group,d:$.map((P,A)=>(A?\"L\":\"M\")+q(P,a).join(\" \")).join(\"\"),depth:tt($[Math.floor($.length/2)],a),line:!0,tone:1,material:f.material??\"metal\",active:N});continue}for(let[$,P]of h.faces.entries()){if(dt(P.normal,m)<=1e-4)continue;let w=P.points.map(S=>I(S,M)),T=P.holes?.map(S=>S.map(H=>I(H,M))),D=_(w.reduce((S,H)=>I(S,H),[0,0,0]),1/w.length),G=dt(P.normal,[-.25,-.4,.88]);s.push({floor:O,key:f.id+\"_\"+$,part:f.id,group:f.group,d:At(w.map(S=>q(S,a)))+(T??[]).map(S=>At(S.map(H=>q(H,a)))).join(\"\"),depth:tt(D,a),line:!1,tone:G>.5?0:G>.1?1:G>-.4?2:3,material:f.material??\"metal\",active:N})}}if(s.length>8192)throw Error(\"Motion mechanism: face budget exceeded\");return s.sort((c,u)=>c.floor-u.floor||c.depth-u.depth||c.key.localeCompare(u.key))}var z=(t,a,n,o,e,i={})=>({id:t,type:\"box\",label:a,at:n,size:o,group:e,...i}),F=(t,a,n,o,e,i,r={})=>({id:t,type:\"cylinder\",label:a,at:n,radius:o,height:e,group:i,...r});function ut(t,a,n,o,e=3,i=18){return[[-1,-1],[-1,1],[1,-1],[1,1]].map(([r,p],d)=>F(o+\"_bolt_\"+d,\"\\u56FA\\u5B9A\\u87BA\\u6813\",[t+r*i,a+p*i,n],e,3,o,{material:\"dark\",explode:[0,0,26]}))}function ft(){return{title:\"\\u5206\\u5C42\\u7ED3\\u6784 \\xB7 \\u793A\\u610F\\u88C5\\u914D\",groups:[{id:\"base\",label:\"\\u5E95\\u5EA7\",description:\"\\u652F\\u6491\\u6574\\u4F53\\uFF0C\\u5E76\\u63D0\\u4F9B\\u5B89\\u88C5\\u4F4D\\u7F6E\\u3002\"},{id:\"board\",label:\"\\u529F\\u80FD\\u677F\",description:\"\\u8FDE\\u63A5\\u8F93\\u5165\\u4E0E\\u5904\\u7406\\u6A21\\u5757\\uFF1B\\u793A\\u610F\\u7ED3\\u6784\\uFF0C\\u4E0D\\u5BF9\\u5E94\\u7279\\u5B9A\\u4EA7\\u54C1\\u3002\"},{id:\"sink\",label:\"\\u6563\\u70ED\\u5C42\",description:\"\\u5C06\\u70ED\\u91CF\\u4F20\\u5230\\u66F4\\u5927\\u7684\\u8868\\u9762\\u79EF\\u3002\"},{id:\"cover\",label:\"\\u5916\\u58F3\",description:\"\\u4FDD\\u62A4\\u5185\\u90E8\\uFF1B\\u5256\\u5F00\\u9009\\u9879\\u901A\\u8FC7\\u9690\\u85CF\\u5916\\u58F3\\u5C55\\u793A\\u7ED3\\u6784\\u3002\"}],parts:[z(\"base\",\"\\u5B89\\u88C5\\u5E95\\u5EA7\",[0,0,0],[120,85,10],\"base\",{round:6,material:\"metal\"}),...[-1,1].flatMap((t,a)=>[-1,1].map((n,o)=>F(\"foot_\"+a+o,\"\\u9632\\u6ED1\\u811A\",[t*46,n*30,-8],6,7,\"base\",{material:\"dark\"}))),z(\"board\",\"\\u529F\\u80FD\\u677F\",[0,0,15],[96,62,4],\"board\",{round:3,material:\"cool\",explode:[0,0,35]}),z(\"chip\",\"\\u5904\\u7406\\u6A21\\u5757\",[-16,0,22],[25,24,10],\"board\",{round:2,material:\"dark\",explode:[0,0,35]}),z(\"connector\",\"\\u8FDE\\u63A5\\u7AEF\\u53E3\",[32,0,24],[16,36,14],\"board\",{round:2,material:\"warm\",explode:[0,0,35]}),...ut(0,0,20,\"board\",2,24),z(\"spreader\",\"\\u5BFC\\u70ED\\u5E95\\u677F\",[-16,0,32],[44,42,5],\"sink\",{round:2,material:\"metal\",explode:[-10,0,65]}),...Array.from({length:7},(t,a)=>z(\"fin_\"+a,\"\\u6563\\u70ED\\u9CCD\\u7247\",[-34+a*6,0,44],[2.4,40,20],\"sink\",{round:1,material:\"metal\",explode:[-10,0,65]})),z(\"top\",\"\\u9876\\u76D6\",[0,0,61],[124,89,5],\"cover\",{round:6,cover:!0,material:\"metal\",explode:[12,0,85]}),z(\"side\",\"\\u4FA7\\u677F\",[0,-44,32],[124,4,58],\"cover\",{round:2,cover:!0,material:\"metal\",explode:[12,-22,50]})]}}function pt({driverTeeth:t=16,drivenTeeth:a=32,module:n=1.5}={}){let o=W({driverTeeth:t,drivenTeeth:a,module:n}),e=o.centerDistance;return{title:\"\\u9F7F\\u8F6E\\u4F20\\u52A8 \\xB7 \\u7B80\\u5316\\u76F4\\u9F7F\\u8F6E\\u793A\\u610F\",azimuth:70,elevation:55,groups:[{id:\"frame\",label:\"\\u652F\\u67B6\",description:\"\\u56FA\\u5B9A\\u8F74\\u5FC3\\u4F4D\\u7F6E\\uFF1B\\u4E24\\u8F74\\u4E2D\\u5FC3\\u8DDD\\u7531\\u9F7F\\u6570\\u4E0E\\u6A21\\u6570\\u5171\\u540C\\u786E\\u5B9A\\u3002\"},{id:\"driver\",label:\"\\u4E3B\\u52A8\\u8F6E\",description:\"\\u8F93\\u5165\\u8F6C\\u901F\\uFF0C\\u7531\\u9F7F\\u6570\\u51B3\\u5B9A\\u8282\\u5706\\u534A\\u5F84\\u3002\"},{id:\"driven\",label:\"\\u4ECE\\u52A8\\u8F6E\",description:\"\\u5916\\u556E\\u5408\\u8F6C\\u5411\\u76F8\\u53CD\\uFF0C\\u8F6C\\u901F\\u6309\\u9F7F\\u6570\\u6BD4\\u53D8\\u5316\\u3002\"},{id:\"cover\",label:\"\\u5916\\u58F3\",description:\"\\u9690\\u85CF\\u76D6\\u677F\\u4EE5\\u89C2\\u5BDF\\u4F20\\u52A8\\uFF1B\\u4E0D\\u662F\\u52A0\\u5DE5\\u6216\\u53D7\\u529B\\u4EFF\\u771F\\u3002\"}],parts:[z(\"base\",\"\\u5B89\\u88C5\\u5E95\\u5EA7\",[30,0,-8],[140,90,10],\"frame\",{round:6,material:\"metal\"}),...ut(30,0,0,\"frame\",3,32),...[0,e].flatMap((i,r)=>[F(\"bearing_\"+r,\"\\u8F74\\u627F\\u5EA7\",[i,0,4],10,12,\"frame\",{inner:4,material:\"dark\"}),F(\"shaft_\"+r,\"\\u4F20\\u52A8\\u8F74\",[i,0,16],3.8,28,r?\"driven\":\"driver\",{material:\"metal\",explode:[r?24:-24,0,20]}),F(\"hub_\"+r,\"\\u8F6E\\u6BC2\",[i,0,27],6,6,r?\"driven\":\"driver\",{inner:3.8,material:\"warm\",explode:[r?24:-24,0,20]})]),{id:\"driverGear\",type:\"gear\",label:\"\\u4E3B\\u52A8\\u9F7F\\u8F6E\",group:\"driver\",at:[0,0,20],radius:o.driverRadius,teeth:t,module:n,height:7,inner:4,material:\"accent\",explode:[-24,0,20]},{id:\"drivenGear\",type:\"gear\",label:\"\\u4ECE\\u52A8\\u9F7F\\u8F6E\",group:\"driven\",at:[e,0,20],radius:o.drivenRadius,teeth:a,module:n,height:7,inner:4,material:\"cool\",rotation:o.drivenAngle,explode:[24,0,20]},z(\"cover\",\"\\u9632\\u62A4\\u76D6\\u677F\",[30,0,36],[140,90,4],\"cover\",{round:6,cover:!0,material:\"metal\",explode:[0,0,62]})]}}function ht({radius:t=20,rod:a=70,angle:n=0}={}){let o=X({radius:t,rod:a,angle:n,z:28});return{title:\"\\u66F2\\u67C4\\u8FDE\\u6746 \\xB7 \\u65CB\\u8F6C\\u8F6C\\u4E3A\\u5F80\\u590D\",groups:[{id:\"frame\",label:\"\\u652F\\u67B6\\u4E0E\\u5BFC\\u8F68\",description:\"\\u652F\\u6491\\u8F74\\u5FC3\\uFF0C\\u9650\\u5236\\u6ED1\\u5757\\u6CBF\\u540C\\u4E00\\u65B9\\u5411\\u8FD0\\u52A8\\u3002\"},{id:\"crank\",label:\"\\u66F2\\u67C4\",description:\"\\u66F2\\u67C4\\u7ED5\\u56FA\\u5B9A\\u8F74\\u5FC3\\u65CB\\u8F6C\\u3002\"},{id:\"rod\",label:\"\\u8FDE\\u6746\",description:\"\\u8FDE\\u63A5\\u66F2\\u67C4\\u9500\\u548C\\u6ED1\\u5757\\uFF0C\\u957F\\u5EA6\\u4FDD\\u6301\\u4E0D\\u53D8\\u3002\"},{id:\"piston\",label:\"\\u6ED1\\u5757\",description:\"\\u6CBF\\u5BFC\\u8F68\\u5F80\\u590D\\uFF1B\\u5B8C\\u6574\\u884C\\u7A0B\\u7B49\\u4E8E\\u66F2\\u67C4\\u534A\\u5F84\\u7684\\u4E24\\u500D\\u3002\"}],parts:[z(\"base\",\"\\u5B89\\u88C5\\u5E95\\u5EA7\",[35,0,-6],[175,80,10],\"frame\",{round:6,material:\"metal\"}),...ut(35,0,1,\"frame\",3,30),F(\"bearing\",\"\\u66F2\\u8F74\\u8F74\\u627F\",[0,0,7],11,15,\"frame\",{inner:4,material:\"dark\"}),F(\"axle\",\"\\u66F2\\u8F74\",[0,0,15],3.8,26,\"crank\",{material:\"metal\",explode:[0,-18,18]}),F(\"flywheel\",\"\\u66F2\\u67C4\\u76D8\",[0,0,20],t+6,6,\"crank\",{inner:4,material:\"accent\",explode:[0,-18,18]}),z(\"railA\",\"\\u5BFC\\u8F68\",[74,-16,13],[85,5,6],\"frame\",{round:1,material:\"dark\"}),z(\"railB\",\"\\u5BFC\\u8F68\",[74,16,13],[85,5,6],\"frame\",{round:1,material:\"dark\"}),F(\"crankPin\",\"\\u66F2\\u67C4\\u9500\",o.crank,3,12,\"crank\",{material:\"warm\",explode:[0,-18,18]}),{id:\"link\",type:\"rod\",label:\"\\u8FDE\\u6746\",from:o.crank,to:o.piston,radius:2.4,group:\"rod\",material:\"metal\",explode:[0,0,45]},z(\"piston\",\"\\u6ED1\\u5757\",[o.position,0,22],[18,24,16],\"piston\",{round:3,material:\"cool\",explode:[20,0,18]}),F(\"pistonPin\",\"\\u6ED1\\u5757\\u9500\",o.piston,3,10,\"piston\",{material:\"warm\",explode:[20,0,18]})]}}var St=`\n.mui-mechanism{font:15px/1.7 system-ui,sans-serif;color:var(--canvas-ink);background:var(--canvas-bg);border:1px solid var(--canvas-line);border-radius:24px;padding:20px;max-width:100%;box-sizing:border-box}.mui-mechanism *{box-sizing:border-box}.mui-mechanism h2{font-size:22px;line-height:1.35;margin:0 0 8px;font-weight:750}.mui-mechanism p{margin:0;color:var(--canvas-muted)}\n.mui-mechanism-svg{display:block;width:100%;height:auto;max-height:420px;touch-action:pan-y}.mui-mechanism-svg path{stroke:color-mix(in srgb,var(--canvas-ink) 38%,var(--canvas-bg));stroke-width:.8;stroke-linejoin:round;vector-effect:non-scaling-stroke;cursor:pointer;fill:var(--mech-base,var(--canvas-soft))}.mui-mechanism-svg [data-material=metal]{--mech-base:color-mix(in srgb,var(--canvas-muted) 32%,var(--canvas-bg))}.mui-mechanism-svg [data-material=dark]{--mech-base:color-mix(in srgb,var(--canvas-ink) 78%,var(--canvas-bg))}.mui-mechanism-svg [data-material=accent]{--mech-base:var(--canvas-accent)}.mui-mechanism-svg [data-material=cool]{--mech-base:color-mix(in srgb,#22998a 70%,var(--canvas-bg))}.mui-mechanism-svg [data-material=warm]{--mech-base:color-mix(in srgb,#dd9c42 75%,var(--canvas-bg))}\n.mui-mechanism-svg [data-tone=\"0\"]{fill:color-mix(in srgb,var(--mech-base) 74%,var(--canvas-bg))}.mui-mechanism-svg [data-tone=\"1\"]{fill:var(--mech-base)}.mui-mechanism-svg [data-tone=\"2\"]{fill:color-mix(in srgb,var(--mech-base) 85%,var(--canvas-ink))}.mui-mechanism-svg [data-tone=\"3\"]{fill:color-mix(in srgb,var(--mech-base) 68%,var(--canvas-ink))}.mui-mechanism-svg [data-line]{fill:none;stroke:var(--mech-base);stroke-width:2}.mui-mechanism-svg [data-active=false]{opacity:.2}.mui-mechanism-svg [data-active=true]{opacity:1}\n.mui-mechanism-nav{display:flex;flex-wrap:wrap;gap:7px;margin:8px 0 12px}.mui-mechanism button{font:inherit;color:var(--canvas-ink);background:var(--canvas-bg);border:1px solid var(--canvas-line);border-radius:999px;padding:6px 13px;cursor:pointer;min-height:36px}.mui-mechanism button[aria-pressed=true]{color:var(--canvas-bg);background:var(--canvas-ink);border-color:var(--canvas-ink)}.mui-mechanism button:focus-visible,.mui-mechanism input:focus-visible{outline:2px solid var(--canvas-accent);outline-offset:3px}.mui-mechanism button:disabled{opacity:.5;cursor:default}\n.mui-mechanism-controls{display:flex;align-items:center;flex-wrap:wrap;gap:14px;padding:12px 14px;border-radius:16px;background:var(--canvas-soft);margin:8px 0}.mui-mechanism-controls label{display:flex;align-items:center;gap:9px;flex:1 1 180px;min-width:0}.mui-mechanism-controls input[type=range]{flex:1;min-width:70px;accent-color:var(--canvas-accent)}.mui-mechanism-controls input[type=checkbox]{accent-color:var(--canvas-accent)}.mui-mechanism-controls output{font-variant-numeric:tabular-nums;min-width:40px;text-align:right;font-weight:650}.mui-mechanism-readout{display:block;padding:10px 0;font-weight:700;font-variant-numeric:tabular-nums;color:var(--canvas-ink)}.mui-mechanism-description{min-height:0;font-size:14px}\n.mui-mechanism[data-style=editorial]{border:0;border-top:1px solid var(--canvas-line);border-radius:0;padding:16px 0}.mui-mechanism[data-style=editorial] h2{font-family:ui-serif,Georgia,serif;font-weight:650}.mui-mechanism[data-style=editorial] .mui-mechanism-controls{border-radius:0;background:transparent;border-block:1px solid var(--canvas-line)}\n.mui-mechanism[data-style=technical]{border-radius:10px}.mui-mechanism[data-style=technical] button,.mui-mechanism[data-style=technical] .mui-mechanism-controls{border-radius:6px}.mui-mechanism[data-style=technical] .mui-mechanism-readout{font-family:ui-monospace,Consolas,monospace}\n.mui-mechanism[data-style=soft]{border:0;background:var(--canvas-soft);border-radius:28px 28px 28px 10px}.mui-mechanism[data-style=soft] .mui-mechanism-controls{background:var(--canvas-bg);border-radius:22px}.mui-mechanism[data-style=expressive] h2{font-size:28px;font-weight:850;border-left:5px solid var(--canvas-accent);padding-left:14px}.mui-mechanism[data-style=expressive]{border:0;border-radius:0}.mui-mechanism[data-style=prismframe]{border:1.5px solid var(--canvas-line);box-shadow:4px 5px 0 color-mix(in srgb,var(--canvas-accent) 14%,var(--canvas-bg))}.mui-mechanism[data-style=aurora]{border-radius:28px;box-shadow:0 16px 30px -25px #30204d66}.mui-mechanism[data-style=aurora] .mui-mechanism-controls{border-radius:24px}\n@media(max-width:500px){.mui-mechanism{padding:14px;border-radius:20px}.mui-mechanism h2{font-size:20px}.mui-mechanism-controls{padding:10px}.mui-mechanism-nav{gap:5px}.mui-mechanism button{padding:6px 10px;font-size:14px}}\n@media(prefers-reduced-motion:reduce){.mui-mechanism *{animation:none!important;transition:none!important}}\n@media(forced-colors:active){.mui-mechanism-svg path{fill:Canvas;stroke:CanvasText}.mui-mechanism button[aria-pressed=true]{background:Highlight;color:HighlightText}}\n`;var xt=\"http://www.w3.org/2000/svg\",C=(t,a,n)=>{let o=document.createElement(t);return a&&(o.className=a),n!==void 0&&(o.textContent=n),o};function Ft(t){function a(o,e){let i=typeof o==\"string\"?document.querySelector(o):o;if(!i||!i.replaceChildren)throw Error(\"Motion mechanism: target element not found\");Y(e);let r=structuredClone({...e,onSelect:void 0}),p=nt(r),d={explode:0,selected:\"all\",cutaway:!1,pose:{}},m=!1,s=\"\",c=\"\",u=0,f=new Map,g=new Map,h=new Map,M=[],O=C(\"section\",\"mui-mechanism\"),N=C(\"h2\",null,r.title??\"\\u7ED3\\u6784\\u56FE\\u89E3\"),$=document.createElementNS(xt,\"svg\"),P=document.createElementNS(xt,\"g\"),A=C(\"div\",\"mui-mechanism-nav\"),w=C(\"div\",\"mui-mechanism-controls\"),T=C(\"p\",\"mui-mechanism-description\"),D=C(\"output\",\"mui-mechanism-readout\"),G=C(\"style\");G.textContent=St,N.hidden=r.title===\"\",O.setAttribute(\"aria-label\",r.title??\"\\u7ED3\\u6784\\u56FE\\u89E3\"),$.setAttribute(\"class\",\"mui-mechanism-svg\"),$.setAttribute(\"viewBox\",\"0 0 600 340\"),$.setAttribute(\"role\",\"img\"),$.setAttribute(\"aria-label\",\"\\u53EF\\u62C6\\u89E3\\u7684\\u7ED3\\u6784\\u793A\\u610F\\u56FE\\uFF1B\\u4F7F\\u7528\\u4E0B\\u65B9\\u6309\\u94AE\\u9009\\u62E9\\u90E8\\u4EF6\"),$.append(P),O.append(G,N,$,A,w,D,T),i.replaceChildren(O);let S=()=>r.groups?.length?r.groups:r.parts.map(l=>({id:l.id,label:l.label??l.id,description:l.description??\"\"}));function H(l){r.bind?.selected?t.setState({[r.bind.selected]:l}):typeof e.onSelect==\"function\"?e.onSelect(l):Q({selected:l})}function K(){if(A.replaceChildren(),h.clear(),!r.bind?.selected&&typeof e.onSelect!=\"function\"){A.hidden=!0;return}A.hidden=!1;for(let l of[{id:\"all\",label:\"\\u5168\\u90E8\",description:\"\"},...S()]){let b=C(\"button\",null,l.label);b.type=\"button\",b.setAttribute(\"aria-pressed\",String(d.selected===l.id)),b.addEventListener(\"click\",()=>H(l.id)),h.set(l.id,b),A.append(b)}}let x,rt,L;if(r.bind?.explode){let l=C(\"label\",null,\"\\u62C6\\u89E3\\u8FDB\\u5EA6\");x=C(\"input\"),x.type=\"range\",x.min=\"0\",x.max=\"1\",x.step=\".01\",x.setAttribute(\"aria-label\",\"\\u62C6\\u89E3\\u8FDB\\u5EA6\"),rt=C(\"output\"),x.addEventListener(\"input\",()=>t.setState({[r.bind.explode]:Number(x.value)})),l.append(x,rt),w.append(l)}if(r.bind?.cutaway&&r.parts.some(l=>l.cover)){let l=C(\"label\",null,\"\\u5256\\u5F00\\u5916\\u58F3\");L=C(\"input\"),L.type=\"checkbox\",L.setAttribute(\"aria-label\",\"\\u5256\\u5F00\\u5916\\u58F3\"),L.addEventListener(\"change\",()=>t.setState({[r.bind.cutaway]:L.checked})),l.prepend(L),w.append(l)}w.hidden=!w.childNodes.length;function jt(l){let b=JSON.stringify([l.explode,l.cutaway]);b!==s&&(p=nt(r,l),s=b);let E=mt(r,p,l,f),k=new Set;for(let j of E){k.add(j.key);let R=g.get(j.key);R||(R=document.createElementNS(xt,\"path\"),R.setAttribute(\"fill-rule\",\"evenodd\"),g.set(j.key,R));for(let[gt,bt]of Object.entries({d:j.d,\"data-part\":j.part,\"data-material\":j.material,\"data-tone\":j.tone,\"data-active\":j.active}))R.getAttribute(gt)!==String(bt)&&R.setAttribute(gt,String(bt));j.line?R.setAttribute(\"data-line\",\"\"):R.removeAttribute(\"data-line\")}for(let[j,R]of g)k.has(j)||(R.remove(),g.delete(j));let Z=E.map(j=>j.key).join(\"|\");if(Z!==c){for(let j of E)P.append(g.get(j.key));c=Z}u++}function Q(l={}){if(m)return;for(let k of Object.keys(l))if(![\"explode\",\"selected\",\"cutaway\",\"pose\",\"parts\",\"readout\"].includes(k))throw Error(\"Motion mechanism: unknown update field \"+k);if(l.parts){let k={...r,parts:l.parts};Y(k);let Z=nt(k);mt(k,Z,{...d,...l},new Map),r=k,p=Z,f.clear(),c=\"\",s=\"\"}let b={...d,...l};l.pose&&(b.pose=l.pose),jt(b),d=b;for(let[k,Z]of h)Z.setAttribute(\"aria-pressed\",String(b.selected===k));let E=S().find(k=>k.id===b.selected)??r.parts.find(k=>k.id===b.selected);T.textContent=E?.description??(b.selected===\"all\"?\"\\u9009\\u62E9\\u4E00\\u4E2A\\u90E8\\u4EF6\\uFF0C\\u67E5\\u770B\\u5B83\\u7684\\u804C\\u8D23\\uFF1B\\u62C6\\u89E3\\u53EA\\u5C55\\u793A\\u88C5\\u914D\\u5173\\u7CFB\\u3002\":\"\"),x&&(x.value=String(b.explode),rt.textContent=Math.round(b.explode*100)+\"%\"),L&&(L.checked=b.cutaway),l.readout!==void 0&&(D.textContent=String(l.readout))}return $.addEventListener(\"click\",l=>{let b=l.target.closest(\"[data-part]\");if(!b)return;let E=r.parts.find(k=>k.id===b.getAttribute(\"data-part\"));E&&H(r.groups?.length?E.group??E.id:E.id)}),K(),M.push(t.subscribe(l=>{if(m)return;let b={};for(let[E,k]of Object.entries(r.bind??{})){if(!Object.hasOwn(l.state,k))throw Error(\"Motion mechanism: bind.\"+E+\" names undeclared state \"+k);b[E]=l.state[k]}O.dataset.style=l.theme?.visualStyle??\"\";for(let E of h.values())E.disabled=l.disabled;x&&(x.disabled=l.disabled),L&&(L.disabled=l.disabled),Q(b)})),Object.freeze({update:Q,pose:l=>Q({pose:l}),stats:()=>({frames:u,faces:g.size,parts:r.parts.length}),destroy(){m||(m=!0,M.forEach(l=>l()),g.clear(),f.clear(),i.replaceChildren())}})}function n(o,e,i={}){if(![\"assembly\",\"gears\",\"crank\"].includes(e))throw Error(\"Motion mechanism: demo must be assembly/gears/crank\");let r=i.bind??{},p=()=>t.snapshot.state,d=(A,w)=>Object.hasOwn(r,A)?p()[r[A]]:w;for(let[A,w]of Object.entries(r))if(![\"explode\",\"selected\",\"cutaway\",\"rpm\",\"teeth\",\"running\",\"angle\",\"radius\"].includes(A)||typeof w!=\"string\"||!Object.hasOwn(p(),w))throw Error(\"Motion mechanism: invalid demo binding \"+A);let m=d(\"angle\",0)*Math.PI/180,s=m,c=\"\",u=!1,f=Object.fromEntries(Object.entries(r).filter(([A])=>[\"explode\",\"selected\",\"cutaway\"].includes(A)));function g(){return e===\"assembly\"?ft():e===\"gears\"?pt({driverTeeth:i.driverTeeth??16,drivenTeeth:d(\"teeth\",32),module:i.module??1.5}):ht({radius:d(\"radius\",20),rod:i.rod??70})}let h=a(o,{...g(),...i.title===void 0?{}:{title:i.title},bind:f}),M=!1;function O(A=0,w=!1,T=!0){if(M)return;let D=d(\"rpm\",30),G=d(\"running\",!1),S=d(\"angle\",0)*Math.PI/180,H=s;S!==m?(H=S,m=S):G&&T&&!w&&(H+=D*Math.PI*2/60*A),s=H;let K=JSON.stringify([e,d(\"teeth\",32),d(\"radius\",20)]);if(K!==c&&(h.update({parts:g().parts}),c=K),e===\"gears\"){let x=W({driverTeeth:i.driverTeeth??16,drivenTeeth:d(\"teeth\",32),rpm:D,angle:s,module:i.module??1.5});h.update({pose:{driverGear:{rotation:x.driverAngle},drivenGear:{rotation:x.drivenAngle}},readout:\"\\u4E3B\\u52A8\\u8F6E \"+t.formatNumber(x.driverRpm,0)+\" rpm \\xB7 \\u4ECE\\u52A8\\u8F6E \"+t.formatNumber(x.drivenRpm,2)+\" rpm \\xB7 \\u9F7F\\u6570\\u6BD4 \"+t.formatNumber(x.ratio,2)+\"\\uFF08\\u8D1F\\u53F7\\u8868\\u793A\\u53CD\\u5411\\uFF09\"})}else if(e===\"crank\"){let x=X({angle:s,radius:d(\"radius\",20),rod:i.rod??70,z:28});h.update({pose:{crankPin:{at:x.crank},link:{from:x.crank,to:x.piston},piston:{at:[x.position,0,22]},pistonPin:{at:x.piston}},readout:\"\\u66F2\\u67C4\\u89D2 \"+t.formatNumber((s*180/Math.PI%360+360)%360,1)+\"\\xB0 \\xB7 \\u6ED1\\u5757\\u4F4D\\u7F6E \"+t.formatNumber(x.position,2)+\" \\xB7 \\u884C\\u7A0B \"+t.formatNumber(x.stroke,1)+\"\\uFF08\\u793A\\u610F\\u957F\\u5EA6\\u5355\\u4F4D\\uFF09\"})}u=w}let N=null,$=()=>{O(0,u,!0),e!==\"assembly\"&&(d(\"running\",!1)&&!N?N=t.animate(({delta:A,reducedMotion:w,visible:T})=>{T&&!w?O(A,!1,!0):u!==w&&O(0,w,T)}):!d(\"running\",!1)&&N&&(N(),N=null))},P=t.subscribe($);return O(),Object.freeze({...h,destroy(){M||(M=!0,N?.(),P(),h.destroy())}})}return Object.freeze({mount:a,mountDemo:n,gearPair:W,sliderCrank:X,follow:Mt,scenes:Object.freeze({assembly:ft,gears:pt,crank:ht})})}return _t(Tt);})();\n";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {gearPair,sliderCrank} from './mechanism-geometry.js';
|
|
2
|
+
const box=(id,label,at,size,group,extra={})=>({id,type:'box',label,at,size,group,...extra});
|
|
3
|
+
const round=(id,label,at,radius,height,group,extra={})=>({id,type:'cylinder',label,at,radius,height,group,...extra});
|
|
4
|
+
function bolts(cx,cy,z,group,r=3,spread=18){return [[-1,-1],[-1,1],[1,-1],[1,1]].map(([x,y],i)=>round(group+'_bolt_'+i,'固定螺栓',[cx+x*spread,cy+y*spread,z],r,3,group,{material:'dark',explode:[0,0,26]}))}
|
|
5
|
+
export function assemblyScene(){return{title:'分层结构 · 示意装配',groups:[{id:'base',label:'底座',description:'支撑整体,并提供安装位置。'},{id:'board',label:'功能板',description:'连接输入与处理模块;示意结构,不对应特定产品。'},{id:'sink',label:'散热层',description:'将热量传到更大的表面积。'},{id:'cover',label:'外壳',description:'保护内部;剖开选项通过隐藏外壳展示结构。'}],parts:[
|
|
6
|
+
box('base','安装底座',[0,0,0],[120,85,10],'base',{round:6,material:'metal'}),...[-1,1].flatMap((x,i)=>[-1,1].map((y,j)=>round('foot_'+i+j,'防滑脚',[x*46,y*30,-8],6,7,'base',{material:'dark'}))),
|
|
7
|
+
box('board','功能板',[0,0,15],[96,62,4],'board',{round:3,material:'cool',explode:[0,0,35]}),box('chip','处理模块',[-16,0,22],[25,24,10],'board',{round:2,material:'dark',explode:[0,0,35]}),box('connector','连接端口',[32,0,24],[16,36,14],'board',{round:2,material:'warm',explode:[0,0,35]}),...bolts(0,0,20,'board',2,24),
|
|
8
|
+
box('spreader','导热底板',[-16,0,32],[44,42,5],'sink',{round:2,material:'metal',explode:[-10,0,65]}),...Array.from({length:7},(_,i)=>box('fin_'+i,'散热鳍片',[-34+i*6,0,44],[2.4,40,20],'sink',{round:1,material:'metal',explode:[-10,0,65]})),
|
|
9
|
+
box('top','顶盖',[0,0,61],[124,89,5],'cover',{round:6,cover:true,material:'metal',explode:[12,0,85]}),box('side','侧板',[0,-44,32],[124,4,58],'cover',{round:2,cover:true,material:'metal',explode:[12,-22,50]})]}}
|
|
10
|
+
export function gearsScene({driverTeeth=16,drivenTeeth=32,module=1.5}={}){const g=gearPair({driverTeeth,drivenTeeth,module}),x=g.centerDistance;return{title:'齿轮传动 · 简化直齿轮示意',azimuth:70,elevation:55,groups:[{id:'frame',label:'支架',description:'固定轴心位置;两轴中心距由齿数与模数共同确定。'},{id:'driver',label:'主动轮',description:'输入转速,由齿数决定节圆半径。'},{id:'driven',label:'从动轮',description:'外啮合转向相反,转速按齿数比变化。'},{id:'cover',label:'外壳',description:'隐藏盖板以观察传动;不是加工或受力仿真。'}],parts:[
|
|
11
|
+
box('base','安装底座',[30,0,-8],[140,90,10],'frame',{round:6,material:'metal'}),...bolts(30,0,0,'frame',3,32),
|
|
12
|
+
...[0,x].flatMap((cx,i)=>[round('bearing_'+i,'轴承座',[cx,0,4],10,12,'frame',{inner:4,material:'dark'}),round('shaft_'+i,'传动轴',[cx,0,16],3.8,28,i?'driven':'driver',{material:'metal',explode:[i?24:-24,0,20]}),round('hub_'+i,'轮毂',[cx,0,27],6,6,i?'driven':'driver',{inner:3.8,material:'warm',explode:[i?24:-24,0,20]})]),
|
|
13
|
+
{id:'driverGear',type:'gear',label:'主动齿轮',group:'driver',at:[0,0,20],radius:g.driverRadius,teeth:driverTeeth,module,height:7,inner:4,material:'accent',explode:[-24,0,20]},
|
|
14
|
+
{id:'drivenGear',type:'gear',label:'从动齿轮',group:'driven',at:[x,0,20],radius:g.drivenRadius,teeth:drivenTeeth,module,height:7,inner:4,material:'cool',rotation:g.drivenAngle,explode:[24,0,20]},
|
|
15
|
+
box('cover','防护盖板',[30,0,36],[140,90,4],'cover',{round:6,cover:true,material:'metal',explode:[0,0,62]})]}}
|
|
16
|
+
export function crankScene({radius=20,rod=70,angle=0}={}){const k=sliderCrank({radius,rod,angle,z:28});return{title:'曲柄连杆 · 旋转转为往复',groups:[{id:'frame',label:'支架与导轨',description:'支撑轴心,限制滑块沿同一方向运动。'},{id:'crank',label:'曲柄',description:'曲柄绕固定轴心旋转。'},{id:'rod',label:'连杆',description:'连接曲柄销和滑块,长度保持不变。'},{id:'piston',label:'滑块',description:'沿导轨往复;完整行程等于曲柄半径的两倍。'}],parts:[
|
|
17
|
+
box('base','安装底座',[35,0,-6],[175,80,10],'frame',{round:6,material:'metal'}),...bolts(35,0,1,'frame',3,30),round('bearing','曲轴轴承',[0,0,7],11,15,'frame',{inner:4,material:'dark'}),round('axle','曲轴',[0,0,15],3.8,26,'crank',{material:'metal',explode:[0,-18,18]}),round('flywheel','曲柄盘',[0,0,20],radius+6,6,'crank',{inner:4,material:'accent',explode:[0,-18,18]}),
|
|
18
|
+
box('railA','导轨',[74,-16,13],[85,5,6],'frame',{round:1,material:'dark'}),box('railB','导轨',[74,16,13],[85,5,6],'frame',{round:1,material:'dark'}),
|
|
19
|
+
round('crankPin','曲柄销',k.crank,3,12,'crank',{material:'warm',explode:[0,-18,18]}),{id:'link',type:'rod',label:'连杆',from:k.crank,to:k.piston,radius:2.4,group:'rod',material:'metal',explode:[0,0,45]},
|
|
20
|
+
box('piston','滑块',[k.position,0,22],[18,24,16],'piston',{round:3,material:'cool',explode:[20,0,18]}),round('pistonPin','滑块销',k.piston,3,10,'piston',{material:'warm',explode:[20,0,18]})]}}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {EXPRESSIVE_EXAMPLES} from './expressive-examples.js';
|
|
2
|
+
import {validateDocument} from './protocol.js';
|
|
3
|
+
const ref=key=>({ref:key}),constant=value=>({const:value});
|
|
4
|
+
export const MOTION_RECIPES=Object.freeze([
|
|
5
|
+
...EXPRESSIVE_EXAMPLES,
|
|
6
|
+
{id:'plan',name:'方案切换',description:'分段选择、本地计算与精确指标',document:{version:1,title:'订阅预算 · 示例',fallback:'示例单价 29 元/人/月,按月数和人数计算,不是真实报价。',presentation:{preset:'brief'},state:{months:{type:'number',default:1,min:1,max:12,step:1,options:[1,3,12]},seats:{type:'number',default:2,min:1,max:8,step:1}},derived:{total:{op:'mul',args:[constant(29),ref('state.months'),ref('state.seats')]}},nodes:[{id:'period',type:'select',label:'购买月数',bind:'state.months',variant:'segmented'},{id:'seats',type:'slider',label:'使用人数',bind:'state.seats'},{id:'total',type:'metric',label:'示例合计',value:ref('derived.total'),unit:'元',decimals:0},{id:'budget',type:'progress',label:'占 3000 元预算',value:ref('derived.total'),max:3000,unit:'元'},{id:'note',type:'text',text:'示例单价 29 元/人/月,不是真实报价。'}]}},
|
|
7
|
+
{id:'checklist',name:'任务勾选',description:'真实勾选状态联动完成进度',document:{version:1,title:'发布前自查 · 示例',fallback:'勾选实际完成的检查项,进度在本地更新。',presentation:{preset:'brief'},state:{readme:{type:'boolean',default:false},test:{type:'boolean',default:false},license:{type:'boolean',default:false}},derived:{done:{op:'add',args:['readme','test','license'].map(k=>({op:'if',args:[ref('state.'+k),constant(1),constant(0)]}))}},nodes:[{id:'readme',type:'toggle',label:'说明文档已检查',bind:'state.readme'},{id:'test',type:'toggle',label:'实际测试已完成',bind:'state.test'},{id:'license',type:'toggle',label:'许可证已确认',bind:'state.license'},{id:'done',type:'progress',label:'已完成检查',value:ref('derived.done'),max:3,unit:'项'},{id:'note',type:'text',text:'仅记录用户勾选,不代表插件已执行这些检查。'}]}},
|
|
8
|
+
{id:'copy',name:'复制反馈',description:'复用文本/代码,成功或失败均有反馈',document:{version:1,title:'可直接使用的内容',fallback:'git status\ngit diff',nodes:[{id:'draft',type:'text',title:'提交说明',text:'补充使用说明并修复交互问题。',copyable:true},{id:'code',type:'code',title:'检查本地改动',lang:'bash',code:'git status\ngit diff'}]}},
|
|
9
|
+
{id:'plot',name:'参数图解',description:'安全函数表达式,本地实时重绘',document:{title:'振幅探索',items:[{type:'plot',title:'y = a sin(x)',xMin:-6.28,xMax:6.28,series:[{expr:'a*sin(x)',label:'正弦',params:[{name:'a',value:1,min:0,max:3,step:0.1}]}]},{type:'text',content:'调整 a 改变振幅。函数在浏览器本地绘制,无需再次调用模型。'}]}}
|
|
10
|
+
]);
|
|
11
|
+
export function motionCatalog(id){
|
|
12
|
+
if(!id)return{recipes:MOTION_RECIPES.map(({id,name,description})=>({id,name,description})),usage:'ui_catalog({topic:"motion",scene:"plan"}) returns one complete example; adapt real content and selected guided style. Do not load every recipe.'};
|
|
13
|
+
const recipe=MOTION_RECIPES.find(r=>r.id===id);if(!recipe)throw Error('Unknown motion recipe; choose '+MOTION_RECIPES.map(r=>r.id).join(', '));
|
|
14
|
+
return{...recipe,document:validateDocument(recipe.document),guidance:'All feedback follows actual state/results. Native controls and graph transitions are built in; no extra JS is needed. Preview is read-only until turn end. Do not copy sample prices as real data or simulate success with a timer.'};
|
|
15
|
+
}
|
|
16
|
+
export const MOTION_GUIDE='Native controls already provide local feedback: segmented select for2..8 short options, slider fill, numeric stepper, elastic toggle, button/link hover and real copy confirmation. Values stay exact immediately; related graphics may transition. No custom script is required for native feedback. For richer motion retrieve one motion recipe; choose animation that explains a real change, honor reduced motion, never simulate task success.';
|
|
17
|
+
export const MOTION_INTERFACE='Native control feedback is built in and respects reduced motion; canvas animation is model-authored. No palette or layout is imposed.';
|
|
@@ -6,7 +6,7 @@ import {COMPONENTS} from './components.js';
|
|
|
6
6
|
const own = (v,k) => Object.hasOwn(v,k);
|
|
7
7
|
const singleItem = v => object(v) && Object.keys(v).length === 1 && own(v,'item');
|
|
8
8
|
const array = v => {let candidate=v;for(let i=0;i<8&&singleItem(candidate);i++)candidate=candidate.item;return Array.isArray(candidate)?candidate:v};
|
|
9
|
-
const numericOps = new Set(['add','sub','mul','div','min','max','round','abs','lt','lte','gt','gte']);
|
|
9
|
+
const numericOps = new Set(['add','sub','mul','div','min','max','round','ceil','floor','abs','lt','lte','gt','gte']);
|
|
10
10
|
function number(v) {
|
|
11
11
|
if (typeof v !== 'string' || !/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(v.trim())) return v;
|
|
12
12
|
const n = Number(v);
|
|
@@ -15,6 +15,7 @@ function number(v) {
|
|
|
15
15
|
const boolean = v => v === 'true' ? true : v === 'false' ? false : v;
|
|
16
16
|
function expression(e, numeric = false, logical = false) {
|
|
17
17
|
if (!object(e)) return e;
|
|
18
|
+
if (Object.keys(e).length===1 && own(e,'expr') && typeof e.expr==='number' && Number.isFinite(e.expr)) { e.const=e.expr;delete e.expr; }
|
|
18
19
|
if (own(e,'const')) {
|
|
19
20
|
if (numeric) e.const = number(e.const);
|
|
20
21
|
if (logical) e.const = boolean(e.const);
|
|
@@ -29,6 +30,7 @@ export function normalizeAuthoring(doc) {
|
|
|
29
30
|
if (object(doc)&&!own(doc,'nodes')&&(own(doc,'items')||typeof doc.type==='string')) doc=visualDocument(doc);
|
|
30
31
|
if (!object(doc)) return doc;
|
|
31
32
|
if (own(doc,'version')) doc.version = number(doc.version);
|
|
33
|
+
if(object(doc.presentation)&&doc.presentation.layout==='flow'&&(!own(doc.presentation,'preset')||doc.presentation.preset==='flow')){doc.presentation.layout='guide';doc.presentation.preset='flow';}
|
|
32
34
|
if (object(doc.state)) for (const k of Object.keys(doc.state)) {
|
|
33
35
|
let s = doc.state[k];
|
|
34
36
|
// Decode a single JSON object only where a state specification is required.
|
|
@@ -54,6 +56,12 @@ export function normalizeAuthoring(doc) {
|
|
|
54
56
|
if (!Array.isArray(list)) return list;
|
|
55
57
|
for (const node of list) {
|
|
56
58
|
if (!object(node)) continue;
|
|
59
|
+
// Code always has a copy button. false is conflicting intent and stays invalid.
|
|
60
|
+
if(node.type==='code'&&(node.copyable===true||node.copyable==='true'))delete node.copyable;
|
|
61
|
+
if(['slider','input'].includes(node.type)&&typeof node.bind==='string'&&node.bind.startsWith('state.')){
|
|
62
|
+
const spec=doc.state?.[node.bind.slice(6)];
|
|
63
|
+
if(spec?.type==='number')for(const field of ['min','max','step'])if(own(node,field)&&number(node[field])===spec[field])delete node[field];
|
|
64
|
+
}
|
|
57
65
|
if(['text','heading','callout'].includes(node.type)&&own(node,'content')){
|
|
58
66
|
// Collapse aliases only when no conflicting content would be lost.
|
|
59
67
|
if(!own(node,'text')){node.text=node.content;delete node.content}
|
|
@@ -79,6 +87,10 @@ export function normalizeAuthoring(doc) {
|
|
|
79
87
|
for(const k of Object.keys(node))delete node[k];
|
|
80
88
|
Object.assign(node,{id,type:'visual',spec});
|
|
81
89
|
}
|
|
90
|
+
// Positional cells map losslessly to an explicit ordered, unique column list.
|
|
91
|
+
if(node.type==='table'&&Array.isArray(node.columns)&&node.columns.length&&node.columns.every(c=>object(c)&&typeof c.key==='string')&&new Set(node.columns.map(c=>c.key)).size===node.columns.length&&Array.isArray(node.rows)&&node.rows.every(row=>Array.isArray(row)&&row.length===node.columns.length)){
|
|
92
|
+
node.rows=node.rows.map(row=>Object.fromEntries(node.columns.map((c,i)=>[c.key,row[i]])));
|
|
93
|
+
}
|
|
82
94
|
if(node.type==='table'&&Array.isArray(node.columns)&&node.columns.length&&Array.isArray(node.rows)&&node.rows.every(Array.isArray)&&node.columns.every(c=>object(c)&&Number.isInteger(c.key)&&c.key>=0)&&new Set(node.columns.map(c=>c.key)).size===node.columns.length){
|
|
83
95
|
const columns=node.columns.map(c=>({...c,key:'col_'+c.key}));
|
|
84
96
|
node.rows=node.rows.map(row=>Object.fromEntries(node.columns.map(c=>['col_'+c.key,row[c.key]??null])));node.columns=columns;
|
|
@@ -91,7 +103,7 @@ export function normalizeAuthoring(doc) {
|
|
|
91
103
|
if (!own(node,'title')) node.title = doc.title;
|
|
92
104
|
if (!own(node,'fallback')) node.fallback = doc.fallback;
|
|
93
105
|
}
|
|
94
|
-
for (const field of ['children','items','columns','stateKeys','dataKeys','derivedKeys']) if (own(node,field)) node[field] = array(node[field]);
|
|
106
|
+
for (const field of ['children','items','columns','libraries','stateKeys','dataKeys','derivedKeys']) if (own(node,field)) node[field] = array(node[field]);
|
|
95
107
|
if (['table','chart'].includes(node.type) && own(node,'rows')) node.rows = array(node.rows);
|
|
96
108
|
for (const field of ['decimals','height']) if (own(node,field)) node[field] = number(node[field]);
|
|
97
109
|
if(['row','column'].includes(node.type)){if(own(node,'gap'))node.gap=number(node.gap);if(node.type==='row'&&own(node,'columns'))node.columns=number(node.columns)}
|
|
@@ -103,6 +115,7 @@ export function normalizeAuthoring(doc) {
|
|
|
103
115
|
if (node.type === 'details' && own(node,'open')) node.open = boolean(node.open);
|
|
104
116
|
if(node.type==='text'&&own(node,'copyable'))node.copyable=boolean(node.copyable);
|
|
105
117
|
if(node.type==='code'&&own(node,'wrap'))node.wrap=boolean(node.wrap);
|
|
118
|
+
if(['table','chart'].includes(node.type)&&Array.isArray(node.rows))for(const row of node.rows)if(object(row))for(const cell of Object.values(row))expression(cell);
|
|
106
119
|
for (const field of ['text','value','max','rows']) if (own(node,field)) expression(node[field],node.type === 'progress');
|
|
107
120
|
if (['row','column','card','details'].includes(node.type) && own(node,'children')) node.children = nodes(node.children);
|
|
108
121
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {AUTHORING_GUIDE} from './authoring-guide.js';
|
|
2
|
+
// Shared interface only; guided mode adds design/style, free mode does not.
|
|
3
|
+
export const TOOL_DIRECTORY=`Motion UI entries (native calls, never inside PTC/run_code): render_scene=flow/comparison/allocation/budget from short task data; render_text=readable text/lists/comparisons/copyable text/code/links; render_ui=native state/formulas or extended visuals; render_canvas=custom HTML/SVG/JS; render_mechanism=assembly/gears/crank with generated controls (driver16 teeth, rod70). Static explanations are complete UIs too. Optional short entry render_scene: flow/comparison items; allocation total/unit/items[{label,percent}] sum100; budget values:{people,nights,roomRate,transportPerPerson}, two adults/room. Explicitly label assumed prices. Other content stays custom.
|
|
4
|
+
ui_catalog is optional: text=typography, native=full v1 protocol, visual=extended fields, canvas=API/design/examples, style=selected guided style and examples, templates=scene examples, motion=interaction recipes, mechanism=custom part API. Prefer one targeted topic/scene, not all. No installation-source search is required; file/search tools remain available for the user's actual task. validate_ui checks without displaying a UI; if used, require valid:true AND complete:true before rendering. A successful render still delivers the UI.`;
|
|
5
|
+
export const NATIVE_CONTRACT=`Native v1: render_ui({document:{version:1,title,fallback,nodes:[],state?:{},data?:{},derived?:{},actions?:{},presentation?:{layout,accent,density,preset}}}). IDs: unique Unicode-letter/underscore identifiers, max64, then letters/digits/underscores; no constructor/prototype/__proto__. presentation.layout ONLY dashboard/workbench/comparison/guide/status/report; omit for Host defaults. Typography presets are separate: flow/reading/list are NOT layouts (flow uses layout:"guide" or render_text preset:"flow").
|
|
6
|
+
render_text({title?,preset?,accent?,blocks:[]}) accepts strings/native nodes with auto IDs. Presets reading/brief/comparison/list/callout/scorecard/flow/minimal. text/heading:{text OR spans:[{text,size?,weight?,color?}],size?:12..48|sm/md/lg/xl/hero,weight?:400|500|600|700|800,color?:default/muted/accent/success/warning/danger/violet|hex,align?,lineHeight?:1.2..2.4}. Reusable text: copyable:true,title?. code:{type:"code",code,lang?,title?,wrap?}, copied literally, never executed. link/button:{text,href:http(s) OR path:real local file,variant?:primary/secondary/text}; opens only on click, no command/custom URI in path. Native text is not HTML.
|
|
7
|
+
Native nodes {id,type,...}: row/column/card use children:[nodes]; row.columns:1..4, gap:0..40; card.title optional. metric:{label,value:number|string|boolean|binding,unit?,decimals?:0..6}. table:{columns:[{key,label,unit?}],rows:[objects]|binding,label?}. chart:{kind:"bar"|"line",rows,labelKey,valueKey}; series are numeric. steps:{items:[{title,description?,status?:pending|active|done|error}]} has1..12 direct items; omit status for explanation. Extended root {title,items:[components]} is separate, never mixed with version/nodes; native text uses text, extended uses content. Query visual fields when needed; multi-question grading uses radio + one submit for all groups.
|
|
8
|
+
State keys: number={type:"number",default,min,max,step}; string={type:"string",default,options?:[scalars]}; boolean={type:"boolean",default}; json={type:"json",default:[]|{}} (16KiB/value, canvas structured state). slider/input:{id,type,label,bind:"state.key"}; bounds belong in state, never slider/input nodes. select/toggle also bind state; select needs options. Declared state creates NO controls. Canvas stateKeys are bare keys.
|
|
9
|
+
Derived:{total:{expr:"state.quantity * 12"}}; bindings {ref:"derived.total"} or {expr:"..."}. Supports state/data/derived refs, + - * /, comparisons, condition ? yes : no, min/max/round/ceil/floor/abs. round/ceil/floor/abs each take ONE argument; round uses Math.round, ties toward positive infinity. ceil(state.people / 2) handles room counts. expr must be a STRING (literal 100 or {expr:"100"}, never {expr:100}). Numeric add cannot concatenate text; use metric label/unit. No String()/toFixed()/pow/trig/arbitrary JS; use a canvas. Prevent zero denominators/non-finite results. decimals only formats display.
|
|
10
|
+
Chart/table rows may contain formula cells: [{label:"住宿",value:{expr:"ceil(state.people / 2) * state.room"}}]. data is literal. Complex many-row maps are in native catalog; no invented callbacks/operators. Share calculation sources, units and rounding. Agent follow-up: actions:{key:{type:"agent.followup",intent,stateKeys:[bare keys]}}, button.action:"key" requests a new turn; it does not execute host tools.`;
|
|
11
|
+
export const CANVAS_CONTRACT=`Canvas: render_canvas({title,fallback,html,css?,script?,height?:160..1000,libraries?:["mechanism"],state?,data?,derived?}). html is a body fragment, CSS in css, JS in script using addEventListener. Each source field max49152 characters; whole document128KiB. Inline SVG/system fonts work; external images/fonts/imports/network, eval, inline handlers and host APIs do not. Origin isolation is not a CPU/memory hard limit; avoid unbounded/heavy loops. A v1 sandbox node adds id,type:"sandbox",title,fallback,html/css/script and stateKeys/dataKeys/derivedKeys:[bare keys].
|
|
12
|
+
DSHUI.snapshot/subscribe(fn) expose {state,data,derived,disabled,visible,theme}; subscribe supplies current values immediately and returns cleanup. DSHUI.setState({key:value}) validates/persists declared user changes. Build actual labelled controls for editable parameters; subscribe updates displays, not inputs. Never echo subscribe changes or save each animation frame. Read shared derived, don't recalculate it independently. DSHUI.formatNumber(number,decimals=2) matches native rounding/grouping (0..6 decimals).
|
|
13
|
+
DSHUI.animate(({time,delta,reducedMotion,visible})=>...) returns cleanup; seconds, delta<=0.05. Integrate delta only while running; offscreen/background pauses, reduced motion gets a useful static frame. State changes preserve iframe; source replacement/reset disposes it and may reset transient phase. Important parameters persist. Theme vars --canvas-bg/soft/ink/muted/line/accent and snapshot.theme.scheme follow Host. Content determines height; SVG viewBox fits graphics. Keyboard controls, readable labels and fallback remain useful without animation. Optional .ui-* helpers (ui-edge, ui-label, ui-controls) are documented in canvas catalog.
|
|
14
|
+
Declare libraries:["mechanism"] for DSHUI.mechanism.mount/mountDemo/gearPair/sliderCrank/follow; query mechanism for custom parts/bindings. render_mechanism needs no source/catalog lookup. Educational geometry/kinematics only, not force/contact/collision/manufacturing validation.`;
|
|
15
|
+
export const DELIVERY_CONTRACT=`Presentation mode: inline. Complete content previews read-only; scripts/controls activate after the whole assistant turn ends and parameter restoration. Tool success saves the UI; continue without waiting/polling the preview. Multiple documents may accompany one reply. After each successful render_text/render_ui/render_canvas/render_mechanism/render_scene insert its returned documentId once at the intended position:
|
|
16
|
+
\`\`\`dsh-ui
|
|
17
|
+
<returned documentId>
|
|
18
|
+
\`\`\`
|
|
19
|
+
Never invent IDs or repeat the same UI source/content elsewhere. Extended JSON dsh-ui/motion-ui source fences or standalone svg fences are alternative paths; choose one path per UI. Do not end with only a promise.
|
|
20
|
+
Edit: inspect_ui for revision/state/derived/observations; absent report means unobserved, not passed. Do not poll empty reports or claim unperformed checks. Find the requested part with query:text/label/type (returns IDs/paths/positions); use detail:"nodes" with query OR nodeIds for source edits. Patch that node instead of rebuilding the whole UI. patch_ui changes existing scalar/source props; update_ui changes structure/parameters with stable operationId. Preserve original documentId/marker and compatible values. Conflicts require reread; identical retries reuse operationId.
|
|
21
|
+
Correct the named field in the complete original document, not probes/partial renders. Automatic correction is at most two rounds per user request; then simplify or explain remaining failure with a useful answer. New user edits start a new request. This is a behavior budget, not a reasoning/output/token/time limit. Never invent measurements, sources, progress or action success. UI/animation is not tool execution evidence; host/external actions follow normal DSH permissions.`;
|
|
22
|
+
export const MODEL_CONTRACT=[AUTHORING_GUIDE,NATIVE_CONTRACT,CANVAS_CONTRACT,DELIVERY_CONTRACT,TOOL_DIRECTORY].join('\n\n');
|