diff --git a/e2e/three-dimensional-camera.spec.ts b/e2e/three-dimensional-camera.spec.ts new file mode 100644 index 0000000..da86b5a --- /dev/null +++ b/e2e/three-dimensional-camera.spec.ts @@ -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); +}); diff --git a/jest.config.js b/jest.config.js index e5bc46a..a4ebefb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,7 @@ const createJestConfig = nextJest({ const customJestConfig = { setupFilesAfterEnv: ['/jest.setup.js'], testEnvironment: 'jest-environment-jsdom', + testPathIgnorePatterns: ['/e2e/'], moduleNameMapper: { '^@pages/(.*)$': '/pages/$1', '^@/(.*)$': '/src/$1', diff --git a/public/three-dimensional/zjb/v29/appearance.mjs b/public/three-dimensional/zjb/v29/appearance.mjs index e946656..778e828 100644 --- a/public/three-dimensional/zjb/v29/appearance.mjs +++ b/public/three-dimensional/zjb/v29/appearance.mjs @@ -85,14 +85,14 @@ export function enhanceMaterials(object){ } // Restore the authored appearance on every mode change. In network overview, -// opaque buildings fade more than glass so the curtain wall remains legible. -export function applyBuildingContext(material,fade){ +// all architectural context follows the same user-controlled opacity ceiling. +export function applyBuildingContext(material,fade,contextOpacity=.4){ const original=material.userData.original??{opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite}; material.userData.original=original; - const facade=/v3_glass|coatedcurtainglass/i.test(material.name); - material.opacity=fade?(facade?Math.min(original.opacity,.38):.12):original.opacity; - material.transparent=fade||original.transparent; - material.depthWrite=fade?false:original.depthWrite;material.needsUpdate=true; + const opacity=THREE.MathUtils.clamp(contextOpacity,0,1),contextualOpacity=Math.min(original.opacity,opacity); + material.opacity=fade?contextualOpacity:original.opacity; + material.transparent=fade?(contextualOpacity<1||original.transparent):original.transparent; + material.depthWrite=fade&&contextualOpacity<1?false:original.depthWrite;material.needsUpdate=true; } const presets={ diff --git a/public/three-dimensional/zjb/v29/camera-navigation.mjs b/public/three-dimensional/zjb/v29/camera-navigation.mjs index 405938c..b76bf49 100644 --- a/public/three-dimensional/zjb/v29/camera-navigation.mjs +++ b/public/three-dimensional/zjb/v29/camera-navigation.mjs @@ -1,15 +1,56 @@ -import * as THREE from 'three'; -export function framePose(box,aspect,{direction=[.6,.8,1],padding=1.2,fov=42}={}){ - if(box.isEmpty())throw Error('视角目标没有可见模型。'); - const sphere=box.getBoundingSphere(new THREE.Sphere()),angle=Math.min(fov*Math.PI/360,Math.atan(Math.tan(fov*Math.PI/360)*aspect)); - 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()}; -} -export function createCameraNavigation({camera,controls,model,networkRoot,root,show,getMode,appearance,onChange,getDisplayMode=()=> 'global',setDisplayMode=()=>{}}){ +import * as THREE from 'three'; +const WORLD_UP=new THREE.Vector3(0,1,0); +function boxCorners(box){const {min,max}=box;return [ + 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), + 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 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:'站房精细模型与周边关系'}]; 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 · 上层配水未推断'}); 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 stop(){request++;flight=null;active=null;emit('自由浏览 · 可保存当前视角');} 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); 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==='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==='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==='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==='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==='crossing'){const a=model.layouts[8].pipes;let i=0;for(let j=0;j{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(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.2);} + 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;jp.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; 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]; - 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; 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();} @@ -37,7 +79,7 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s }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 save(label){label=label.trim();if(!label||label.length>24)throw Error('请输入 1–24 字的视角名称。');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('请输入 1–24 字的视角名称。');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('已移除保存的视角');} emit('选择一个观察位置');return {visit,tick,save,remove,stop,presets}; } diff --git a/public/three-dimensional/zjb/v29/network-style.mjs b/public/three-dimensional/zjb/v29/network-style.mjs index 245f140..0457bb9 100644 --- a/public/three-dimensional/zjb/v29/network-style.mjs +++ b/public/three-dimensional/zjb/v29/network-style.mjs @@ -1,6 +1,6 @@ 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); export function validateResults(input,model){ if(input?.modelId!==model.modelId)throw Error('结果文件与当前管网模型编号不一致。'); diff --git a/public/three-dimensional/zjb/v29/preview.html b/public/three-dimensional/zjb/v29/preview.html index 7f3b773..b6dc731 100644 --- a/public/three-dimensional/zjb/v29/preview.html +++ b/public/three-dimensional/zjb/v29/preview.html @@ -20,6 +20,6 @@
正在载入三维模型
- + diff --git a/public/three-dimensional/zjb/v29/preview.mjs b/public/three-dimensional/zjb/v29/preview.mjs index 163cc5f..38e9e23 100644 --- a/public/three-dimensional/zjb/v29/preview.mjs +++ b/public/three-dimensional/zjb/v29/preview.mjs @@ -1,20 +1,20 @@ 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 * as THREE from 'three'; import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js'; import {OrbitControls} from 'three/addons/controls/OrbitControls.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 {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'; let net,networkRoot,networkStyle,navigation,inspector; let mode='network',generation=0,contextVisible=true,roofVisible=true; let sceneStatus='正在载入三维模型'; 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={}){ if(window.parent===window)return; @@ -37,7 +37,7 @@ function runtimeState(){ function postState(){if(net&&networkStyle)postHost('scene-state',{state:runtimeState()});} 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('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; appearance.frame(box,mode); 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.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; root.clear();objects.forEach((object,index)=>{ 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); 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){ 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.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.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);} + window.webQA.appearance={...appearanceState}; 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 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); @@ -146,7 +149,7 @@ async function locateAsset(item){ if(mode!==targetMode)await show(targetMode,{frame:false});if(request!==locateRequest||generation!==expected)return; 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 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=[]; @@ -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}; 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();} - 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(); 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}); -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');} 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){ 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==='save-camera')navigation.save(command.label); 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==='reset-style'){networkStyle.setStyle(DEFAULT_STYLE);localStorage.removeItem('zjb-network-style-v23');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-v24');updateStyle();} else if(command.name==='set-display-mode')setDisplayMode(command.mode); else if(command.name==='set-appearance')applyAppearance(command.patch); else if(command.name==='toggle-context')toggleContext(); diff --git a/src/components/threeDimensional/ThreeDimensionalControls.test.tsx b/src/components/threeDimensional/ThreeDimensionalControls.test.tsx index c4d3c14..626aa4a 100644 --- a/src/components/threeDimensional/ThreeDimensionalControls.test.tsx +++ b/src/components/threeDimensional/ThreeDimensionalControls.test.tsx @@ -10,7 +10,7 @@ const state: SceneRuntimeState = { roofVisible: true, displayMode: "global", style: { - scale: 8, + scale: 6, mode: "uniform", color: "#098ed0", missingColor: "#89949d", @@ -30,6 +30,7 @@ const state: SceneRuntimeState = { appearance: { preset: "day", exposure: 0.95, + contextOpacity: 0.4, shadows: true, effects: true, quality: "standard", @@ -49,6 +50,48 @@ const state: SceneRuntimeState = { }; describe("ThreeDimensionalControls", () => { + it("toggles the active toolbar panel and opens a different panel", () => { + const onOpenChange = jest.fn(); + const onTabChange = jest.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "场景与视角" })); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(onTabChange).not.toHaveBeenCalled(); + + rerender( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "管网样式" })); + expect(onTabChange).toHaveBeenLastCalledWith("style"); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + }); + it("sends typed scene commands from platform controls", () => { const onCommand = jest.fn(); const onTimelineOpenChange = jest.fn(); @@ -69,10 +112,49 @@ describe("ThreeDimensionalControls", () => { fireEvent.click(screen.getByRole("button", { name: "站房精细版" })); 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: "时间轴" })); expect(onTimelineOpenChange).toHaveBeenCalledWith(false); }); + it("uses the glass listbox instead of a native select", () => { + const onCommand = jest.fn(); + render( + , + ); + + 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", () => { const onCommand = jest.fn(); render( diff --git a/src/components/threeDimensional/ThreeDimensionalControls.tsx b/src/components/threeDimensional/ThreeDimensionalControls.tsx index 20566b9..0ab71e1 100644 --- a/src/components/threeDimensional/ThreeDimensionalControls.tsx +++ b/src/components/threeDimensional/ThreeDimensionalControls.tsx @@ -1,10 +1,14 @@ "use client"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; import clsx from "clsx"; -import { useState, type ReactNode } from "react"; +import { useId, useState, type ReactNode } from "react"; import { FiBox, FiCamera, + FiCheck, + FiChevronDown, FiChevronRight, FiClock, 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"; 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"; +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"; @@ -71,6 +159,10 @@ export function ThreeDimensionalControls({ const [cameraLabel, setCameraLabel] = useState(""); const selectTab = (tab: ControlTab) => { + if (open && activeTab === tab) { + onOpenChange(false); + return; + } onTabChange(tab); onOpenChange(true); }; @@ -150,7 +242,7 @@ export function ThreeDimensionalControls({ aria-label="三维场景控制面板" className={clsx( 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", )} >
@@ -175,7 +267,7 @@ export function ThreeDimensionalControls({
@@ -255,6 +347,15 @@ export function ThreeDimensionalControls({ onCommand({ name: "toggle-context" })} /> + onCommand({ name: "set-appearance", patch: { contextOpacity: 1 - value } })} + /> onCommand({ name: "toggle-roof" })} /> ; onChange: (value: string) => void }) { + const labelId = useId(); + const selected = options.find((option) => option.value === value); + return ( - +
+ {label} + +
); } function SwitchRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) { return ( -