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 (; index
sum+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) {