feat(3d): refine scene controls and camera views

Keep the control panel geometry stable when toggling building context, and use accessible switch controls to prevent focus-driven movement. Add browser coverage for camera framing, opacity, responsive layout, and the default 6x pipe scale.
This commit is contained in:
2026-09-14 15:41:44 +08:00
parent 69ad145e75
commit 3420b1fa3a
11 changed files with 567 additions and 56 deletions
+246
View File
@@ -0,0 +1,246 @@
import { expect, test } from "@playwright/test";
import { mockBackend } from "./support/mockBackend";
test.setTimeout(180_000);
type CameraProbe = {
active: string | null;
position: number[];
target: number[];
up: number[];
};
type SceneProbe = {
errors: string[];
contextVisible?: boolean;
camera?: CameraProbe;
appearance?: { contextOpacity: number };
networkStyle?: { style: { scale: number } };
};
test("三维页面默认展开场景工具并可调建筑透明度", async ({ page }) => {
await mockBackend(page);
await page.addInitScript(() => {
localStorage.setItem("active_project", "playwright-project");
localStorage.setItem("MAP_WORKSPACE", "zjb");
localStorage.setItem("NETWORK_NAME", "zjb");
});
await page.goto("/three-dimensional-scene");
await expect(page.getByLabel("三维场景控制面板")).toBeVisible();
await expect(page.getByRole("tab", { name: "场景" })).toHaveAttribute(
"aria-selected",
"true",
);
const opacitySlider = page.getByRole("slider", {
name: /建筑背景透明度/,
});
await expect(opacitySlider).toBeEnabled({ timeout: 60_000 });
await expect(opacitySlider).toHaveAccessibleName("建筑背景透明度 60%");
const backgroundToggle = page.getByRole("switch", { name: "建筑背景" });
await backgroundToggle.evaluate((element) =>
element.scrollIntoView({ block: "center" }),
);
const panel = page.getByLabel("三维场景控制面板");
const panelBeforeToggle = await panel.boundingBox();
const panelScrollBeforeToggle = await panel.locator(".overflow-y-auto").evaluate(
(element) => element.scrollTop,
);
await backgroundToggle.click();
await expect(backgroundToggle).toHaveAttribute("aria-checked", "false");
const panelAfterToggle = await panel.boundingBox();
const panelScrollAfterToggle = await panel.locator(".overflow-y-auto").evaluate(
(element) => element.scrollTop,
);
expect(panelAfterToggle).toEqual(panelBeforeToggle);
expect(panelScrollAfterToggle).toBe(panelScrollBeforeToggle);
await page.setViewportSize({ width: 375, height: 812 });
const mobilePanelBeforeToggle = await panel.boundingBox();
await backgroundToggle.click();
await expect(backgroundToggle).toHaveAttribute("aria-checked", "true");
const mobilePanelAfterToggle = await panel.boundingBox();
expect(mobilePanelAfterToggle).toEqual(mobilePanelBeforeToggle);
await expect(opacitySlider).toBeEnabled();
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
});
test("全部预设使用稳定且紧凑的场景视角", async ({ page }) => {
await page.goto(
"/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity-test",
);
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
const sceneProbe = () =>
page.evaluate(
() =>
(window as typeof window & { webQA: SceneProbe }).webQA,
);
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe("overview");
const initialProbe = await sceneProbe();
const overview = initialProbe.camera;
expect(overview?.up[1]).toBeGreaterThan(0.8);
expect(initialProbe.appearance?.contextOpacity).toBe(0.4);
expect(initialProbe.networkStyle?.style.scale).toBe(6);
await page.evaluate(() =>
window.postMessage(
{
channel: "tjwater:zjb-scene",
version: 2,
type: "command",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
command: { name: "set-appearance", patch: { contextOpacity: 0.35 } },
},
window.location.origin,
),
);
await expect
.poll(async () => (await sceneProbe()).appearance?.contextOpacity)
.toBe(0.35);
const cameraBeforeContextToggle = (await sceneProbe()).camera;
await page.evaluate(() =>
window.postMessage(
{
channel: "tjwater:zjb-scene",
version: 2,
type: "command",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
command: { name: "toggle-context" },
},
window.location.origin,
),
);
await expect.poll(async () => (await sceneProbe()).contextVisible).toBe(false);
const cameraAfterContextToggle = (await sceneProbe()).camera;
expect(cameraAfterContextToggle?.position).toEqual(cameraBeforeContextToggle?.position);
expect(cameraAfterContextToggle?.target).toEqual(cameraBeforeContextToggle?.target);
await page.evaluate(() =>
window.postMessage(
{
channel: "tjwater:zjb-scene",
version: 2,
type: "command",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
command: { name: "toggle-context" },
},
window.location.origin,
),
);
await page.evaluate(() =>
window.postMessage(
{
channel: "tjwater:zjb-scene",
version: 2,
type: "command",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
command: { name: "visit-camera", viewId: "plan" },
},
window.location.origin,
),
);
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe("plan");
await expect
.poll(async () => {
const camera = (await sceneProbe()).camera;
return Math.max(
Math.abs((camera?.position[0] ?? 0) - (camera?.target[0] ?? 1)),
Math.abs((camera?.position[2] ?? 0) - (camera?.target[2] ?? 1)),
);
})
.toBeLessThan(0.01);
const planProbe = await sceneProbe();
const plan = planProbe.camera;
expect(planProbe.errors).toEqual([]);
expect(Math.abs((plan?.position[0] ?? 0) - (plan?.target[0] ?? 1))).toBeLessThan(0.01);
expect(Math.abs((plan?.position[2] ?? 0) - (plan?.target[2] ?? 1))).toBeLessThan(0.01);
expect(plan?.up[0]).toBeCloseTo(0, 6);
expect(plan?.up[1]).toBeCloseTo(0, 6);
expect(plan?.up[2]).toBeCloseTo(-1, 6);
for (const viewId of [
"pump",
"hydraulic",
"singlePump",
"main",
"meter",
"crossing",
"station",
"riser",
]) {
await page.evaluate((nextViewId) =>
window.postMessage(
{
channel: "tjwater:zjb-scene",
version: 2,
type: "command",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
command: { name: "visit-camera", viewId: nextViewId },
},
window.location.origin,
),
viewId,
);
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe(viewId);
const presetProbe = await sceneProbe();
expect(presetProbe.errors).toEqual([]);
expect(presetProbe.camera?.position.every(Number.isFinite)).toBe(true);
expect(presetProbe.camera?.target.every(Number.isFinite)).toBe(true);
expect(presetProbe.camera?.up[1]).toBeGreaterThan(0.99);
}
const occupancy = await page.evaluate(async () => {
const threeUrl =
"/three-dimensional/zjb/v29/vendor/three/three.module.js";
const navigationUrl =
"/three-dimensional/zjb/v29/camera-navigation.mjs?v=31-camera-presets-test";
const THREE = await import(threeUrl);
const { framePose } = await import(navigationUrl);
const aspect = 16 / 9;
const box = new THREE.Box3(
new THREE.Vector3(-330, 0, -205),
new THREE.Vector3(330, 15, 205),
);
const pose = framePose(box, aspect, {
direction: [0, 1, 0],
up: [0, 0, -1],
padding: 1.04,
});
const camera = new THREE.PerspectiveCamera(42, aspect, 0.1, 10_000);
camera.position.fromArray(pose.position);
camera.up.fromArray(pose.up);
camera.lookAt(new THREE.Vector3(...pose.target));
camera.updateProjectionMatrix();
camera.updateMatrixWorld();
const points = [
[-330, 0, -205],
[-330, 0, 205],
[-330, 15, -205],
[-330, 15, 205],
[330, 0, -205],
[330, 0, 205],
[330, 15, -205],
[330, 15, 205],
].map((point) => new THREE.Vector3(...point).project(camera));
return {
x: Math.max(...points.map((point) => Math.abs(point.x))),
y: Math.max(...points.map((point) => Math.abs(point.y))),
};
});
expect(occupancy.x).toBeLessThanOrEqual(1);
expect(occupancy.y).toBeLessThanOrEqual(1);
expect(Math.max(occupancy.x, occupancy.y)).toBeGreaterThan(0.9);
});
+1
View File
@@ -9,6 +9,7 @@ const createJestConfig = nextJest({
const customJestConfig = { const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom', testEnvironment: 'jest-environment-jsdom',
testPathIgnorePatterns: ['<rootDir>/e2e/'],
moduleNameMapper: { moduleNameMapper: {
'^@pages/(.*)$': '<rootDir>/pages/$1', '^@pages/(.*)$': '<rootDir>/pages/$1',
'^@/(.*)$': '<rootDir>/src/$1', '^@/(.*)$': '<rootDir>/src/$1',
@@ -85,14 +85,14 @@ export function enhanceMaterials(object){
} }
// Restore the authored appearance on every mode change. In network overview, // Restore the authored appearance on every mode change. In network overview,
// opaque buildings fade more than glass so the curtain wall remains legible. // all architectural context follows the same user-controlled opacity ceiling.
export function applyBuildingContext(material,fade){ export function applyBuildingContext(material,fade,contextOpacity=.4){
const original=material.userData.original??{opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite}; const original=material.userData.original??{opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite};
material.userData.original=original; material.userData.original=original;
const facade=/v3_glass|coatedcurtainglass/i.test(material.name); const opacity=THREE.MathUtils.clamp(contextOpacity,0,1),contextualOpacity=Math.min(original.opacity,opacity);
material.opacity=fade?(facade?Math.min(original.opacity,.38):.12):original.opacity; material.opacity=fade?contextualOpacity:original.opacity;
material.transparent=fade||original.transparent; material.transparent=fade?(contextualOpacity<1||original.transparent):original.transparent;
material.depthWrite=fade?false:original.depthWrite;material.needsUpdate=true; material.depthWrite=fade&&contextualOpacity<1?false:original.depthWrite;material.needsUpdate=true;
} }
const presets={ const presets={
@@ -1,15 +1,56 @@
import * as THREE from 'three'; import * as THREE from 'three';
export function framePose(box,aspect,{direction=[.6,.8,1],padding=1.2,fov=42}={}){ const WORLD_UP=new THREE.Vector3(0,1,0);
if(box.isEmpty())throw Error('视角目标没有可见模型。'); function boxCorners(box){const {min,max}=box;return [
const sphere=box.getBoundingSphere(new THREE.Sphere()),angle=Math.min(fov*Math.PI/360,Math.atan(Math.tan(fov*Math.PI/360)*aspect)); new THREE.Vector3(min.x,min.y,min.z),new THREE.Vector3(min.x,min.y,max.z),new THREE.Vector3(min.x,max.y,min.z),new THREE.Vector3(min.x,max.y,max.z),
const distance=Math.max(2,sphere.radius)*padding/Math.sin(angle);return {target:sphere.center.toArray(),position:sphere.center.clone().add(new THREE.Vector3(...direction).normalize().multiplyScalar(distance)).toArray()}; new THREE.Vector3(max.x,min.y,min.z),new THREE.Vector3(max.x,min.y,max.z),new THREE.Vector3(max.x,max.y,min.z),new THREE.Vector3(max.x,max.y,max.z),
} ];}
export function createCameraNavigation({camera,controls,model,networkRoot,root,show,getMode,appearance,onChange,getDisplayMode=()=> 'global',setDisplayMode=()=>{}}){ export function networkPrimaryAxis(model){
const points=(model.nodes??[]).map(node=>node.coordinate).filter(point=>Array.isArray(point)&&point.length>=2&&point.slice(0,2).every(Number.isFinite));
if(points.length<2)return new THREE.Vector3(1,0,0);
const center=points.reduce((sum,point)=>[sum[0]+point[0]/points.length,sum[1]-point[1]/points.length],[0,0]);let xx=0,xz=0,zz=0;
for(const point of points){const x=point[0]-center[0],z=-point[1]-center[1];xx+=x*x;xz+=x*z;zz+=z*z;}
const angle=.5*Math.atan2(2*xz,xx-zz),axis=new THREE.Vector3(Math.cos(angle),0,Math.sin(angle));if(axis.x<0)axis.negate();return axis.normalize();
}
function linkAxis(link,fallback){
const points=link?.coordinates;if(!Array.isArray(points)||points.length<2)return fallback.clone();
const first=points[0],last=points.at(-1),axis=new THREE.Vector3(last[0]-first[0],0,-(last[1]-first[1]));
if(axis.lengthSq()<1e-8)return fallback.clone();if(axis.x<0)axis.negate();return axis.normalize();
}
function viewingDirection(axis,reference,elevation){
const horizontal=axis.clone().cross(WORLD_UP).normalize(),referenceHorizontal=reference.clone().cross(WORLD_UP).normalize();
if(horizontal.dot(referenceHorizontal)<0)horizontal.negate();return horizontal.addScaledVector(WORLD_UP,elevation).normalize();
}
export function framePose(box,aspect,{direction=[.6,.8,1],up=[0,1,0],padding=1.12,fov=42}={}){
if(box.isEmpty())throw Error('视角目标没有可见模型。');
const target=box.getCenter(new THREE.Vector3()),viewDirection=new THREE.Vector3(...direction).normalize();let requestedUp=new THREE.Vector3(...up).normalize();
if(Math.abs(requestedUp.dot(viewDirection))>.98)requestedUp=Math.abs(viewDirection.y)<.98?WORLD_UP.clone():new THREE.Vector3(0,0,-1);
const right=requestedUp.clone().cross(viewDirection).normalize(),projectedUp=viewDirection.clone().cross(right).normalize();
const forward=viewDirection.clone().negate(),verticalTan=Math.tan(fov*Math.PI/360),horizontalTan=verticalTan*Math.max(.1,aspect);let distance=2;
for(const corner of boxCorners(box)){const offset=corner.sub(target),depthOffset=offset.dot(forward);distance=Math.max(distance,Math.abs(offset.dot(right))/horizontalTan-depthOffset,Math.abs(offset.dot(projectedUp))/verticalTan-depthOffset);}
distance*=padding;return {target:target.toArray(),position:target.clone().add(viewDirection.multiplyScalar(distance)).toArray(),up:requestedUp.toArray()};
}
export function createCameraNavigation({camera,controls,model,networkRoot,root,show,getMode,appearance,onChange,getDisplayMode=()=> 'global',setDisplayMode=()=>{}}){
const presets=[{id:'overview',label:'供水总览',mode:'network',note:'完整管网与站区背景'},{id:'plan',label:'管网俯视',mode:'network',note:'查看管网平面关系'},{id:'pump',label:'CAD 泵房参考',mode:'pump',note:'六泵与集管剖开检查 · 隐藏外墙和楼梯'},{id:'hydraulic',label:'泵组与管网',mode:'hydraulic',note:'六泵与下沉接入 · 竖向按 CAD,平面配准待核'},{id:'main',label:'主干管段',mode:'network',note:'定位模型中最长的供水管段'},{id:'meter',label:'BJ9 水表',mode:'network',note:'定位 CAD 水表及宿主管段'},{id:'crossing',label:'交叉下穿',mode:'network',note:'查看非连通交叉的示意下穿'},{id:'station',label:'站房外观',mode:'detail',note:'站房精细模型与周边关系'}]; const presets=[{id:'overview',label:'供水总览',mode:'network',note:'完整管网与站区背景'},{id:'plan',label:'管网俯视',mode:'network',note:'查看管网平面关系'},{id:'pump',label:'CAD 泵房参考',mode:'pump',note:'六泵与集管剖开检查 · 隐藏外墙和楼梯'},{id:'hydraulic',label:'泵组与管网',mode:'hydraulic',note:'六泵与下沉接入 · 竖向按 CAD,平面配准待核'},{id:'main',label:'主干管段',mode:'network',note:'定位模型中最长的供水管段'},{id:'meter',label:'BJ9 水表',mode:'network',note:'定位 CAD 水表及宿主管段'},{id:'crossing',label:'交叉下穿',mode:'network',note:'查看非连通交叉的示意下穿'},{id:'station',label:'站房外观',mode:'detail',note:'站房精细模型与周边关系'}];
presets.splice(4,0,{id:'singlePump',label:'主泵近景',mode:'hydraulic',note:'PU_VFD_1 · 设备法兰与进出水连接'}); presets.splice(4,0,{id:'singlePump',label:'主泵近景',mode:'hydraulic',note:'PU_VFD_1 · 设备法兰与进出水连接'});
if(model.cadAttachments?.length)presets.push({id:'riser',label:'站台上层立管',mode:'network',context:false,note:'CAD DN150 · 高差 10.85 m · 上层配水未推断'}); if(model.cadAttachments?.length)presets.push({id:'riser',label:'站台上层立管',mode:'network',context:false,note:'CAD DN150 · 高差 10.85 m · 上层配水未推断'});
let request=0,flight=null,active=null;const key='zjb-camera-bookmarks-v24';let saved=[]; let request=0,flight=null,active=null;const key='zjb-camera-bookmarks-v24';let saved=[];
try{const v=JSON.parse(localStorage.getItem(key)||'[]');if(Array.isArray(v))saved=v.filter(p=>p&&typeof p.id==='string'&&typeof p.label==='string'&&p.label.length<=24&&['network','hydraulic','pump','detail','map','meters'].includes(p.mode)&&[p.position,p.target].every(a=>Array.isArray(a)&&a.length===3&&a.every(Number.isFinite))).slice(0,8);}catch{} try{const v=JSON.parse(localStorage.getItem(key)||'[]');if(Array.isArray(v))saved=v.filter(p=>p&&typeof p.id==='string'&&typeof p.label==='string'&&p.label.length<=24&&['network','hydraulic','pump','detail','map','meters'].includes(p.mode)&&[p.position,p.target].every(a=>Array.isArray(a)&&a.length===3&&a.every(Number.isFinite))&&(!p.up||(Array.isArray(p.up)&&p.up.length===3&&p.up.every(Number.isFinite)))).slice(0,8);}catch{}
const primaryAxis=networkPrimaryAxis(model),planDirection=WORLD_UP.clone(),planUp=new THREE.Vector3(0,0,-1),overviewDirection=viewingDirection(primaryAxis,primaryAxis,.92);
const links=new Map((model.links??[]).map(link=>[link.id,link]));
const linkLength=link=>(link.coordinates??[]).slice(1).reduce((sum,point,index)=>sum+Math.hypot(point[0]-link.coordinates[index][0],point[1]-link.coordinates[index][1]),0);
const mainLink=(model.links??[]).filter(link=>link.kind==='PIPES').sort((a,b)=>linkLength(b)-linkLength(a))[0];
const mainAxis=linkAxis(mainLink,primaryAxis);
const meterBinding=(model.cadEquipmentBindings??[]).find(binding=>binding.assetId==='zjb:meter:BJ9'),meterAxis=linkAxis(links.get(meterBinding?.inpLinkId),primaryAxis);
const poseOptions={
pump:{direction:viewingDirection(primaryAxis,primaryAxis,.72).toArray(),padding:1.06},
hydraulic:{direction:viewingDirection(primaryAxis,primaryAxis,.58).toArray(),padding:1.08},
singlePump:{direction:viewingDirection(primaryAxis,primaryAxis,.36).toArray(),padding:1.28},
main:{direction:viewingDirection(mainAxis,primaryAxis,.72).toArray(),padding:1.1},
meter:{direction:viewingDirection(meterAxis,primaryAxis,1.6).toArray(),padding:1.16},
crossing:{direction:viewingDirection(primaryAxis,primaryAxis,2.3).toArray(),padding:1.16},
station:{direction:viewingDirection(primaryAxis,primaryAxis,.58).toArray(),padding:1.06},
riser:{direction:viewingDirection(primaryAxis,primaryAxis,.2).toArray(),padding:1.24},
};
function emit(note){onChange({active,views:[...presets,...saved],note});} function emit(note){onChange({active,views:[...presets,...saved],note});}
function stop(){request++;flight=null;active=null;emit('自由浏览 · 可保存当前视角');} function stop(){request++;flight=null;active=null;emit('自由浏览 · 可保存当前视角');}
controls.addEventListener('start',stop); controls.addEventListener('start',stop);
@@ -17,11 +58,11 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s
if(p.id==='overview'||p.id==='plan')box.setFromObject(networkRoot); if(p.id==='overview'||p.id==='plan')box.setFromObject(networkRoot);
else if(p.id==='pump'||p.id==='station'){for(const o of root.children)if(o.visible&&(p.id!=='pump'||/physical_|piping_reference/.test(o.userData.resourceId)))box.union(new THREE.Box3().setFromObject(o));if(p.id==='station'){const facade=root.children.find(o=>o.userData.resourceId==='detail_facade');if(facade)box.setFromObject(facade);}} else if(p.id==='pump'||p.id==='station'){for(const o of root.children)if(o.visible&&(p.id!=='pump'||/physical_|piping_reference/.test(o.userData.resourceId)))box.union(new THREE.Box3().setFromObject(o));if(p.id==='station'){const facade=root.children.find(o=>o.userData.resourceId==='detail_facade');if(facade)box.setFromObject(facade);}}
else if(p.id==='hydraulic'){for(const l of model.links.filter(l=>l.kind==='PUMPS'))for(const a of l.coordinates)box.expandByPoint(new THREE.Vector3(a[0]-513800,model.verticalCoordination?.pump.pipeCenterY??.35,-(a[1]-2344450)));networkRoot.traverse(o=>{if(o.userData.kind==='PUMPS'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(2);} else if(p.id==='hydraulic'){for(const l of model.links.filter(l=>l.kind==='PUMPS'))for(const a of l.coordinates)box.expandByPoint(new THREE.Vector3(a[0]-513800,model.verticalCoordination?.pump.pipeCenterY??.35,-(a[1]-2344450)));networkRoot.traverse(o=>{if(o.userData.kind==='PUMPS'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(2);}
else if(p.id==='riser'){networkRoot.traverse(o=>{if(o.userData.assetId===model.cadAttachments[0].assetId)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(2);} else if(p.id==='riser'){networkRoot.traverse(o=>{if(o.userData.assetId===model.cadAttachments[0].assetId)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(4);}
else if(p.id==='singlePump'){networkRoot.traverse(o=>{if(o.userData.assetId==='inp:link:PU_VFD_1'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(.75);} else if(p.id==='singlePump'){networkRoot.traverse(o=>{if(o.userData.assetId==='inp:link:PU_VFD_1'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(1.25);}
else if(p.id==='meter'){networkRoot.traverse(o=>{if(o.userData.assetId==='zjb:meter:BJ9')box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(3);} else if(p.id==='meter'){networkRoot.traverse(o=>{if(o.userData.assetId==='zjb:meter:BJ9')box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(3.2);}
else if(p.id==='main'){const distance=l=>l.coordinates.slice(1).reduce((s,p,i)=>s+Math.hypot(p[0]-l.coordinates[i][0],p[1]-l.coordinates[i][1]),0),l=model.links.filter(l=>l.kind==='PIPES').sort((a,b)=>distance(b)-distance(a))[0];for(const p of l.coordinates)box.expandByPoint(new THREE.Vector3(p[0]-513800,.35,-(p[1]-2344450)));box.expandByScalar(5);} else if(p.id==='main'){for(const point of mainLink.coordinates)box.expandByPoint(new THREE.Vector3(point[0]-513800,.35,-(point[1]-2344450)));box.expandByScalar(6);}
else if(p.id==='crossing'){const a=model.layouts[8].pipes;let i=0;for(let j=0;j<a.length;j+=16)if(a[j+13]<a[i+13])i=j;const point=new THREE.Vector3(a[i+12],a[i+13],a[i+14]);box.setFromCenterAndSize(point,new THREE.Vector3(22,10,22));} else if(p.id==='crossing'){const a=model.layouts[8].pipes;let i=0;for(let j=0;j<a.length;j+=16)if(a[j+13]<a[i+13])i=j;const point=new THREE.Vector3(a[i+12],a[i+13],a[i+14]);box.setFromCenterAndSize(point,new THREE.Vector3(30,14,30));}
return box; return box;
} }
async function visit(id){const p=[...presets,...saved].find(p=>p.id===id);if(!p)return;const token=++request;flight=null;emit('正在准备 '+p.label+'…'); async function visit(id){const p=[...presets,...saved].find(p=>p.id===id);if(!p)return;const token=++request;flight=null;emit('正在准备 '+p.label+'…');
@@ -29,7 +70,8 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s
for(const o of root.children)if(/roof|ceiling/.test(o.userData.resourceId))o.visible=p.roof??true; for(const o of root.children)if(/roof|ceiling/.test(o.userData.resourceId))o.visible=p.roof??true;
if(p.id==='pump')for(const o of root.children)if(['detail_pump_walls','detail_pump_access'].includes(o.userData.resourceId))o.visible=false; if(p.id==='pump')for(const o of root.children)if(['detail_pump_walls','detail_pump_access'].includes(o.userData.resourceId))o.visible=false;
if(p.layers)for(const o of root.children)if(typeof p.layers[o.userData.resourceId]==='boolean')o.visible=p.layers[o.userData.resourceId]; if(p.layers)for(const o of root.children)if(typeof p.layers[o.userData.resourceId]==='boolean')o.visible=p.layers[o.userData.resourceId];
let pose,box;if(p.position){pose=p;box=new THREE.Box3().setFromCenterAndSize(new THREE.Vector3(...p.target),new THREE.Vector3(20,20,20));}else{box=bounds(p);pose=framePose(box,camera.aspect,{direction:p.id==='plan'?[0,1,.0001]:p.id==='crossing'?[1,.8,1]:p.id==='pump'?[.5,.9,1]:[.6,.8,1]});} let pose,box;if(p.position){pose=p;box=new THREE.Box3().setFromCenterAndSize(new THREE.Vector3(...p.target),new THREE.Vector3(20,20,20));}else{box=bounds(p);pose=framePose(box,camera.aspect,p.id==='plan'?{direction:planDirection.toArray(),up:planUp.toArray(),padding:1.04}:p.id==='overview'?{direction:overviewDirection.toArray(),padding:1.08}:poseOptions[p.id]);}
camera.up.fromArray(pose.up??[0,1,0]).normalize();
appearance.frame(box,p.mode);camera.near=Math.max(.05,new THREE.Vector3(...pose.position).distanceTo(new THREE.Vector3(...pose.target))/2000);camera.far=20000;camera.updateProjectionMatrix();active=id; appearance.frame(box,p.mode);camera.near=Math.max(.05,new THREE.Vector3(...pose.position).distanceTo(new THREE.Vector3(...pose.target))/2000);camera.far=20000;camera.updateProjectionMatrix();active=id;
flight={start:performance.now(),from:camera.position.clone(),targetFrom:controls.target.clone(),to:new THREE.Vector3(...pose.position),targetTo:new THREE.Vector3(...pose.target)}; flight={start:performance.now(),from:camera.position.clone(),targetFrom:controls.target.clone(),to:new THREE.Vector3(...pose.position),targetTo:new THREE.Vector3(...pose.target)};
if(matchMedia('(prefers-reduced-motion: reduce)').matches){camera.position.copy(flight.to);controls.target.copy(flight.targetTo);flight=null;controls.update();} if(matchMedia('(prefers-reduced-motion: reduce)').matches){camera.position.copy(flight.to);controls.target.copy(flight.targetTo);flight=null;controls.update();}
@@ -37,7 +79,7 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s
}catch(e){if(token===request)emit('视角加载失败:'+e.message);} }catch(e){if(token===request)emit('视角加载失败:'+e.message);}
} }
function tick(now){if(!flight)return;const t=Math.min(1,(now-flight.start)/500),v=t*t*(3-2*t);camera.position.lerpVectors(flight.from,flight.to,v);controls.target.lerpVectors(flight.targetFrom,flight.targetTo,v);if(t===1)flight=null;} function tick(now){if(!flight)return;const t=Math.min(1,(now-flight.start)/500),v=t*t*(3-2*t);camera.position.lerpVectors(flight.from,flight.to,v);controls.target.lerpVectors(flight.targetFrom,flight.targetTo,v);if(t===1)flight=null;}
function save(label){label=label.trim();if(!label||label.length>24)throw Error('请输入 124 字的视角名称。');if(saved.length>=8)throw Error('最多保存 8 个视角,请先移除不用的视角。');const p={id:'saved-'+Date.now(),label,mode:getMode(),displayMode:getDisplayMode(),position:camera.position.toArray(),target:controls.target.toArray(),context:root.visible,roof:root.children.filter(o=>/roof|ceiling/.test(o.userData.resourceId)).every(o=>o.visible)};p.layers=Object.fromEntries(root.children.map(o=>[o.userData.resourceId,o.visible]));const next=[...saved,p];localStorage.setItem(key,JSON.stringify(next));saved=next;active=p.id;emit('视角已保存在本机浏览器');} function save(label){label=label.trim();if(!label||label.length>24)throw Error('请输入 124 字的视角名称。');if(saved.length>=8)throw Error('最多保存 8 个视角,请先移除不用的视角。');const p={id:'saved-'+Date.now(),label,mode:getMode(),displayMode:getDisplayMode(),position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray(),context:root.visible,roof:root.children.filter(o=>/roof|ceiling/.test(o.userData.resourceId)).every(o=>o.visible)};p.layers=Object.fromEntries(root.children.map(o=>[o.userData.resourceId,o.visible]));const next=[...saved,p];localStorage.setItem(key,JSON.stringify(next));saved=next;active=p.id;emit('视角已保存在本机浏览器');}
function remove(id){saved=saved.filter(p=>p.id!==id);localStorage.setItem(key,JSON.stringify(saved));if(active===id)active=null;emit('已移除保存的视角');} function remove(id){saved=saved.filter(p=>p.id!==id);localStorage.setItem(key,JSON.stringify(saved));if(active===id)active=null;emit('已移除保存的视角');}
emit('选择一个观察位置');return {visit,tick,save,remove,stop,presets}; emit('选择一个观察位置');return {visit,tick,save,remove,stop,presets};
} }
@@ -1,6 +1,6 @@
import * as THREE from 'three'; import * as THREE from 'three';
export const DEFAULT_STYLE=Object.freeze({scale:8,mode:'uniform',color:'#098ed0',missingColor:'#89949d',lowColor:'#2b83ba',highColor:'#e66c37',opacity:1,roughness:.3,metalness:.22,nodes:true,direction:'none',arrowColor:'#f2b447',autoRange:true,min:0,max:3}); export const DEFAULT_STYLE=Object.freeze({scale:6,mode:'uniform',color:'#098ed0',missingColor:'#89949d',lowColor:'#2b83ba',highColor:'#e66c37',opacity:1,roughness:.3,metalness:.22,nodes:true,direction:'none',arrowColor:'#f2b447',autoRange:true,min:0,max:3});
const finite=v=>typeof v==='number'&&Number.isFinite(v); const finite=v=>typeof v==='number'&&Number.isFinite(v);
export function validateResults(input,model){ export function validateResults(input,model){
if(input?.modelId!==model.modelId)throw Error('结果文件与当前管网模型编号不一致。'); if(input?.modelId!==model.modelId)throw Error('结果文件与当前管网模型编号不一致。');
@@ -20,6 +20,6 @@
<body> <body>
<div id="boot" role="status"><span><i aria-hidden="true"></i>正在载入三维模型</span></div> <div id="boot" role="status"><span><i aria-hidden="true"></i>正在载入三维模型</span></div>
<output id="qa" hidden></output> <output id="qa" hidden></output>
<script type="module" src="./preview.mjs?v=29-platform2"></script> <script type="module" src="./preview.mjs?v=32-context-opacity"></script>
</body> </body>
</html> </html>
+16 -12
View File
@@ -1,20 +1,20 @@
import {createRenderEffects} from './render-effects.mjs'; import {createRenderEffects} from './render-effects.mjs';
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=28'; import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=31-camera-presets';
import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2'; import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2';
import * as THREE from 'three'; import * as THREE from 'three';
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js'; import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js'; import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js'; import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js';
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=28b'; import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=32-default-scale';
import {cadToGltf} from './integration.mjs'; import {cadToGltf} from './integration.mjs';
import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=29'; import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=30-context-opacity';
const HOST_CHANNEL='tjwater:zjb-scene',HOST_VERSION=2,PROJECT_CODE='zjb',MODEL_ID='zjb-water-network-v23'; const HOST_CHANNEL='tjwater:zjb-scene',HOST_VERSION=2,PROJECT_CODE='zjb',MODEL_ID='zjb-water-network-v23';
let net,networkRoot,networkStyle,navigation,inspector; let net,networkRoot,networkStyle,navigation,inspector;
let mode='network',generation=0,contextVisible=true,roofVisible=true; let mode='network',generation=0,contextVisible=true,roofVisible=true;
let sceneStatus='正在载入三维模型'; let sceneStatus='正在载入三维模型';
let cameraState={active:null,note:'正在准备观察位置',views:[]}; let cameraState={active:null,note:'正在准备观察位置',views:[]};
let appearanceState={preset:'day',exposure:.95,shadows:true,effects:true,quality:'standard'}; let appearanceState={preset:'day',exposure:.95,contextOpacity:.4,shadows:true,effects:true,quality:'standard'};
function postHost(type,detail={}){ function postHost(type,detail={}){
if(window.parent===window)return; if(window.parent===window)return;
@@ -37,7 +37,7 @@ function runtimeState(){
function postState(){if(net&&networkStyle)postHost('scene-state',{state:runtimeState()});} function postState(){if(net&&networkStyle)postHost('scene-state',{state:runtimeState()});}
function setStatus(value){sceneStatus=value;postState();} function setStatus(value){sceneStatus=value;postState();}
window.webQA={errors:[],loaded:[],mode:'network'}; window.webQA={errors:[],loaded:[],mode:'network',contextVisible};
window.addEventListener('error',event=>{const message=String(event.message||'三维场景运行错误');window.webQA.errors.push(message);postHost('error',{message});}); window.addEventListener('error',event=>{const message=String(event.message||'三维场景运行错误');window.webQA.errors.push(message);postHost('error',{message});});
window.addEventListener('unhandledrejection',event=>{const message=String(event.reason||'三维场景异步任务失败');window.webQA.errors.push(message);postHost('error',{message});}); window.addEventListener('unhandledrejection',event=>{const message=String(event.reason||'三维场景异步任务失败');window.webQA.errors.push(message);postHost('error',{message});});
@@ -78,6 +78,7 @@ function fit(){
if(box.isEmpty())return; if(box.isEmpty())return;
appearance.frame(box,mode); appearance.frame(box,mode);
const sphere=box.getBoundingSphere(new THREE.Sphere());controls.target.copy(sphere.center); const sphere=box.getBoundingSphere(new THREE.Sphere());controls.target.copy(sphere.center);
camera.up.copy(new THREE.Vector3(0,1,0));
camera.position.copy(sphere.center).add(new THREE.Vector3(.65,.72,1).normalize().multiplyScalar(sphere.radius*3.1/Math.min(1,camera.aspect))); camera.position.copy(sphere.center).add(new THREE.Vector3(.65,.72,1).normalize().multiplyScalar(sphere.radius*3.1/Math.min(1,camera.aspect)));
camera.near=Math.max(.005,sphere.radius/5000);camera.far=sphere.radius*30+100;camera.updateProjectionMatrix();controls.update(); camera.near=Math.max(.005,sphere.radius/5000);camera.far=sphere.radius*30+100;camera.updateProjectionMatrix();controls.update();
} }
@@ -103,7 +104,7 @@ async function show(next,{frame=true}={}){
const objects=await Promise.all(ids.map(load));if(token!==generation)return; const objects=await Promise.all(ids.map(load));if(token!==generation)return;
root.clear();objects.forEach((object,index)=>{ root.clear();objects.forEach((object,index)=>{
object.position.set(next==='meters'?index*1.5:0,0,0);object.visible=true;root.add(object); object.position.set(next==='meters'?index*1.5:0,0,0);object.visible=true;root.add(object);
object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(next));}); object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(next),appearanceState.contextOpacity);});
}); });
if(networkRoot)networkRoot.visible=['network','hydraulic'].includes(next); if(networkRoot)networkRoot.visible=['network','hydraulic'].includes(next);
networkStyle?.setFocusScope(next==='hydraulic');applyVisibility();inspector?.highlight();if(frame)fit(); networkStyle?.setFocusScope(next==='hydraulic');applyVisibility();inspector?.highlight();if(frame)fit();
@@ -115,12 +116,14 @@ function setDisplayMode(value){networkStyle.setDisplayMode(value);navigation?.st
function applyAppearance(patch){ function applyAppearance(patch){
if(patch.preset!==undefined){if(!['day','studio','evening'].includes(patch.preset))throw Error('不支持的光照场景');appearance.preset(patch.preset);appearanceState.preset=patch.preset;appearanceState.exposure=appearance.state.exposure;} if(patch.preset!==undefined){if(!['day','studio','evening'].includes(patch.preset))throw Error('不支持的光照场景');appearance.preset(patch.preset);appearanceState.preset=patch.preset;appearanceState.exposure=appearance.state.exposure;}
if(patch.exposure!==undefined){if(!Number.isFinite(patch.exposure)||patch.exposure<.55||patch.exposure>1.6)throw Error('亮度超出有效范围');appearance.exposure(patch.exposure);appearanceState.exposure=patch.exposure;} if(patch.exposure!==undefined){if(!Number.isFinite(patch.exposure)||patch.exposure<.55||patch.exposure>1.6)throw Error('亮度超出有效范围');appearance.exposure(patch.exposure);appearanceState.exposure=patch.exposure;}
if(patch.contextOpacity!==undefined){if(!Number.isFinite(patch.contextOpacity)||patch.contextOpacity<0||patch.contextOpacity>1)throw Error('建筑背景透明度超出有效范围');appearanceState.contextOpacity=patch.contextOpacity;for(const object of root.children)object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(mode),appearanceState.contextOpacity);});appearance.refresh();}
if(patch.shadows!==undefined){appearance.shadows(Boolean(patch.shadows));appearanceState.shadows=Boolean(patch.shadows);} if(patch.shadows!==undefined){appearance.shadows(Boolean(patch.shadows));appearanceState.shadows=Boolean(patch.shadows);}
if(patch.effects!==undefined){effects.setEnabled(Boolean(patch.effects));appearanceState.effects=Boolean(patch.effects);} if(patch.effects!==undefined){effects.setEnabled(Boolean(patch.effects));appearanceState.effects=Boolean(patch.effects);}
if(patch.quality!==undefined){if(!['standard','high'].includes(patch.quality))throw Error('不支持的画质');effects.setQuality(patch.quality);appearanceState.quality=patch.quality;localStorage.setItem('zjb-render-quality-v27',patch.quality);} if(patch.quality!==undefined){if(!['standard','high'].includes(patch.quality))throw Error('不支持的画质');effects.setQuality(patch.quality);appearanceState.quality=patch.quality;localStorage.setItem('zjb-render-quality-v27',patch.quality);}
window.webQA.appearance={...appearanceState};
postState(); postState();
} }
function toggleContext(){contextVisible=!contextVisible;applyVisibility();appearance.refresh();fit();postState();} function toggleContext(){contextVisible=!contextVisible;window.webQA.contextVisible=contextVisible;applyVisibility();appearance.refresh();postState();}
function toggleRoof(){roofVisible=!roofVisible;applyVisibility();appearance.refresh();postState();} function toggleRoof(){roofVisible=!roofVisible;applyVisibility();appearance.refresh();postState();}
function resizeView(){const current=viewport();camera.aspect=current.width/current.height;camera.updateProjectionMatrix();renderer.setSize(current.width,current.height);effects.resize(current.width,current.height);} function resizeView(){const current=viewport();camera.aspect=current.width/current.height;camera.updateProjectionMatrix();renderer.setSize(current.width,current.height);effects.resize(current.width,current.height);}
addEventListener('resize',resizeView); addEventListener('resize',resizeView);
@@ -146,7 +149,7 @@ async function locateAsset(item){
if(mode!==targetMode)await show(targetMode,{frame:false});if(request!==locateRequest||generation!==expected)return; if(mode!==targetMode)await show(targetMode,{frame:false});if(request!==locateRequest||generation!==expected)return;
setDisplayMode(targetMode==='hydraulic'?'coordinated':'global');contextVisible=targetMode!=='hydraulic';applyVisibility(); setDisplayMode(targetMode==='hydraulic'?'coordinated':'global');contextVisible=targetMode!=='hydraulic';applyVisibility();
const box=inspector.bounds(item);if(box.isEmpty())return;box.expandByScalar(item.link?.kind==='PUMPS'?.6:1); const box=inspector.bounds(item);if(box.isEmpty())return;box.expandByScalar(item.link?.kind==='PUMPS'?.6:1);
const pose=framePose(box,camera.aspect,{padding:1.3});controls.target.fromArray(pose.target);camera.position.fromArray(pose.position);camera.far=20000;controls.update();appearance.frame(box,targetMode);inspector.highlight();postState(); const pose=framePose(box,camera.aspect,{padding:1.3});camera.up.fromArray(pose.up??[0,1,0]).normalize();controls.target.fromArray(pose.target);camera.position.fromArray(pose.position);camera.far=20000;controls.update();appearance.frame(box,targetMode);inspector.highlight();postState();
} }
let lastFrame=performance.now(),frameTimes=[]; let lastFrame=performance.now(),frameTimes=[];
@@ -155,15 +158,16 @@ renderer.setAnimationLoop(()=>{
if(frameTimes.length>=60)window.webQA.performance={samples:frameTimes.length,meanFps:1000/(frameTimes.reduce((sum,value)=>sum+value,0)/frameTimes.length),viewport:viewport(),quality:effects.state.quality}; if(frameTimes.length>=60)window.webQA.performance={samples:frameTimes.length,meanFps:1000/(frameTimes.reduce((sum,value)=>sum+value,0)/frameTimes.length),viewport:viewport(),quality:effects.state.quality};
renderer.info.reset();navigation?.tick(performance.now());controls.update();const near=Math.max(.02,camera.position.distanceTo(controls.target)/150); renderer.info.reset();navigation?.tick(performance.now());controls.update();const near=Math.max(.02,camera.position.distanceTo(controls.target)/150);
if(Math.abs(camera.near-near)>.001){camera.near=near;camera.updateProjectionMatrix();} if(Math.abs(camera.near-near)>.001){camera.near=near;camera.updateProjectionMatrix();}
effects.render(mode);window.webQA.drawCalls=renderer.info.render.calls;window.webQA.triangles=renderer.info.render.triangles;document.getElementById('qa').textContent=JSON.stringify(window.webQA); effects.render(mode);window.webQA.drawCalls=renderer.info.render.calls;window.webQA.triangles=renderer.info.render.triangles;window.webQA.camera={active:cameraState.active,position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray()};document.getElementById('qa').textContent=JSON.stringify(window.webQA);
}); });
net=await (await fetch(manifest.networkModel.metadata,{cache:'no-store'})).json(); net=await (await fetch(manifest.networkModel.metadata,{cache:'no-store'})).json();
const built=await loader.loadAsync(manifest.networkModel.file+'?v='+manifest.networkModel.sha256);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot); const built=await loader.loadAsync(manifest.networkModel.file+'?v='+manifest.networkModel.sha256);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot);
networkStyle=attachNetworkStyle(networkRoot,net);grain(networkStyle.pipes.material,{frequency:140,amplitude:.00004,variation:.08}); networkStyle=attachNetworkStyle(networkRoot,net);grain(networkStyle.pipes.material,{frequency:140,amplitude:.00004,variation:.08});
try{const saved=localStorage.getItem('zjb-network-style-v23');if(saved)networkStyle.setStyle(JSON.parse(saved));}catch{} try{const saved=localStorage.getItem('zjb-network-style-v24');if(saved)networkStyle.setStyle(JSON.parse(saved));}catch{}
try{const quality=localStorage.getItem('zjb-render-quality-v27')??'standard';effects.setQuality(quality);appearanceState.quality=quality;}catch{effects.setQuality('standard');} try{const quality=localStorage.getItem('zjb-render-quality-v27')??'standard';effects.setQuality(quality);appearanceState.quality=quality;}catch{effects.setQuality('standard');}
window.webQA.network={...net.connectivity,meters:net.cadEquipmentBindings.length,modelId:net.modelId,runtimeInpRequired:false}; window.webQA.network={...net.connectivity,meters:net.cadEquipmentBindings.length,modelId:net.modelId,runtimeInpRequired:false};
window.webQA.networkStyle={...networkStyle.summary,style:networkStyle.style};window.webQA.appearance={...appearanceState};
navigation=createCameraNavigation({camera,controls,model:net,networkRoot,root,show,getMode:()=>mode,appearance,getDisplayMode:()=>networkStyle.displayMode,setDisplayMode,onChange(state){ navigation=createCameraNavigation({camera,controls,model:net,networkRoot,root,show,getMode:()=>mode,appearance,getDisplayMode:()=>networkStyle.displayMode,setDisplayMode,onChange(state){
cameraState={active:state.active,note:state.note,views:state.views.map(view=>({id:view.id,label:view.label,mode:view.mode,note:view.note,saved:view.id.startsWith('saved-')}))};postState(); cameraState={active:state.active,note:state.note,views:state.views.map(view=>({id:view.id,label:view.label,mode:view.mode,note:view.note,saved:view.id.startsWith('saved-')}))};postState();
}}); }});
@@ -185,8 +189,8 @@ window.addEventListener('message',async event=>{
else if(command.name==='visit-camera')await navigation.visit(command.viewId); else if(command.name==='visit-camera')await navigation.visit(command.viewId);
else if(command.name==='save-camera')navigation.save(command.label); else if(command.name==='save-camera')navigation.save(command.label);
else if(command.name==='remove-camera')navigation.remove(command.viewId); else if(command.name==='remove-camera')navigation.remove(command.viewId);
else if(command.name==='set-style'){networkStyle.setStyle(command.patch);localStorage.setItem('zjb-network-style-v23',JSON.stringify(networkStyle.style));updateStyle();} else if(command.name==='set-style'){networkStyle.setStyle(command.patch);localStorage.setItem('zjb-network-style-v24',JSON.stringify(networkStyle.style));updateStyle();}
else if(command.name==='reset-style'){networkStyle.setStyle(DEFAULT_STYLE);localStorage.removeItem('zjb-network-style-v23');updateStyle();} else if(command.name==='reset-style'){networkStyle.setStyle(DEFAULT_STYLE);localStorage.removeItem('zjb-network-style-v24');updateStyle();}
else if(command.name==='set-display-mode')setDisplayMode(command.mode); else if(command.name==='set-display-mode')setDisplayMode(command.mode);
else if(command.name==='set-appearance')applyAppearance(command.patch); else if(command.name==='set-appearance')applyAppearance(command.patch);
else if(command.name==='toggle-context')toggleContext(); else if(command.name==='toggle-context')toggleContext();
@@ -10,7 +10,7 @@ const state: SceneRuntimeState = {
roofVisible: true, roofVisible: true,
displayMode: "global", displayMode: "global",
style: { style: {
scale: 8, scale: 6,
mode: "uniform", mode: "uniform",
color: "#098ed0", color: "#098ed0",
missingColor: "#89949d", missingColor: "#89949d",
@@ -30,6 +30,7 @@ const state: SceneRuntimeState = {
appearance: { appearance: {
preset: "day", preset: "day",
exposure: 0.95, exposure: 0.95,
contextOpacity: 0.4,
shadows: true, shadows: true,
effects: true, effects: true,
quality: "standard", quality: "standard",
@@ -49,6 +50,48 @@ const state: SceneRuntimeState = {
}; };
describe("ThreeDimensionalControls", () => { describe("ThreeDimensionalControls", () => {
it("toggles the active toolbar panel and opens a different panel", () => {
const onOpenChange = jest.fn();
const onTabChange = jest.fn();
const { rerender } = render(
<ThreeDimensionalControls
open
activeTab="scene"
ready
state={state}
selection={null}
timelineOpen
onOpenChange={onOpenChange}
onTimelineOpenChange={jest.fn()}
onTabChange={onTabChange}
onCommand={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "场景与视角" }));
expect(onOpenChange).toHaveBeenLastCalledWith(false);
expect(onTabChange).not.toHaveBeenCalled();
rerender(
<ThreeDimensionalControls
open={false}
activeTab="scene"
ready
state={state}
selection={null}
timelineOpen
onOpenChange={onOpenChange}
onTimelineOpenChange={jest.fn()}
onTabChange={onTabChange}
onCommand={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "管网样式" }));
expect(onTabChange).toHaveBeenLastCalledWith("style");
expect(onOpenChange).toHaveBeenLastCalledWith(true);
});
it("sends typed scene commands from platform controls", () => { it("sends typed scene commands from platform controls", () => {
const onCommand = jest.fn(); const onCommand = jest.fn();
const onTimelineOpenChange = jest.fn(); const onTimelineOpenChange = jest.fn();
@@ -69,10 +112,49 @@ describe("ThreeDimensionalControls", () => {
fireEvent.click(screen.getByRole("button", { name: "站房精细版" })); fireEvent.click(screen.getByRole("button", { name: "站房精细版" }));
expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" }); expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" });
fireEvent.change(screen.getByRole("slider", { name: "建筑背景透明度 60%" }), {
target: { value: "0.35" },
});
expect(onCommand).toHaveBeenCalledWith({
name: "set-appearance",
patch: { contextOpacity: 0.65 },
});
fireEvent.click(screen.getByRole("switch", { name: "建筑背景" }));
expect(onCommand).toHaveBeenCalledWith({ name: "toggle-context" });
fireEvent.click(screen.getByRole("button", { name: "时间轴" })); fireEvent.click(screen.getByRole("button", { name: "时间轴" }));
expect(onTimelineOpenChange).toHaveBeenCalledWith(false); expect(onTimelineOpenChange).toHaveBeenCalledWith(false);
}); });
it("uses the glass listbox instead of a native select", () => {
const onCommand = jest.fn();
render(
<ThreeDimensionalControls
open
activeTab="scene"
ready
state={state}
selection={null}
timelineOpen
onOpenChange={jest.fn()}
onTimelineOpenChange={jest.fn()}
onTabChange={jest.fn()}
onCommand={onCommand}
/>,
);
const displayMode = screen.getByRole("combobox", {
name: "管网展示比例",
});
expect(displayMode.tagName).not.toBe("SELECT");
fireEvent.mouseDown(displayMode);
fireEvent.click(screen.getByRole("option", { name: "泵房协调比例" }));
expect(onCommand).toHaveBeenCalledWith({
name: "set-display-mode",
mode: "coordinated",
});
});
it("shows selected asset fields and linked assets in the property tab", () => { it("shows selected asset fields and linked assets in the property tab", () => {
const onCommand = jest.fn(); const onCommand = jest.fn();
render( render(
@@ -1,10 +1,14 @@
"use client"; "use client";
import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select";
import clsx from "clsx"; import clsx from "clsx";
import { useState, type ReactNode } from "react"; import { useId, useState, type ReactNode } from "react";
import { import {
FiBox, FiBox,
FiCamera, FiCamera,
FiCheck,
FiChevronDown,
FiChevronRight, FiChevronRight,
FiClock, FiClock,
FiCrosshair, FiCrosshair,
@@ -40,6 +44,90 @@ const glassSurface =
"bg-[linear-gradient(135deg,rgba(255,255,255,0.50),rgba(224,239,250,0.28))] [backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [-webkit-backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-1px_0_rgba(112,145,168,0.18),0_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/55"; "bg-[linear-gradient(135deg,rgba(255,255,255,0.50),rgba(224,239,250,0.28))] [backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [-webkit-backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-1px_0_rgba(112,145,168,0.18),0_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/55";
const fieldClass = const fieldClass =
"h-10 w-full rounded-lg border border-slate-300/70 bg-white/55 px-3 text-sm text-slate-800 outline-none transition focus:border-blue-500 focus:bg-white/80 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50"; "h-10 w-full rounded-lg border border-slate-300/70 bg-white/55 px-3 text-sm text-slate-800 outline-none transition focus:border-blue-500 focus:bg-white/80 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50";
const selectSx = {
height: 40,
borderRadius: "0.5rem",
backgroundColor: "rgba(255,255,255,0.55)",
color: "#1e293b",
fontSize: "0.875rem",
transition: "background-color 150ms ease, box-shadow 150ms ease",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.78)",
},
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
minHeight: "0 !important",
padding: "8px 38px 8px 12px !important",
},
"& .MuiSelect-icon": {
right: 12,
color: "#64748b",
fontSize: 16,
transition: "transform 150ms ease",
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(148,163,184,0.7)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(59,130,246,0.65)",
},
"&.Mui-focused": {
backgroundColor: "rgba(255,255,255,0.82)",
boxShadow: "0 0 0 3px rgba(59,130,246,0.16)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "#3b82f6",
borderWidth: 1,
},
};
const selectMenuProps = {
disableScrollLock: true,
PaperProps: {
elevation: 0,
sx: {
mt: 0.75,
overflow: "hidden",
borderRadius: "0.75rem",
border: "1px solid rgba(255,255,255,0.72)",
background:
"linear-gradient(135deg, rgba(244,249,252,0.94), rgba(225,239,248,0.88))",
backdropFilter: "blur(24px) saturate(155%)",
WebkitBackdropFilter: "blur(24px) saturate(155%)",
boxShadow:
"inset 0 1px 0 rgba(255,255,255,0.92), 0 16px 42px rgba(15,43,69,0.22)",
},
},
MenuListProps: {
sx: {
p: 0.75,
},
},
};
const selectMenuItemSx = {
minHeight: 40,
gap: 1,
borderRadius: "0.5rem",
px: 1.25,
color: "#334155",
fontSize: "0.875rem",
transition: "background-color 120ms ease, color 120ms ease",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.68)",
color: "#1d4ed8",
},
"&.Mui-selected": {
backgroundColor: "#2563eb",
color: "#fff",
},
"&.Mui-selected:hover": {
backgroundColor: "#1d4ed8",
},
"&.Mui-focusVisible": {
outline: "2px solid rgba(59,130,246,0.42)",
outlineOffset: -2,
},
};
export type ControlTab = "scene" | "style" | "properties"; export type ControlTab = "scene" | "style" | "properties";
@@ -71,6 +159,10 @@ export function ThreeDimensionalControls({
const [cameraLabel, setCameraLabel] = useState(""); const [cameraLabel, setCameraLabel] = useState("");
const selectTab = (tab: ControlTab) => { const selectTab = (tab: ControlTab) => {
if (open && activeTab === tab) {
onOpenChange(false);
return;
}
onTabChange(tab); onTabChange(tab);
onOpenChange(true); onOpenChange(true);
}; };
@@ -150,7 +242,7 @@ export function ThreeDimensionalControls({
aria-label="三维场景控制面板" aria-label="三维场景控制面板"
className={clsx( className={clsx(
glassSurface, glassSurface,
"absolute inset-x-2 bottom-2 z-30 flex max-h-[min(64dvh,560px)] flex-col overflow-hidden rounded-2xl md:inset-x-auto md:bottom-4 md:right-4 md:top-4 md:h-auto md:max-h-[760px] md:w-96", "absolute inset-x-2 bottom-2 z-30 flex h-[min(64dvh,560px)] flex-col overflow-hidden rounded-2xl md:inset-x-auto md:bottom-auto md:right-4 md:top-4 md:h-[min(760px,calc(100%-2rem))] md:w-96",
)} )}
> >
<div className="flex min-h-14 items-center gap-3 border-b border-white/45 bg-white/10 px-4"> <div className="flex min-h-14 items-center gap-3 border-b border-white/45 bg-white/10 px-4">
@@ -175,7 +267,7 @@ export function ThreeDimensionalControls({
<div <div
aria-disabled={!ready} aria-disabled={!ready}
className={clsx( className={clsx(
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 [scrollbar-color:rgba(100,116,139,.45)_transparent] [scrollbar-width:thin]", "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 [overflow-anchor:none] [scrollbar-color:rgba(100,116,139,.45)_transparent] [scrollbar-width:thin]",
!ready && "pointer-events-none opacity-50", !ready && "pointer-events-none opacity-50",
)} )}
> >
@@ -255,6 +347,15 @@ export function ThreeDimensionalControls({
<ControlSection title="场景显示"> <ControlSection title="场景显示">
<SwitchRow label="建筑背景" checked={state.contextVisible} onChange={() => onCommand({ name: "toggle-context" })} /> <SwitchRow label="建筑背景" checked={state.contextVisible} onChange={() => onCommand({ name: "toggle-context" })} />
<LabeledSlider
label={`建筑背景透明度 ${Math.round((1 - state.appearance.contextOpacity) * 100)}%`}
value={1 - state.appearance.contextOpacity}
min={0}
max={1}
step={0.05}
disabled={!state.contextVisible}
onChange={(value) => onCommand({ name: "set-appearance", patch: { contextOpacity: 1 - value } })}
/>
<SwitchRow label="屋盖与吊顶" checked={state.roofVisible} onChange={() => onCommand({ name: "toggle-roof" })} /> <SwitchRow label="屋盖与吊顶" checked={state.roofVisible} onChange={() => onCommand({ name: "toggle-roof" })} />
<SelectField <SelectField
label="管网展示比例" label="管网展示比例"
@@ -386,31 +487,64 @@ function ControlSection({ title, description, children }: { title: string; descr
} }
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) { function SelectField({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) {
const labelId = useId();
const selected = options.find((option) => option.value === value);
return ( return (
<label className="block"> <div className="block">
<span className="mb-1 block text-xs text-slate-500">{label}</span> <span id={labelId} className="mb-1 block text-xs text-slate-500">{label}</span>
<select className={fieldClass} value={value} onChange={(event) => onChange(event.target.value)}> <Select
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)} fullWidth
</select> labelId={labelId}
</label> value={value}
renderValue={() => selected?.label ?? value}
MenuProps={selectMenuProps}
IconComponent={FiChevronDown}
onChange={(event) => onChange(String(event.target.value))}
sx={selectSx}
>
{options.map((option) => {
const optionSelected = option.value === value;
return (
<MenuItem key={option.value} value={option.value} sx={selectMenuItemSx}>
<span>{option.label}</span>
{optionSelected && <FiCheck aria-hidden="true" className="ml-auto text-base" />}
</MenuItem>
);
})}
</Select>
</div>
); );
} }
function SwitchRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) { function SwitchRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
return ( return (
<label className="flex min-h-10 cursor-pointer items-center justify-between gap-3 rounded-lg px-1 text-sm text-slate-700"> <button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="flex min-h-10 w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-1 text-sm text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
>
<span>{label}</span> <span>{label}</span>
<input type="checkbox" className="peer sr-only" checked={checked} onChange={(event) => onChange(event.target.checked)} /> <span
<span aria-hidden="true" className="relative h-6 w-11 shrink-0 rounded-full bg-slate-300/80 transition peer-checked:bg-blue-600 peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500/40 peer-focus-visible:ring-offset-2 after:absolute after:left-1 after:top-1 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-5" /> aria-hidden="true"
</label> className={clsx(
"relative h-6 w-11 shrink-0 rounded-full transition-colors after:absolute after:left-1 after:top-1 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform",
checked
? "bg-blue-600 after:translate-x-5"
: "bg-slate-300/80",
)}
/>
</button>
); );
} }
function LabeledSlider({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) { function LabeledSlider({ label, value, min, max, step, disabled = false, onChange }: { label: string; value: number; min: number; max: number; step: number; disabled?: boolean; onChange: (value: number) => void }) {
return ( return (
<label className="block"> <label className={clsx("block", disabled && "opacity-45")}>
<span className="mb-1.5 block text-xs tabular-nums text-slate-500">{label}</span> <span className="mb-1.5 flex items-center justify-between text-xs tabular-nums text-slate-500">{label}</span>
<input type="range" aria-label={label} className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-300/75 accent-blue-600" value={value} min={min} max={max} step={step} onChange={(event) => onChange(Number(event.target.value))} /> <input type="range" aria-label={label} className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-300/75 accent-blue-600 disabled:cursor-not-allowed" value={value} min={min} max={max} step={step} disabled={disabled} onChange={(event) => onChange(Number(event.target.value))} />
</label> </label>
); );
} }
@@ -38,7 +38,7 @@ import {
type SceneRuntimeState, type SceneRuntimeState,
} from "./sceneProtocol"; } from "./sceneProtocol";
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2"; const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity";
const SCENE_LOAD_TIMEOUT_MS = 30_000; const SCENE_LOAD_TIMEOUT_MS = 30_000;
const initialRuntimeState: SceneRuntimeState = { const initialRuntimeState: SceneRuntimeState = {
@@ -48,7 +48,7 @@ const initialRuntimeState: SceneRuntimeState = {
roofVisible: true, roofVisible: true,
displayMode: "global", displayMode: "global",
style: { style: {
scale: 8, scale: 6,
mode: "uniform", mode: "uniform",
color: "#098ed0", color: "#098ed0",
missingColor: "#89949d", missingColor: "#89949d",
@@ -68,6 +68,7 @@ const initialRuntimeState: SceneRuntimeState = {
appearance: { appearance: {
preset: "day", preset: "day",
exposure: 0.95, exposure: 0.95,
contextOpacity: 0.4,
shadows: true, shadows: true,
effects: true, effects: true,
quality: "standard", quality: "standard",
@@ -128,7 +129,7 @@ export default function ThreeDimensionalScene() {
const [runtimeState, setRuntimeState] = const [runtimeState, setRuntimeState] =
useState<SceneRuntimeState>(initialRuntimeState); useState<SceneRuntimeState>(initialRuntimeState);
const [selection, setSelection] = useState<SceneAssetSelection | null>(null); const [selection, setSelection] = useState<SceneAssetSelection | null>(null);
const [controlsOpen, setControlsOpen] = useState(false); const [controlsOpen, setControlsOpen] = useState(true);
const [controlTab, setControlTab] = useState<ControlTab>("scene"); const [controlTab, setControlTab] = useState<ControlTab>("scene");
const [timelineOpen, setTimelineOpen] = useState(true); const [timelineOpen, setTimelineOpen] = useState(true);
const [pressureDevices, setPressureDevices] = useState<PressureDevice[]>([]); const [pressureDevices, setPressureDevices] = useState<PressureDevice[]>([]);
@@ -38,6 +38,7 @@ export type SceneNetworkStyle = {
export type SceneAppearance = { export type SceneAppearance = {
preset: SceneLightingPreset; preset: SceneLightingPreset;
exposure: number; exposure: number;
contextOpacity: number;
shadows: boolean; shadows: boolean;
effects: boolean; effects: boolean;
quality: SceneQuality; quality: SceneQuality;