diff --git a/package.json b/package.json index 4beb15b..07ea999 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "validate:molecules": "node scripts/validate-molecules.mjs", - "validate:interaction": "node scripts/validate-interaction.mjs", + "validate:interaction": "node scripts/validate-interaction.mjs && node scripts/validate-orbitals.mjs", "build": "vite build", "preview": "vite preview" }, diff --git a/scripts/validate-orbitals.mjs b/scripts/validate-orbitals.mjs new file mode 100644 index 0000000..e0b016c --- /dev/null +++ b/scripts/validate-orbitals.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import {bondFrame,sampleDirection} from '../src/webgpu-cloud.js'; +import {molecules} from '../src/science-data.js'; +import {inferBonds} from '../src/molecular-model.js'; + +const benzene=molecules.find(m=>m.id==='benzene'); +for(const angle of [0,.4,Math.PI/2,2.2]) { + const atoms=benzene.atoms.map(a=>({...a,y:a.y*Math.cos(angle)-a.z*Math.sin(angle),z:a.y*Math.sin(angle)+a.z*Math.cos(angle)})); + const bonds=inferBonds(atoms,benzene.id,benzene.bonds); + for(const bond of bonds.filter(b=>b.order>1.2)) { + const {v,axial}=bondFrame(atoms,bond,bonds); + assert.equal(axial,false); + assert.ok(Math.abs(-Math.sin(angle)*v[1]+Math.cos(angle)*v[2])>1-1e-10,'π direction must follow the molecular plane after rotation'); + } +} +// A deterministic stream rejects more than 24 proposals before accepting one. +// The previous bounded sampler returned one of those rejected directions. +const original=Math.random; +let calls=0; +try { + Math.random=()=>{const i=calls++;return i<90?[.5,.25,.5][i%3]:[.5,0,0][i%3];}; + const direction=sampleDirection(1,0); + assert.ok(direction[0]>.999); + assert.ok(calls>90); +} finally {Math.random=original;} +console.log('PASS orbital rotation covariance and rejection sampling'); diff --git a/src/main.js b/src/main.js index 956221a..722eafd 100644 --- a/src/main.js +++ b/src/main.js @@ -93,7 +93,7 @@ function renderApp() { const legendPrimary=moleculeMode?(orbitalMode?'原子轨道贡献':'原子中心密度'):(orbitalMode?'波函数正相位':'高概率密度'); const legendSecondary=moleculeMode?(orbitalMode?'键区 / π 电子密度':'键区总密度'):(orbitalMode?'波函数负相位':'低概率密度'); const captionTitle=moleculeMode?(orbitalMode?'QUALITATIVE MOLECULAR ORBITAL':'MOLECULAR ELECTRON DENSITY'):(orbitalMode?'REPRESENTATIVE VALENCE ORBITAL':'TOTAL ELECTRON DENSITY'); - const captionCopy=moleculeMode?'LCAO 定性近似 · 亮线为分子骨架':orbitalMode?'颜色表示波函数相位,不是电荷':'所有占据轨道的概率密度之和'; + const captionCopy=moleculeMode?'局部轨道与键区示意 · 非量化计算':orbitalMode?'颜色表示波函数相位,不是电荷':'所有占据轨道的概率密度之和'; const modelCard=moleculeMode?`
分子模型 ${info.electrons} e⁻
${info.atomCount}可视原子中心${info.bondCount}推断键连接
@@ -161,7 +161,7 @@ function renderApp() { -
01

原子轨道组成分子轨道

分子模式用原子轨道的线性组合近似键合,并增强两个原子核之间的共享电子密度。

02

骨架帮助阅读云层

亮线表示推断的原子连接;它不是电子轨迹。多重键同时展示轴向 σ 密度与轴外 π 密度。

03

定性,而非量化计算

复杂分子的精确轨道需要量子化学数值求解;本模型用于观察几何、节点和离域趋势。

+
01

局部轨道与键区

分子模式沿局部成键几何排列原子轨道,并添加键区概率云;尚未求解分子波函数及其干涉项。

02

骨架帮助阅读云层

亮线表示原子连接;它不是电子轨迹。多重键同时展示轴向 σ 密度与轴外 π 密度。

03

定性,而非量化计算

轨道大小经过显示缩放;精确的分子轨道及电子密度需要量子化学数值求解。

`; diff --git a/src/webgpu-cloud.js b/src/webgpu-cloud.js index ae1e6f9..77eb572 100644 --- a/src/webgpu-cloud.js +++ b/src/webgpu-cloud.js @@ -96,6 +96,12 @@ function electronConfiguration(atomicNumber) { configuration.push({n,l,electrons,cumulative}); remaining -= electrons; } + // Chromium's measured ground configuration is [Ar] 3d5 4s1 (NIST). + if(atomicNumber===24){ + configuration.find(s=>s.n===4&&s.l===0).electrons=1; + configuration.find(s=>s.n===3&&s.l===2).electrons=5; + let sum=0;for(const shell of configuration){sum+=shell.electrons;shell.cumulative=sum;} + } return configuration; } @@ -158,13 +164,12 @@ function angularProbability(direction, l, orbital) { return Math.min(1,amplitude*amplitude); } -function sampleDirection(l, orbital) { - let direction = randomDirection(); - for (let attempt=0; attempt<24; attempt++) { - direction = randomDirection(); +export function sampleDirection(l, orbital) { + // Rejection sampling must never admit a rejected point into a nodal region. + for (;;) { + const direction = randomDirection(); if (Math.random() <= angularProbability(direction,l,orbital)) return direction; } - return direction; } function prepareAtomModels(atoms, mode) { @@ -194,6 +199,25 @@ function perpendicularBasis([x,y,z]) { return [u,v]; } +export function bondFrame(atoms,bond,bonds) { + const a=atoms[bond.a],b=atoms[bond.b]; + const axis=normalize([b.x-a.x,b.y-a.y,b.z-a.z]); + const 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]]; + let normal=null,strength=1e-8; + for(const center of [bond.a,bond.b]) { + const neighbors=bonds.flatMap(edge=>edge.a===center?[edge.b]:edge.b===center?[edge.a]:[]).sort((a,b)=>a-b); + for(const neighbor of neighbors) { + if(neighbor===bond.a||neighbor===bond.b)continue; + const p=atoms[neighbor],origin=atoms[center]; + const candidate=cross(axis,normalize([p.x-origin.x,p.y-origin.y,p.z-origin.z])); + const length=Math.hypot(...candidate); + if(length>strength+1e-8){strength=length;normal=candidate.map(v=>v/length);} + } + } + if(!normal){const [u,v]=perpendicularBasis(axis);return {u,v,axial:true};} + return {u:cross(normal,axis),v:normal,axial:false}; +} + function writeAtomicCloud(data, index, model, mode, radiusScale, opacityScale=1) { const {atom,atomic,configuration,frontier,representativeOrbital,samplers}=model, offset=index*8; let subshell=frontier, orbital=representativeOrbital; @@ -206,8 +230,11 @@ function writeAtomicCloud(data, index, model, mode, radiusScale, opacityScale=1) const key=`${subshell.n}:${subshell.l}`; if (!samplers.has(key)) samplers.set(key,makeRadialSampler(subshell.n,subshell.l)); const radialSample=samplers.get(key)(), radius=radialSample.radius*radiusScale, radialPhase=radialSample.phase; - const direction=sampleDirection(subshell.l,orbital); - const angularPhase=Math.sign(angularAmplitude(direction,subshell.l,orbital))||1; + const localFrame=mode==='orbital'?model.frame:null; + const sampledOrbital=localFrame?2:orbital; + let direction=sampleDirection(subshell.l,sampledOrbital); + const angularPhase=Math.sign(angularAmplitude(direction,subshell.l,sampledOrbital))||1; + if(localFrame){const {u,v,w}=localFrame;direction=direction.map((_,i)=>u[i]*direction[0]+v[i]*direction[1]+w[i]*direction[2]);} const [red,green,blue]=atomColor(atom), variation=.82+Math.random()*.18; const negative=mode==='orbital'&&radialPhase*angularPhase<0; const phaseColor=negative @@ -218,15 +245,21 @@ function writeAtomicCloud(data, index, model, mode, radiusScale, opacityScale=1) function writeBondCloud(data, index, atoms, bond, mode) { const first=atoms[bond.a], second=atoms[bond.b], offset=index*8; - const axis=[(second.x-first.x)/bond.distance,(second.y-first.y)/bond.distance,(second.z-first.z)/bond.distance]; - const [u,v]=perpendicularBasis(axis); - const t=Math.max(.04,Math.min(.96,.5+randomNormal()*.24)); + const {u,v,axial}=bond.frame; + let t; + do {t=.5+randomNormal()*.24;} while(t<.04||t>.96); let radialU=randomNormal()*.16, radialV=randomNormal()*.16; const piOrbital=bond.order>1.2&&Math.random()<.52; let phase=1; if (piOrbital) { phase=Math.random()<.5?-1:1; - radialV+=phase*(.27+Math.abs(randomNormal())*.10); + if(axial||bond.order>=2.6) { + const angle=Math.random()*Math.PI*2,r=.27+Math.abs(randomNormal())*.10; + radialU=r*Math.cos(angle);radialV=r*Math.sin(angle); + } else { + // p-like probability: y² exp(-y² / 2σ²), with a genuine node at y=0. + radialV=phase*.23*Math.hypot(randomNormal(),randomNormal(),randomNormal()); + } } const x=first.x+(second.x-first.x)*t+u[0]*radialU+v[0]*radialV; const y=first.y+(second.y-first.y)*t+u[1]*radialU+v[1]*radialV; @@ -236,7 +269,10 @@ function writeBondCloud(data, index, atoms, bond, mode) { const phaseColor=mode==='orbital'&&phase<0 ? baseColor.map((value)=>.18+(1-value)*.62) : baseColor.map((value)=>value*.72+.28); - data.set([x,y,z,.50+Math.random()*.72,phaseColor[0],phaseColor[1],phaseColor[2],.030+Math.random()*.052],offset); + // A fixed particle budget is shared by all bonds: avoid overexposing tiny + // molecules while keeping each bond legible in larger structures. + const visibility=Math.min(1.7,.06*atoms.length); + data.set([x,y,z,.50+Math.random()*.72,phaseColor[0],phaseColor[1],phaseColor[2],(.030+Math.random()*.052)*visibility],offset); } function createBondMarkers(atoms, bonds) { @@ -269,6 +305,15 @@ function createBondMarkers(atoms, bonds) { function createParticleData(atoms, mode, {molecule=false,moleculeId='',bonds:explicitBonds}={}) { const count=Math.min(36000,30000+atoms.length*1000), data=new Float32Array(count*8), models=prepareAtomModels(atoms,mode); const bonds=molecule?inferBonds(atoms,moleculeId,explicitBonds):[]; + for(const bond of bonds)bond.frame=bondFrame(atoms,bond,bonds); + for(const model of models) { + if(model.frontier.l!==1)continue; + const index=atoms.indexOf(model.atom),attached=bonds.filter(b=>b.a===index||b.b===index); + const bond=attached.find(b=>b.order>1.2&&!b.frame.axial)||attached[0]; + if(!bond)continue; + const a=atoms[bond.a],b=atoms[bond.b],axis=normalize([b.x-a.x,b.y-a.y,b.z-a.z]); + model.frame=bond.order>1.2&&!bond.frame.axial?{u:axis,v:bond.frame.u,w:bond.frame.v}:{u:bond.frame.u,v:bond.frame.v,w:axis}; + } const markers=createBondMarkers(atoms,bonds), nucleusCount=atoms.length; const cloudCount=count-markers.length-nucleusCount; const bondCloudCount=bonds.length?Math.floor(cloudCount*(mode==='orbital'?.46:.32)):0; @@ -276,7 +321,7 @@ function createParticleData(atoms, mode, {molecule=false,moleculeId='',bonds:exp const radiusScale=molecule?(mode==='orbital'?.56:.48):1; let index=0; for (; indexsum+bond.order,0); @@ -313,6 +358,21 @@ export async function createElectronCloud(canvas, atoms, {mode='orbital',molecul try { const adapter = await navigator.gpu.requestAdapter({powerPreference:'high-performance'}); const device = await adapter?.requestDevice(); if (!device) return fallback(canvas); const format=navigator.gpu.getPreferredCanvasFormat(); + const resolveModule=device.createShaderModule({code:` + @group(0) @binding(0) var cloud: texture_2d; + @vertex fn vs(@builtin(vertex_index) id:u32)->@builtin(position) vec4f { + let p=array(vec2f(-1.,-1.),vec2f(3.,-1.),vec2f(-1.,3.)); + return vec4f(p[id],0.,1.); + } + @fragment fn fs(@builtin(position) p:vec4f)->@location(0) vec4f { + let c=textureLoad(cloud,vec2i(p.xy),0).rgb*1.5; + let peak=max(c.r,max(c.g,c.b)); + let mapped=c*(.92/(.92+peak)); + return vec4f(vec3f(.014,.035,.078)+mapped*.90,1.); + }`}); + // Floating-point accumulation followed by hue-preserving compression keeps + // dense clouds colorful instead of clipping each channel to white. + const resolvePipeline=await device.createRenderPipelineAsync({layout:'auto',vertex:{module:resolveModule,entryPoint:'vs'},fragment:{module:resolveModule,entryPoint:'fs',targets:[{format}]},primitive:{topology:'triangle-list'}}); const shaderModule=device.createShaderModule({code:shader}); const compilation=await shaderModule.getCompilationInfo(); const shaderErrors=compilation.messages.filter((message)=>message.type==='error'); @@ -322,7 +382,7 @@ export async function createElectronCloud(canvas, atoms, {mode='orbital',molecul const bindGroupLayout=device.createBindGroupLayout({entries:[{binding:0,visibility:GPUShaderStage.VERTEX,buffer:{type:'uniform'}}]}); const pipelineLayout=device.createPipelineLayout({bindGroupLayouts:[bindGroupLayout]}); const vertex={module:shaderModule,entryPoint:'vs',buffers:[{arrayStride:32,stepMode:'instance',attributes:[{shaderLocation:0,offset:0,format:'float32x4'},{shaderLocation:1,offset:16,format:'float32x4'}]}]}; - const makePipeline=(blend,depthWriteEnabled,depthCompare)=>device.createRenderPipelineAsync({layout:pipelineLayout,vertex,fragment:{module:shaderModule,entryPoint:'fs',targets:[{format,blend}]},primitive:{topology:'triangle-strip'},depthStencil:{format:'depth24plus',depthWriteEnabled,depthCompare}}); + const makePipeline=(blend,depthWriteEnabled,depthCompare)=>device.createRenderPipelineAsync({layout:pipelineLayout,vertex,fragment:{module:shaderModule,entryPoint:'fs',targets:[{format:depthWriteEnabled?format:'rgba16float',blend}]},primitive:{topology:'triangle-strip'},depthStencil:{format:'depth24plus',depthWriteEnabled,depthCompare}}); const [cloudPipeline,solidPipeline]=await Promise.all([ makePipeline({color:{srcFactor:'src-alpha',dstFactor:'one',operation:'add'},alpha:{srcFactor:'one',dstFactor:'one-minus-src-alpha',operation:'add'}},false,'always'), makePipeline({color:{srcFactor:'src-alpha',dstFactor:'one-minus-src-alpha',operation:'add'},alpha:{srcFactor:'one',dstFactor:'one-minus-src-alpha',operation:'add'}},true,'less'), @@ -331,12 +391,14 @@ export async function createElectronCloud(canvas, atoms, {mode='orbital',molecul const context = canvas.getContext('webgpu'); const molecularExtent=Math.max(...atoms.map((atom)=>Math.hypot(atom.x,atom.y,atom.z))); const minimumScale=molecule?1.05:1.5; - const initialRotation=[0.14,-.17,0,.975],initialScale=molecule?Math.min(2.7,Math.max(1.15,6.5/(molecularExtent+1.2))):2.7; - let rotation=[...initialRotation],scale=initialScale,active=true,hovered=false,dragging=false,dirty=true,px=0,py=0,depthTexture; + const initialRotation=molecule?normalize([0.14,-.17,0,.975]):axisAngle([0,1,0],Math.PI/4),initialScale=molecule?Math.min(2.7,Math.max(1.15,6.5/(molecularExtent+1.2))):2.7; + let rotation=[...initialRotation],scale=initialScale,active=true,hovered=false,dragging=false,dirty=true,px=0,py=0,depthTexture,cloudTexture,resolveBind; let autoEnabled=!matchMedia('(prefers-reduced-motion: reduce)').matches; const reportState=()=>emitCloudState(canvas,{renderer:'WEBGPU',particleCount:data.length/8,motion:!autoEnabled?'paused':hovered||dragging?'hover':'auto',autoEnabled}); function resize(){const box=canvas.getBoundingClientRect(),ratio=Math.min(devicePixelRatio,1.5);canvas.width=Math.max(1,box.width*ratio);canvas.height=Math.max(1,box.height*ratio);context.configure({device,format,alphaMode:'premultiplied'});depthTexture?.destroy();depthTexture=device.createTexture({size:[canvas.width,canvas.height],format:'depth24plus',usage:GPUTextureUsage.RENDER_ATTACHMENT});dirty=true;} - resize();const observer=new ResizeObserver(resize);observer.observe(canvas); + function resizeCloud(){cloudTexture?.destroy();cloudTexture=device.createTexture({size:[canvas.width,canvas.height],format:'rgba16float',usage:GPUTextureUsage.RENDER_ATTACHMENT|GPUTextureUsage.TEXTURE_BINDING});resolveBind=device.createBindGroup({layout:resolvePipeline.getBindGroupLayout(0),entries:[{binding:0,resource:cloudTexture.createView()}]});} + const resizeAll=()=>{resize();resizeCloud();}; + resizeAll();const observer=new ResizeObserver(resizeAll);observer.observe(canvas); const pointerEnter=()=>{hovered=true;reportState();}; const pointerLeave=()=>{hovered=false;dragging=false;reportState();}; const pointerDown=e=>{dragging=true;dirty=true;px=e.clientX;py=e.clientY;canvas.focus({preventScroll:true});canvas.setPointerCapture(e.pointerId);reportState();}; @@ -370,11 +432,16 @@ export async function createElectronCloud(canvas, atoms, {mode='orbital',molecul const box=canvas.getBoundingClientRect(); device.queue.writeBuffer(uniform,0,new Float32Array([...rotation,box.width,box.height,scale,now*.001])); const encoder=device.createCommandEncoder(); - const pass=encoder.beginRenderPass({colorAttachments:[{view:context.getCurrentTexture().createView(),clearValue:{r:.014,g:.035,b:.078,a:1},loadOp:'clear',storeOp:'store'}],depthStencilAttachment:{view:depthTexture.createView(),depthClearValue:1,depthLoadOp:'clear',depthStoreOp:'store'}}); - pass.setBindGroup(0,bind);pass.setVertexBuffer(0,buffer);pass.setPipeline(cloudPipeline);pass.draw(4,cloudCount);pass.setPipeline(solidPipeline);pass.draw(4,data.length/8-cloudCount,0,cloudCount);pass.end(); + const pass=encoder.beginRenderPass({colorAttachments:[{view:cloudTexture.createView(),clearValue:{r:0,g:0,b:0,a:0},loadOp:'clear',storeOp:'store'}],depthStencilAttachment:{view:depthTexture.createView(),depthClearValue:1,depthLoadOp:'clear',depthStoreOp:'store'}}); + pass.setBindGroup(0,bind);pass.setVertexBuffer(0,buffer);pass.setPipeline(cloudPipeline);pass.draw(4,cloudCount);pass.end(); + const outputView=context.getCurrentTexture().createView(); + const resolve=encoder.beginRenderPass({colorAttachments:[{view:outputView,loadOp:'clear',storeOp:'store'}]}); + resolve.setPipeline(resolvePipeline);resolve.setBindGroup(0,resolveBind);resolve.draw(3);resolve.end(); + const solids=encoder.beginRenderPass({colorAttachments:[{view:outputView,loadOp:'load',storeOp:'store'}],depthStencilAttachment:{view:depthTexture.createView(),depthClearValue:1,depthLoadOp:'clear',depthStoreOp:'store'}}); + solids.setBindGroup(0,bind);solids.setVertexBuffer(0,buffer);solids.setPipeline(solidPipeline);solids.draw(4,data.length/8-cloudCount,0,cloudCount);solids.end(); device.queue.submit([encoder.finish()]); } requestAnimationFrame(frame); - return()=>{active=false;observer.disconnect();canvas.removeEventListener('pointerenter',pointerEnter);canvas.removeEventListener('pointerleave',pointerLeave);canvas.removeEventListener('pointerdown',pointerDown);canvas.removeEventListener('pointerup',pointerUp);canvas.removeEventListener('dblclick',resetView);canvas.removeEventListener('keydown',keyDown);canvas.removeEventListener('cloud:reset',resetView);canvas.removeEventListener('cloud:toggle-auto',toggleAuto);depthTexture?.destroy();buffer.destroy();uniform.destroy();}; + return()=>{active=false;observer.disconnect();canvas.removeEventListener('pointerenter',pointerEnter);canvas.removeEventListener('pointerleave',pointerLeave);canvas.removeEventListener('pointerdown',pointerDown);canvas.removeEventListener('pointerup',pointerUp);canvas.removeEventListener('dblclick',resetView);canvas.removeEventListener('keydown',keyDown);canvas.removeEventListener('cloud:reset',resetView);canvas.removeEventListener('cloud:toggle-auto',toggleAuto);depthTexture?.destroy();cloudTexture?.destroy();buffer.destroy();uniform.destroy();}; } catch (error) { console.warn('WebGPU unavailable',error); return fallback(canvas); } } diff --git a/src/workspace.css b/src/workspace.css index 46fc14a..7ca1c55 100644 --- a/src/workspace.css +++ b/src/workspace.css @@ -89,6 +89,8 @@ main { margin-left: 0; } .composition-row span { padding: 4px 7px; border-radius: 10px; color: #356fc8; background: #eef5ff; font: 9px 'DM Mono'; } .composition-row b { margin-left: 3px; color: #8493a8; font-weight: 500; } +.atom-symbol { flex-shrink: 0; width: auto; min-width: 54px; padding: 0 10px; border-radius: 12px; font-size: 16px; } + .field-keys.molecular { grid-template-columns: repeat(3, 1fr); } .key-point.bond { border-radius: 1px; background: #d7e8ff; box-shadow: 0 0 5px #8cbcff; } @@ -154,6 +156,9 @@ main { margin-left: 0; } @media (max-width: 1120px) { .workspace { grid-template-columns: 1fr; } + .viewer-column { position: sticky; top: 8px; z-index: 4; } + .canvas-frame { height: clamp(300px, 48vh, 540px); min-height: 0; } + .control-column { max-height: 38vh; overflow-y: auto; overscroll-behavior: contain; } } @media (max-width: 720px) {