Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a90be0d3 | ||
|
|
1b275206a9 | ||
|
|
cdf97afbc4 | ||
|
|
ca10d8f384 | ||
|
|
1622eaa2da | ||
|
|
a9b25b94d8 | ||
|
|
69ad145e75 | ||
|
|
331ea3f094 | ||
|
|
08ddb4453d | ||
|
|
35b6819459 | ||
|
|
3ebe3328aa |
@@ -0,0 +1,63 @@
|
||||
name: Frontend E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
playwright:
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
GIT_USERNAME: ${{ github.actor }}
|
||||
GIT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
case "$SERVER_URL" in
|
||||
http://*)
|
||||
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
|
||||
;;
|
||||
https://*)
|
||||
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
|
||||
;;
|
||||
*)
|
||||
AUTH_SERVER_URL="$SERVER_URL"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -d .git ]; then
|
||||
git init .
|
||||
fi
|
||||
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
||||
else
|
||||
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
||||
fi
|
||||
|
||||
git fetch --depth=1 origin "$COMMIT_SHA"
|
||||
git checkout --force --detach FETCH_HEAD
|
||||
git clean -ffdx
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
CI: "true"
|
||||
run: npm run test:e2e
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
/e2e/.auth/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -44,6 +44,7 @@ npm run dev
|
||||
npm run lint
|
||||
npm test
|
||||
npm run test:coverage
|
||||
npm run test:e2e
|
||||
npm run build
|
||||
npm run start
|
||||
docker build -t tjwater-frontend:local .
|
||||
@@ -52,6 +53,7 @@ docker build -t tjwater-frontend:local .
|
||||
- `npm run lint`:运行 ESLint。
|
||||
- `npm test`:运行 Jest。
|
||||
- `npm run test:coverage`:生成测试覆盖率。
|
||||
- `npm run test:e2e`:启动本地 Next.js 与 Playwright Chromium 烟测。
|
||||
- `npm run build`:生成生产构建。
|
||||
- `npm run start`:启动生产模式服务。
|
||||
|
||||
@@ -86,6 +88,36 @@ npm run build
|
||||
|
||||
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。
|
||||
|
||||
### Playwright E2E
|
||||
|
||||
首次运行先安装 Chromium:
|
||||
|
||||
```bash
|
||||
npm run e2e:install
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
本地测试会自动构建生产版本并启动隔离的 `http://127.0.0.1:3100`,生成仅用于测试的 NextAuth
|
||||
会话,并模拟后端 API,因此不要求启动 Keycloak、Server 或 Agent。失败时可通过
|
||||
`npm run test:e2e:report` 查看 HTML 报告,交互调试可使用
|
||||
`npm run test:e2e:ui` 或 `npm run test:e2e:debug`。
|
||||
|
||||
在不能访问 Playwright CDN 的内网环境,可设置
|
||||
`E2E_CHROMIUM_PATH=/absolute/path/to/chrome` 复用预装的 Chromium/Chrome。
|
||||
|
||||
若要对已部署环境运行测试,请传入环境地址和预先保存的管理员 Playwright 登录状态:
|
||||
|
||||
```bash
|
||||
E2E_BASE_URL=https://example.test \
|
||||
E2E_STORAGE_STATE=/absolute/path/to/storage-state.json \
|
||||
E2E_USE_REAL_SERVICES=true \
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
`E2E_BASE_URL` 会关闭本地开发服务器;`E2E_STORAGE_STATE` 避免在仓库中保存账号或
|
||||
会话;`E2E_USE_REAL_SERVICES=true` 会关闭 API 模拟。Gitea 的
|
||||
`.gitea/workflows/e2e.yml` 在分支推送和 PR 中运行同一组 Chromium 测试。
|
||||
|
||||
## 安全规则
|
||||
|
||||
不要提交 `.env`、`.next/`、`node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367"
|
||||
"sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+591
-1513
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockBackend } from "./support/mockBackend";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockBackend(page);
|
||||
});
|
||||
|
||||
test("已登录管理员可以打开审计日志页面", async ({ page }) => {
|
||||
await page.goto("/audit-logs");
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "审计日志", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("系统管理员权限已验证")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "查询结果" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { encode } from "next-auth/jwt";
|
||||
|
||||
import {
|
||||
AUTH_STATE_PATH,
|
||||
E2E_AUTH_SECRET,
|
||||
E2E_BASE_URL,
|
||||
} from "./support/environment";
|
||||
|
||||
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
|
||||
|
||||
export default async function globalSetup() {
|
||||
if (process.env.E2E_STORAGE_STATE) return;
|
||||
|
||||
const now = Date.now();
|
||||
const baseUrl = new URL(E2E_BASE_URL);
|
||||
const secure = baseUrl.protocol === "https:";
|
||||
const token = await encode({
|
||||
secret: E2E_AUTH_SECRET,
|
||||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||
token: {
|
||||
sub: "playwright-user",
|
||||
username: "playwright",
|
||||
name: "E2E 测试用户",
|
||||
email: "playwright@example.invalid",
|
||||
accessToken: "playwright-access-token",
|
||||
accessTokenIssuedAt: now,
|
||||
accessTokenExpires: now + SESSION_MAX_AGE_SECONDS * 1000,
|
||||
sessionExpiresAt: now + SESSION_MAX_AGE_SECONDS * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
await fs.mkdir(path.dirname(AUTH_STATE_PATH), { recursive: true });
|
||||
await fs.writeFile(
|
||||
AUTH_STATE_PATH,
|
||||
JSON.stringify(
|
||||
{
|
||||
cookies: [
|
||||
{
|
||||
name: secure
|
||||
? "__Secure-next-auth.session-token"
|
||||
: "next-auth.session-token",
|
||||
value: token,
|
||||
domain: baseUrl.hostname,
|
||||
path: "/",
|
||||
expires: Math.floor(now / 1000) + SESSION_MAX_AGE_SECONDS,
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
],
|
||||
origins: [
|
||||
{
|
||||
origin: baseUrl.origin,
|
||||
localStorage: [
|
||||
{ name: "active_project", value: "playwright-project" },
|
||||
{ name: "MAP_WORKSPACE", value: "tjwater_e2e" },
|
||||
{ name: "NETWORK_NAME", value: "tjwater_e2e" },
|
||||
{
|
||||
name: "MAP_EXTENT",
|
||||
value: "13508801.93,3608163.35,13555650.64,3633685.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockBackend } from "./support/mockBackend";
|
||||
|
||||
test("地图缩放不会重新请求已打开的 SCADA 历史数据", async ({ page }) => {
|
||||
await mockBackend(page);
|
||||
|
||||
await page.route("**/ows?**", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
id: "S-1",
|
||||
properties: {
|
||||
id: "S-1",
|
||||
device_type: "pressure",
|
||||
reliability: 0.95,
|
||||
transmission_frequency: "00:15:00",
|
||||
},
|
||||
geometry: { type: "Point", coordinates: [13530000, 3620000] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/scada-devices", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1", link_id: null },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
let historyRequests = 0;
|
||||
await page.route("**/api/v1/timeseries/views/element-history/query", (route) => {
|
||||
historyRequests += 1;
|
||||
return route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ series: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/network-simulation");
|
||||
await page.getByText("S-1", { exact: true }).click();
|
||||
await expect(page.getByText("SCADA 历史数据")).toBeVisible();
|
||||
await expect.poll(() => historyRequests).toBe(1);
|
||||
|
||||
const zoomLabel = page.getByText(/缩放: \d+\.\d/);
|
||||
const initialZoom = await zoomLabel.textContent();
|
||||
// The history drawer covers the map controls. Invoke their real handlers
|
||||
// without targeting a covered screen coordinate.
|
||||
await page.getByRole("button", { name: "放大" }).evaluate((button: HTMLButtonElement) => button.click());
|
||||
await expect(zoomLabel).not.toHaveText(initialZoom!);
|
||||
expect(historyRequests).toBe(1);
|
||||
|
||||
await page.getByRole("button", { name: "缩小" }).evaluate((button: HTMLButtonElement) => button.click());
|
||||
await expect(zoomLabel).toHaveText(initialZoom!);
|
||||
expect(historyRequests).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from "node:path";
|
||||
|
||||
export const E2E_BASE_URL =
|
||||
process.env.E2E_BASE_URL || "http://127.0.0.1:3100";
|
||||
|
||||
export const E2E_AUTH_SECRET =
|
||||
process.env.E2E_NEXTAUTH_SECRET ||
|
||||
"tjwater-playwright-local-secret-at-least-32-characters";
|
||||
|
||||
export const AUTH_STATE_PATH = path.join(
|
||||
process.cwd(),
|
||||
"e2e",
|
||||
".auth",
|
||||
"user.json",
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
const json = (route: Route, body: unknown) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const mockBackend = async (page: Page) => {
|
||||
if (process.env.E2E_USE_REAL_SERVICES === "true") return;
|
||||
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
|
||||
if (url.pathname === "/api/v1/access-context") {
|
||||
return json(route, {
|
||||
user_id: "playwright-user",
|
||||
username: "playwright",
|
||||
system_role: "system_admin",
|
||||
is_system_admin: true,
|
||||
project_id: "playwright-project",
|
||||
project_role: "project_admin",
|
||||
permissions: [
|
||||
"webgis.view",
|
||||
"simulation.view",
|
||||
"simulation.run",
|
||||
"scada.clean",
|
||||
"risk.run",
|
||||
"optimization.run",
|
||||
"burst.run",
|
||||
"audit.view",
|
||||
"environment.manage",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/v1/projects/current") {
|
||||
return json(route, {
|
||||
project_id: "playwright-project",
|
||||
code: "tjwater_e2e",
|
||||
gs_workspace: "tjwater_e2e",
|
||||
map_extent: {
|
||||
bbox: [13508801.93, 3608163.35, 13555650.64, 3633685.14],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/v1/audit-logs/count") {
|
||||
return json(route, { count: 0 });
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname === "/api/v1/audit-logs" ||
|
||||
url.pathname === "/api/v1/admin/users" ||
|
||||
url.pathname === "/api/v1/admin/projects"
|
||||
) {
|
||||
return json(route, []);
|
||||
}
|
||||
|
||||
return json(route, {});
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("未登录访问受保护页面时返回登录重定向", async ({ request }) => {
|
||||
const sessionResponse = await request.get("/api/auth/session");
|
||||
expect(await sessionResponse.json()).toEqual({});
|
||||
|
||||
const response = await request.get("/audit-logs", { maxRedirects: 0 });
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
expect(body).toContain("NEXT_REDIRECT;replace;/login;307;");
|
||||
});
|
||||
+7
-2
@@ -1,5 +1,10 @@
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
|
||||
const config = [...nextCoreWebVitals];
|
||||
const config = [
|
||||
{
|
||||
ignores: ["public/three-dimensional/**/vendor/**"],
|
||||
},
|
||||
...nextCoreWebVitals,
|
||||
];
|
||||
|
||||
export default config;
|
||||
export default config;
|
||||
|
||||
@@ -9,6 +9,7 @@ const createJestConfig = nextJest({
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
testPathIgnorePatterns: ['<rootDir>/e2e/'],
|
||||
moduleNameMapper: {
|
||||
'^@pages/(.*)$': '<rootDir>/pages/$1',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
|
||||
@@ -18,6 +18,37 @@ const nextConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/three-dimensional/zjb/v29/models/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=2592000, immutable",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/three-dimensional/zjb/v29/vendor/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=2592000, immutable",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/three-dimensional/zjb/v29/water-network-v28.json",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=2592000, immutable",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
|
||||
Generated
+46
@@ -48,6 +48,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -5710,6 +5711,22 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -19080,6 +19097,35 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"e2e:install": "playwright install chromium",
|
||||
"e2e:serve": "node scripts/startE2eServer.mjs",
|
||||
"api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
|
||||
"api:check": "node scripts/check-api-contracts.mjs",
|
||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||
@@ -64,6 +70,7 @@
|
||||
"sharp": "0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
import {
|
||||
AUTH_STATE_PATH,
|
||||
E2E_AUTH_SECRET,
|
||||
E2E_BASE_URL,
|
||||
} from "./e2e/support/environment";
|
||||
|
||||
const externalServer = Boolean(process.env.E2E_BASE_URL);
|
||||
const storageState = process.env.E2E_STORAGE_STATE || AUTH_STATE_PATH;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
expect: { timeout: 30_000 },
|
||||
reporter: process.env.CI
|
||||
? [["line"], ["html", { open: "never" }]]
|
||||
: [["list"], ["html", { open: "never" }]],
|
||||
outputDir: "test-results",
|
||||
globalSetup: "./e2e/globalSetup.ts",
|
||||
use: {
|
||||
baseURL: E2E_BASE_URL,
|
||||
locale: "zh-CN",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
launchOptions: process.env.E2E_CHROMIUM_PATH
|
||||
? { executablePath: process.env.E2E_CHROMIUM_PATH }
|
||||
: undefined,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-public",
|
||||
testMatch: /unauthenticated\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "chromium-authenticated",
|
||||
testIgnore: /unauthenticated\.spec\.ts/,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState,
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: externalServer
|
||||
? undefined
|
||||
: {
|
||||
command:
|
||||
"npm run runtime:config && npm run build && npm run e2e:serve",
|
||||
url: E2E_BASE_URL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
NEXTAUTH_URL: E2E_BASE_URL,
|
||||
NEXTAUTH_SECRET: E2E_AUTH_SECRET,
|
||||
KEYCLOAK_CLIENT_ID: "tjwater-e2e",
|
||||
KEYCLOAK_CLIENT_SECRET: "tjwater-e2e",
|
||||
KEYCLOAK_ISSUER: "http://127.0.0.1:8180/realms/tjwater-e2e",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import * as THREE from 'three';
|
||||
import {RoomEnvironment} from 'three/addons/environments/RoomEnvironment.js';
|
||||
|
||||
// Runtime-only material treatment. No geometry, hydraulic data or source GLB edits.
|
||||
// Surface grains are illustrative, not measured site textures. World-space detail
|
||||
// avoids inventing UVs for the CAD meshes, and fades below the pixel footprint.
|
||||
const noiseGLSL=`
|
||||
varying vec3 vSurfaceWorld;
|
||||
float grainHash(vec3 p){p=fract(p*.1031);p+=dot(p,p.yzx+33.33);return fract((p.x+p.y)*p.z);}
|
||||
float grainNoise(vec3 p){vec3 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
|
||||
return mix(mix(mix(grainHash(i),grainHash(i+vec3(1,0,0)),f.x),mix(grainHash(i+vec3(0,1,0)),grainHash(i+vec3(1,1,0)),f.x),f.y),mix(mix(grainHash(i+vec3(0,0,1)),grainHash(i+vec3(1,0,1)),f.x),mix(grainHash(i+vec3(0,1,1)),grainHash(i+vec3(1,1,1)),f.x),f.y),f.z);}
|
||||
`;
|
||||
export function grain(material,{frequency=180,amplitude=.00008,variation=.04,colorVariation=0}={}){
|
||||
material.onBeforeCompile=shader=>{
|
||||
shader.vertexShader=shader.vertexShader.replace('#include <common>','#include <common>\nvarying vec3 vSurfaceWorld;').replace('#include <project_vertex>','#include <project_vertex>\nvec4 surfacePosition=vec4(transformed,1.0);\n#ifdef USE_INSTANCING\nsurfacePosition=instanceMatrix*surfacePosition;\n#endif\nvSurfaceWorld=(modelMatrix*surfacePosition).xyz;');
|
||||
shader.fragmentShader=shader.fragmentShader.replace('#include <common>','#include <common>\n'+noiseGLSL)
|
||||
.replace('#include <color_fragment>',`#include <color_fragment>
|
||||
float macroFade=1.-smoothstep(.2,1.2,max(length(dFdx(vSurfaceWorld)),length(dFdy(vSurfaceWorld)))*2.7);\ndiffuseColor.rgb*=1.+(grainNoise(vSurfaceWorld*2.7)-.5)*macroFade*${colorVariation.toFixed(4)};`)
|
||||
.replace('#include <roughnessmap_fragment>',`#include <roughnessmap_fragment>
|
||||
float grainFoot=max(length(dFdx(vSurfaceWorld)),length(dFdy(vSurfaceWorld)))*${frequency.toFixed(1)};
|
||||
float grainFade=1.-smoothstep(.25,1.5,grainFoot);
|
||||
float surfaceGrain=(grainNoise(vSurfaceWorld*${frequency.toFixed(1)})-.5)*grainFade;
|
||||
roughnessFactor=clamp(roughnessFactor+surfaceGrain*${variation.toFixed(4)},.08,1.);`)
|
||||
.replace('#include <normal_fragment_maps>',`#include <normal_fragment_maps>
|
||||
float grainHeight=surfaceGrain*${amplitude.toFixed(6)};
|
||||
vec3 gx=dFdx(-vViewPosition),gy=dFdy(-vViewPosition);
|
||||
vec3 rx=cross(gy,normal),ry=cross(normal,gx);float gd=dot(gx,rx);
|
||||
normal=normalize(abs(gd)*normal-sign(gd)*(dFdx(grainHeight)*rx+dFdy(grainHeight)*ry));`);
|
||||
};
|
||||
material.customProgramCacheKey=()=>`zjb-grain-${frequency}-${amplitude}-${variation}-${colorVariation}`;
|
||||
}
|
||||
|
||||
export function enhanceMaterials(object){
|
||||
const cache=new Map();
|
||||
object.traverse(mesh=>{
|
||||
if(!mesh.isMesh)return;
|
||||
mesh.receiveShadow=true;mesh.castShadow=true;
|
||||
const convert=source=>{
|
||||
if(cache.has(source))return cache.get(source);
|
||||
const m=new THREE.MeshPhysicalMaterial();THREE.MeshStandardMaterial.prototype.copy.call(m,source);
|
||||
m.name=source.name;m.envMapIntensity=.85;
|
||||
const name=m.name.toLowerCase();let surface='paint';
|
||||
// Convert display paint swatches from sRGB; the source palette was very pale
|
||||
// under environment illumination. Preserve blue / red / green identities.
|
||||
if(/v11_blue/.test(name))m.color.set(0x176184);
|
||||
if(/v11_red/.test(name))m.color.set(0xad322c);
|
||||
if(/v11_out/.test(name))m.color.set(0x267f74);
|
||||
if(/v11_white|v11_wall/.test(name))m.color.set(0xcbd0ce);
|
||||
if(/v11_floor/.test(name))m.color.set(0x8c9692);
|
||||
if(/v3_roofinlay/.test(name))m.color.set(0x59646a);
|
||||
else if(/v3_roof|v3_white/.test(name))m.color.set(0xd8dcda);
|
||||
if(/v3_v9_road/.test(name))m.color.set(0x3e4648);
|
||||
if(/v3.*concrete/.test(name))m.color.set(0x929b96);
|
||||
if(/roof/.test(name)){surface='roof-metal';m.metalness=.5;m.roughness=.46;m.clearcoat=.12;m.envMapIntensity=.65;grain(m,{frequency:90,amplitude:.00002,variation:.045});}else if(/glass|water/.test(name)){
|
||||
surface='glass';m.metalness=.05;m.roughness=.19;m.clearcoat=1;m.clearcoatRoughness=.09;m.envMapIntensity=1.2;
|
||||
// Retain alpha visibility and avoid transmission rendering through inspection cutaways.
|
||||
}else if(/steel|lattice|perforated/.test(name)){
|
||||
surface='metal';m.metalness=.88;m.roughness=.3;m.envMapIntensity=1.1;grain(m,{frequency:260,amplitude:.00002,variation:.13});
|
||||
}else if(/concrete|floor|wall|road/.test(name)){
|
||||
surface='mineral';m.metalness=0;m.roughness=/floor/.test(name)?.64:.91;m.envMapIntensity=.3;
|
||||
grain(m,{frequency:/road/.test(name)?25:75,amplitude:.001,variation:.16,colorVariation:.1});
|
||||
}else if(/seat/.test(name)){
|
||||
surface='seat';m.metalness=.04;m.roughness=.66;grain(m,{frequency:360,amplitude:.00012,variation:.15});
|
||||
}else if(/light|diffuser|screen/.test(name)){
|
||||
surface='luminaire';m.metalness=0;m.roughness=.38;m.emissive.set(0xffe5b7);m.emissiveIntensity=.55;
|
||||
}else{
|
||||
m.metalness=/dark|black/.test(name)?.58:.23;m.roughness=/roof/.test(name)?.36:.3;
|
||||
m.clearcoat=.4;m.clearcoatRoughness=.25;grain(m);
|
||||
}
|
||||
// Facade coatings need their own optical treatment. Keep generic glass
|
||||
// (instruments, water and interiors) separate from the station curtain wall.
|
||||
if(/v3_glass|coatedcurtainglass/.test(name)){
|
||||
surface='glass';const coated=/coatedcurtain/.test(name);
|
||||
m.color.set(coated?0x466b82:0x577e90);m.metalness=coated?.42:.18;
|
||||
m.roughness=coated?.1:.13;m.opacity=coated?.78:.58;
|
||||
m.transparent=true;m.depthWrite=false;m.clearcoat=.65;
|
||||
m.clearcoatRoughness=.08;m.envMapIntensity=1.6;
|
||||
}
|
||||
m.userData={...source.userData,surfaceTreatment:surface,appearanceRevision:29};cache.set(source,m);return m;
|
||||
};
|
||||
mesh.material=Array.isArray(mesh.material)?mesh.material.map(convert):convert(mesh.material);
|
||||
mesh.castShadow=!(Array.isArray(mesh.material)?mesh.material:[mesh.material]).every(m=>m.userData.surfaceTreatment==='glass');mesh.receiveShadow=mesh.castShadow;
|
||||
});
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Restore the authored appearance on every mode change. In network overview,
|
||||
// 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 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={
|
||||
day:{background:0xe2e8e9,ground:0xb9c1bd,key:0xffefdc,keyPower:3.1,fill:.32,env:.45,rim:.85,exposure:.95},
|
||||
studio:{background:0x242d34,ground:0x303a40,key:0xfff5e9,keyPower:2.6,fill:.24,env:.8,rim:1.3,exposure:.95},
|
||||
evening:{background:0x555d69,ground:0x707574,key:0xffc185,keyPower:3.0,fill:.22,env:.5,rim:.9,exposure:.95},
|
||||
};
|
||||
export function createAppearance(renderer,scene){
|
||||
renderer.outputColorSpace=THREE.SRGBColorSpace;
|
||||
renderer.toneMapping=THREE.ACESFilmicToneMapping;
|
||||
renderer.shadowMap.enabled=true;renderer.shadowMap.type=THREE.PCFShadowMap;renderer.shadowMap.autoUpdate=false;
|
||||
const pmrem=new THREE.PMREMGenerator(renderer),room=new RoomEnvironment();
|
||||
const environment=pmrem.fromScene(room,.04);room.dispose();pmrem.dispose();scene.environment=environment.texture;
|
||||
const fill=new THREE.HemisphereLight(0xcde3ff,0x716658,.75),sun=new THREE.DirectionalLight(0xffefdc,3.2),rim=new THREE.DirectionalLight(0xc1dbff,1.);
|
||||
sun.castShadow=true;sun.shadow.mapSize.set(2048,2048);sun.shadow.radius=3;sun.shadow.bias=-.00008;sun.shadow.normalBias=.018;
|
||||
scene.add(fill,sun,sun.target,rim,rim.target);
|
||||
const floorMat=new THREE.MeshStandardMaterial({color:0xb9c1bd,roughness:.94,metalness:0});grain(floorMat,{frequency:45,amplitude:.0006,variation:.12,colorVariation:.035});
|
||||
const floor=new THREE.Mesh(new THREE.PlaneGeometry(1,1),floorMat);floor.rotation.x=-Math.PI/2;floor.receiveShadow=true;floor.userData.displayOnly=true;floor.name='Presentation ground (not CAD)';scene.add(floor);
|
||||
let current='day',shadowEnabled=true,lastBox,lastMode;
|
||||
const state={revision:27,preset:current,environment:'offline studio PMREM',shadows:true,proceduralSurfaces:true,groundIsPresentationOnly:true};
|
||||
function refresh(){if(lastMode==='hydraulic'){let y=Infinity;for(const o of scene.children)if(o.visible&&o.isGroup){const b=new THREE.Box3().setFromObject(o);if(!b.isEmpty())y=Math.min(y,b.min.y-.3);}if(Number.isFinite(y))floor.position.y=y;}renderer.shadowMap.needsUpdate=true;}
|
||||
function frame(box,mode){
|
||||
if(box.isEmpty())return;lastBox=box.clone();lastMode=mode;
|
||||
const c=box.getCenter(new THREE.Vector3()),size=box.getSize(new THREE.Vector3()),radius=Math.max(size.length()/2,2),extent=Math.max(size.x,size.z,4)*.7;
|
||||
const p=presets[current];
|
||||
sun.target.position.copy(c);sun.position.copy(c).add(new THREE.Vector3(-.8,current==='evening'?.7:1.8,.95).multiplyScalar(radius*2));
|
||||
rim.target.position.copy(c);rim.position.copy(c).add(new THREE.Vector3(1,.5,-1).multiplyScalar(radius*2));
|
||||
Object.assign(sun.shadow.camera,{left:-extent,right:extent,top:extent,bottom:-extent,near:.1,far:radius*9});sun.shadow.camera.updateProjectionMatrix();sun.shadow.normalBias=radius>100?.15:.055;
|
||||
floor.visible=mode!=='network';
|
||||
let groundY=box.min.y-.06;if(mode==='hydraulic'){for(const o of scene.children)if(o.visible&&o.isGroup){const b=new THREE.Box3().setFromObject(o);if(!b.isEmpty())groundY=Math.min(groundY,b.min.y-.3);}}floor.position.set(c.x,groundY,c.z);floor.scale.setScalar(Math.max(size.x,size.z,4)*12);
|
||||
scene.fog=['network','hydraulic'].includes(mode)?null:new THREE.Fog(p.background,radius*8,radius*25);
|
||||
sun.castShadow=shadowEnabled&&mode!=='network';state.activeShadows=sun.castShadow;state.presentationGroundVisible=floor.visible;refresh();
|
||||
}
|
||||
function preset(name){current=presets[name]?name:'day';const p=presets[current];scene.background=new THREE.Color(p.background);floorMat.color.set(p.ground);sun.color.set(p.key);sun.intensity=p.keyPower;fill.intensity=p.fill;rim.intensity=p.rim;scene.environmentIntensity=p.env;renderer.toneMappingExposure=p.exposure;state.preset=current;state.exposure=p.exposure;if(lastBox)frame(lastBox,lastMode);refresh();}
|
||||
preset('day');
|
||||
return {state,frame,preset,refresh,exposure(value){renderer.toneMappingExposure=value;state.exposure=value;},shadows(enabled){shadowEnabled=enabled;state.shadows=enabled;if(lastBox)frame(lastBox,lastMode);},dispose(){environment.dispose();floor.geometry.dispose();floorMat.dispose();sun.shadow.dispose();scene.remove(fill,sun,sun.target,rim,rim.target,floor);}};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export function createAssetInspector({model,networkRoot,scene,style,onLocate,onSelectionChange=()=>{}}){
|
||||
const links=new Map(model.links.map(link=>[link.id,link]));
|
||||
const nodes=new Map(model.nodes.map(node=>[node.id,node]));
|
||||
const meters=new Map(model.cadEquipmentBindings.map(meter=>[meter.assetId,meter]));
|
||||
const halo=new THREE.Group();halo.name='Selection presentation';scene.add(halo);
|
||||
const material=new THREE.MeshBasicMaterial({color:0xffbd45,transparent:true,opacity:.35,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2});
|
||||
let selected=null;
|
||||
|
||||
const status=input=>input==null?'暂无数据':({open:'开启',closed:'关闭',active:'调节中'}[String(input).toLowerCase()]??String(input));
|
||||
const value=(input,unit)=>typeof input==='number'?input.toFixed(3)+' '+unit:'暂无数据';
|
||||
const fields=rows=>rows.map(([label,input])=>({label,value:input==null?'暂无数据':String(input)}));
|
||||
|
||||
function resolve(input){
|
||||
let id=String(input);
|
||||
if(id.startsWith('zjb:physical_'))id='inp:link:'+id.slice(13);
|
||||
const cad=model.cadAttachments?.find(record=>record.assetId===id);
|
||||
if(cad)return {assetId:id,id:cad.hostNodeId,node:nodes.get(cad.hostNodeId),cad,kind:'CAD 立管'};
|
||||
const meter=meters.get(id);
|
||||
if(meter)return {assetId:id,id:meter.inpLinkId,meter,link:links.get(meter.inpLinkId),kind:'流量计'};
|
||||
if(id.startsWith('inp:link:'))id=id.slice(9);
|
||||
else if(id.startsWith('inp:node:')){
|
||||
id=id.slice(9);const node=nodes.get(id);
|
||||
if(node)return {assetId:'inp:node:'+id,id,node,kind:'节点'};
|
||||
}
|
||||
const link=links.get(id);
|
||||
if(link)return {assetId:'inp:link:'+id,id,link,kind:{PIPES:'管段',PUMPS:'水泵',VALVES:'阀门'}[link.kind]};
|
||||
const node=nodes.get(id);
|
||||
if(node)return {assetId:'inp:node:'+id,id,node,kind:'节点'};
|
||||
throw Error('未找到构件:'+input);
|
||||
}
|
||||
|
||||
function bounds(item=selected){
|
||||
const box=new THREE.Box3();if(!item)return box;networkRoot.updateMatrixWorld(true);
|
||||
if(item.cad||item.meter||item.link?.kind==='PUMPS'){
|
||||
networkRoot.traverse(object=>{if(!object.isMesh&&object.userData.assetId===item.assetId)box.union(new THREE.Box3().setFromObject(object));});
|
||||
}
|
||||
if(box.isEmpty()){
|
||||
const records=item.node?model.nodeInstances:model.pipeInstances;
|
||||
const mesh=item.node?style.nodes:style.pipes;
|
||||
for(let index=0;index<records.length;index++)if(records[index].inpId===item.id){
|
||||
const matrix=new THREE.Matrix4();mesh.getMatrixAt(index,matrix);
|
||||
if(!mesh.geometry.boundingBox)mesh.geometry.computeBoundingBox();
|
||||
box.union(mesh.geometry.boundingBox.clone().applyMatrix4(matrix).applyMatrix4(mesh.matrixWorld));
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function highlight(){
|
||||
for(const object of halo.children)if(object.userData.owned){object.geometry.dispose();object.material.dispose();}
|
||||
halo.clear();if(!selected||!networkRoot.visible)return;
|
||||
if(selected.cad||selected.meter||selected.link?.kind==='PUMPS'){
|
||||
const box=bounds();if(!box.isEmpty()){const helper=new THREE.Box3Helper(box,0xffbd45);helper.userData.owned=true;halo.add(helper);}
|
||||
}else{
|
||||
const records=selected.node?model.nodeInstances:model.pipeInstances;
|
||||
const source=selected.node?style.nodes:style.pipes;
|
||||
records.forEach((record,index)=>{
|
||||
if(record.inpId!==selected.id)return;
|
||||
const matrix=new THREE.Matrix4();source.getMatrixAt(index,matrix);
|
||||
const object=new THREE.Mesh(source.geometry,material);object.matrixAutoUpdate=false;
|
||||
object.matrix.copy(source.matrixWorld).multiply(matrix);object.renderOrder=3;halo.add(object);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function neighbors(item=selected){
|
||||
if(!item)return [];
|
||||
const nodeIds=item.node?[item.id]:[item.link.from,item.link.to];
|
||||
return model.links.filter(link=>link.id!==item.id&&[link.from,link.to].some(nodeId=>nodeIds.includes(nodeId)));
|
||||
}
|
||||
|
||||
function details(item=selected){
|
||||
if(!item)return null;
|
||||
const link=item.link;
|
||||
const result=style.getResult(item.id,item.node?'nodes':'links');
|
||||
const sections=[];
|
||||
sections.push({title:'基本信息',fields:fields([
|
||||
['编号',item.id],['类型',item.kind],
|
||||
...(link?[['名义管径',link.kind==='PUMPS'?'连接模型参数 '+link.diameterMm+' mm':link.diameterMm+' mm'],['初始状态',status(link.initialStatus)]]:[]),
|
||||
['展示比例',style.displayMode==='coordinated'?'泵房协调比例,范围外 '+style.style.scale+'×':'全局 '+style.style.scale+'×'],
|
||||
])});
|
||||
if(item.cad)sections.push({title:'CAD 竖向依据',fields:fields([
|
||||
['接入节点',item.cad.hostNodeId],['管径',item.cad.diameter_mm+' mm'],['图示高差',item.cad.vertical_height_m+' m'],
|
||||
['原图标注',item.cad.source_annotation],['CAD 句柄',item.cad.evidence.map(evidence=>evidence.handle).join('、')],
|
||||
['上端','上层配水走向未确认'],['几何基准','全局显示抬高 0.35 m;高差按图纸'],['水力关系','附着于现有节点,不新增 INP 链接'],
|
||||
])});
|
||||
if(link&&model.verticalCoordination&&model.coordination.scopeLinkIds.includes(link.id))sections.push({title:'泵房竖向依据',fields:fields([
|
||||
['管中心','CAD 换算为地面下 2.90 m,协调模式采用'],['接入走向','靠泵房端增加竖向过渡,折点位置估算'],['平面配准','INP 示意位置;与机械图接口尚未完成配准'],
|
||||
])});
|
||||
if(link)sections.push({title:'连接关系',fields:fields([
|
||||
['起点',link.from],['终点',link.to],...(item.meter?[['宿主管段',item.meter.inpLinkId]]:[]),
|
||||
])});
|
||||
|
||||
let pressure=result?.pressure,pressureBasis='直接结果';
|
||||
if(link&&pressure==null){
|
||||
const fromPressure=style.getResult(link.from,'nodes')?.pressure;
|
||||
const toPressure=style.getResult(link.to,'nodes')?.pressure;
|
||||
if(Number.isFinite(fromPressure)&&Number.isFinite(toPressure)){pressure=(fromPressure+toPressure)/2;pressureBasis='两端节点均值';}
|
||||
}
|
||||
if(item.cad)pressureBasis='接入节点压力,非立管上端压力';
|
||||
const pressureSource=result?.source==='scada'?'SCADA '+(result.deviceId??'监测'):pressure!=null?'在线模拟':'暂无数据';
|
||||
sections.push({title:'运行结果',fields:fields([
|
||||
['运行状态',status(result?.status)],['压力',value(pressure,'m')],
|
||||
...(pressure!=null?[['压力依据',pressureBasis],['压力来源',pressureSource]]:[]),
|
||||
...(result?.source==='scada'&&Number.isFinite(result.simulationPressure)?[['同期模拟压力',value(result.simulationPressure,'m')]]:[]),
|
||||
...(link?[['流速',value(result?.velocity==null?null:Math.abs(result.velocity),'m/s')],['流量',value(result?.flow,'m³/h')],['流向',result?.status?.toLowerCase()==='closed'?'停流':result?.direction!=null||result?.flow!=null?((result.direction??Math.sign(result.flow))>0?'起点 → 终点':(result.direction??Math.sign(result.flow))<0?'终点 → 起点':'停流'):'暂无数据']]:[]),
|
||||
])});
|
||||
sections.push({title:'来源说明',fields:fields([
|
||||
['拓扑来源','已核对 INP;本页不求解水力'],['位置与高度','展示调整,不代表实测埋深'],
|
||||
...(link?.kind==='PUMPS'?[['设备对应','CAD 槽位顺序;现场铭牌未确认']]:[]),
|
||||
...(item.meter?[['CAD 句柄',item.meter.cadHandles.join('、')]]:[]),
|
||||
])});
|
||||
return {
|
||||
assetId:item.assetId,elementId:item.id,kind:item.kind,
|
||||
title:item.kind+' · '+(item.cad?.label??item.meter?.label??item.id),
|
||||
sections,
|
||||
neighbors:neighbors(item).map(record=>({id:record.id,label:record.id})),
|
||||
};
|
||||
}
|
||||
|
||||
function refresh(){if(selected)onSelectionChange(details());highlight();}
|
||||
function select(id){selected=resolve(id);highlight();const selection=details();onSelectionChange(selection);return selection;}
|
||||
function clear(){
|
||||
for(const object of halo.children)if(object.userData.owned){object.geometry.dispose();object.material.dispose();}
|
||||
halo.clear();selected=null;onSelectionChange(null);
|
||||
}
|
||||
function locate(){if(selected)return onLocate(selected,bounds());}
|
||||
return {select,clear,resolve,bounds,refresh,highlight,details,locate,get selected(){return selected;},neighbors};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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))&&(!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);
|
||||
function bounds(p){let box=new THREE.Box3();
|
||||
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(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;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;
|
||||
}
|
||||
async function visit(id){const p=[...presets,...saved].find(p=>p.id===id);if(!p)return;const token=++request;flight=null;emit('正在准备 '+p.label+'…');
|
||||
try{await show(p.mode,{frame:false});if(token!==request)return;setDisplayMode(p.displayMode??(p.mode==='hydraulic'?'coordinated':'global'));root.visible=p.context??(p.mode!=='hydraulic'&&p.id!=='crossing');
|
||||
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,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();}
|
||||
emit(p.note??'已保存的相机位置');
|
||||
}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(),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};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Framework-independent data adapter. Does not parse or modify the project's INP.
|
||||
export const CAD_ORIGIN=Object.freeze([513800,2344450]);
|
||||
export function cadToGltf(point,displayZ=0){
|
||||
if(!Array.isArray(point)||point.length<2||!point.slice(0,2).every(Number.isFinite)||!Number.isFinite(displayZ))throw Error('Expected finite CAD XY and an explicit display height.');
|
||||
return [point[0]-CAD_ORIGIN[0],displayZ,-(point[1]-CAD_ORIGIN[1])];
|
||||
}
|
||||
// Supply longitude/latitude OF CAD_ORIGIN, not the old OSM anchor. This function
|
||||
// deliberately cannot guess CRS, accept the disabled legacy candidate, or add 23.9deg.
|
||||
export function mapModelMatrixElements(mercatorOrigin,meterScale,{rotationDeg=0,horizontalScale=1}={}){
|
||||
if(![mercatorOrigin?.x,mercatorOrigin?.y,mercatorOrigin?.z,meterScale,rotationDeg,horizontalScale].every(Number.isFinite)||meterScale<=0||horizontalScale<=0)throw Error('Confirmed Mercator origin and positive scales required.');
|
||||
const a=rotationDeg*Math.PI/180,c=Math.cos(a)*meterScale*horizontalScale,s=Math.sin(a)*meterScale*horizontalScale;
|
||||
// Column-major THREE.Matrix4; glTF +Y up, -Z CAD north; Mercator +Y south.
|
||||
return [c,-s,0,0, 0,0,meterScale,0, s,c,0,0, mercatorOrigin.x,mercatorOrigin.y,mercatorOrigin.z,1];
|
||||
}
|
||||
export function resolveMeterPlacements(bindingFile,{inpSha256,links,displayHeight=0.35}){
|
||||
if(inpSha256!==bindingFile.compatibleInpSha256)throw Error('INP version mismatch: reconcile the equipment mapping before placement.');
|
||||
const byId=new Map(links.map(link=>[String(link.id),link]));if(byId.size!==links.length)throw Error('Duplicate INP link IDs.');
|
||||
const placements=[],issues=[];
|
||||
for(const meter of bindingFile.meters){
|
||||
const link=byId.get(meter.inpLinkId);
|
||||
if(!link){issues.push({assetId:meter.assetId,reason:'missing_host_link',id:meter.inpLinkId});continue;}
|
||||
const pts=link.coordinates;
|
||||
if(!Array.isArray(pts)||pts.length<2||pts.some(p=>!Array.isArray(p)||p.length<2||!p.slice(0,2).every(Number.isFinite))||!Number.isFinite(link.diameterMm)||link.diameterMm<=0){issues.push({assetId:meter.assetId,reason:'invalid_host_geometry_or_diameter'});continue;}
|
||||
let best;
|
||||
for(let i=1;i<pts.length;i++){
|
||||
const a=pts[i-1],b=pts[i],dx=b[0]-a[0],dy=b[1]-a[1],length=Math.hypot(dx,dy);if(length<1e-8)continue;
|
||||
const t=Math.max(0,Math.min(1,((meter.cadXY[0]-a[0])*dx+(meter.cadXY[1]-a[1])*dy)/(length*length)));
|
||||
const distance=Math.hypot(a[0]+t*dx-meter.cadXY[0],a[1]+t*dy-meter.cadXY[1]);
|
||||
if(!best||distance<best.distance)best={a,b,dx,dy,length,t,distance};
|
||||
}
|
||||
if(!best){issues.push({assetId:meter.assetId,reason:'zero_length_host'});continue;}
|
||||
if(best.distance>.25){issues.push({assetId:meter.assetId,reason:'CAD_host_registration_exceeds_0.25m',distanceM:best.distance});continue;}
|
||||
const half=Math.min(.33,best.length*.2),t=Math.max(half/best.length,Math.min(1-half/best.length,best.t));
|
||||
const center=[best.a[0]+best.dx*t,best.a[1]+best.dy*t];
|
||||
placements.push({assetId:meter.assetId,prototype:meter.prototype,hostLinkId:meter.inpLinkId,position:cadToGltf(center,displayHeight),direction:[best.dx/best.length,0,-best.dy/best.length],scale:[half/.33,link.diameterMm/150,link.diameterMm/150],portDistanceM:2*half,createsHydraulicLink:false,scadaId:null});
|
||||
}
|
||||
return {placements,issues};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,85 @@
|
||||
|
||||
import * as THREE from 'three';
|
||||
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('结果文件与当前管网模型编号不一致。');
|
||||
if(input.units?.velocity!=='m/s'||input.units?.pressure!=='m'||input.units?.flow!=='m³/h')throw Error('结果单位须明确为 m/s、m、m³/h。');
|
||||
const linkIds=new Set(model.links.map(l=>l.id)),nodeIds=new Set(model.nodes.map(n=>n.id));
|
||||
for(const [table,ids,fields] of [[input.links??{},linkIds,['velocity','flow','pressure','direction']],[input.nodes??{},nodeIds,['pressure']]]){
|
||||
if(typeof table!=='object'||table===null||Array.isArray(table))throw Error('结果必须按编号提供对象。');
|
||||
for(const [id,r] of Object.entries(table)){
|
||||
if(!ids.has(id))throw Error('模型中不存在编号:'+id);
|
||||
if(typeof r!=='object'||r===null||Array.isArray(r))throw Error('无效结果:'+id);
|
||||
for(const k of fields)if(r[k]!==undefined&&r[k]!==null&&!finite(r[k]))throw Error('非数值结果:'+id+'.'+k);
|
||||
if(r.status!=null&&(typeof r.status!=='string'||!['open','closed','active'].includes(r.status.toLowerCase())))throw Error('无效运行状态:'+id);
|
||||
if(r.direction!=null&&![-1,0,1].includes(r.direction))throw Error('流向只能为 -1、0、1。');
|
||||
}
|
||||
}
|
||||
return structuredClone(input);
|
||||
}
|
||||
export function metricValue(mode,link,results){
|
||||
const r=results?.links?.[link.id];
|
||||
if(mode==='velocity')return finite(r?.velocity)?Math.abs(r.velocity):null;
|
||||
if(mode==='pressure'){
|
||||
if(finite(r?.pressure))return r.pressure;
|
||||
const a=results?.nodes?.[link.from]?.pressure,b=results?.nodes?.[link.to]?.pressure;return finite(a)&&finite(b)?(a+b)/2:null;
|
||||
}
|
||||
if(mode==='direction'){
|
||||
if(r?.status?.toLowerCase()==='closed')return 0;
|
||||
if(finite(r?.direction))return r.direction;
|
||||
return finite(r?.flow)?Math.sign(r.flow):null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function attachNetworkStyle(group,model){
|
||||
let pipes,nodes,bends;group.traverse(o=>{if(o.isInstancedMesh&&o.userData.instances?.length){if(o.name==='WaterBendInstances')bends=o;else if(o.userData.instances[0].kind==='NODE')nodes=o;else pipes=o;}});
|
||||
if(!pipes||!nodes)throw Error('管网实体缺少可编辑管段或节点。');
|
||||
pipes.userData.instances=model.pipeInstances;nodes.userData.instances=model.nodeInstances;
|
||||
const style={...DEFAULT_STYLE},byId=new Map(model.links.map(l=>[l.id,l])),partsById=new Map();let results=null,displayMode='global',focusScope=false;const scopeIds=new Set(model.coordination?.scopeLinkIds??[]),scopeNodes=new Set(model.links.filter(l=>scopeIds.has(l.id)).flatMap(l=>[l.from,l.to]));
|
||||
model.pipeInstances.forEach((p,i)=>{if(!partsById.has(p.inpId))partsById.set(p.inpId,[]);partsById.get(p.inpId).push(i);});
|
||||
const owned=[];for(const mesh of [pipes,nodes,bends].filter(Boolean)){const material=new THREE.MeshPhysicalMaterial({color:0xffffff,roughness:.3,metalness:.22,clearcoat:.22,clearcoatRoughness:.38});mesh.material=material;mesh.castShadow=true;mesh.receiveShadow=true;owned.push(material);}
|
||||
const geo=new THREE.ConeGeometry(1,2,12),mat=new THREE.MeshBasicMaterial({color:style.arrowColor}),arrows=new THREE.InstancedMesh(geo,mat,model.links.length);arrows.name='WaterFlowArrows';arrows.userData.instances=model.links.map(l=>({assetId:'inp:link:'+l.id,inpId:l.id,kind:l.kind}));group.add(arrows);
|
||||
const markerGeo=new THREE.OctahedronGeometry(.72,0),markerMat=new THREE.MeshBasicMaterial({color:0x1d83e6,transparent:true,opacity:.96,depthTest:false,depthWrite:false}),scadaMarkers=new THREE.InstancedMesh(markerGeo,markerMat,model.nodeInstances.length);scadaMarkers.name='ScadaSensorMarkers';scadaMarkers.userData.instances=model.nodeInstances.map(n=>({assetId:'inp:node:'+n.inpId,inpId:n.inpId,kind:'SCADA'}));scadaMarkers.renderOrder=6;group.add(scadaMarkers);
|
||||
const ringGeo=new THREE.TorusGeometry(1.15,.09,8,28);ringGeo.rotateX(Math.PI/2);const ringMat=new THREE.MeshBasicMaterial({color:0x1d83e6,transparent:true,opacity:.76,depthTest:false,depthWrite:false}),scadaRings=new THREE.InstancedMesh(ringGeo,ringMat,model.nodeInstances.length);scadaRings.name='ScadaSensorRings';scadaRings.userData.instances=scadaMarkers.userData.instances;scadaRings.renderOrder=5;group.add(scadaRings);
|
||||
const matrix=new THREE.Matrix4(),dummy=new THREE.Object3D(),pos=new THREE.Vector3(),quat=new THREE.Quaternion(),scale=new THREE.Vector3();let summary={};
|
||||
function apply(){
|
||||
const layout=(displayMode==='coordinated'?model.coordinatedLayouts:model.layouts)?.[style.scale];if(!layout)throw Error('当前模型支持整数倍率 1–12。');
|
||||
if(layout.pipes.length!==pipes.instanceMatrix.array.length||layout.nodes.length!==nodes.instanceMatrix.array.length)throw Error('模型倍率数据与几何不匹配。');
|
||||
if(bends){bends.instanceMatrix.array.set(layout.bends);bends.instanceMatrix.needsUpdate=true;}pipes.instanceMatrix.array.set(layout.pipes);nodes.instanceMatrix.array.set(layout.nodes);pipes.instanceMatrix.needsUpdate=true;nodes.instanceMatrix.needsUpdate=true;
|
||||
for(const d of layout.devices){group.traverse(o=>{if(o.userData.assetId===d.id&&!o.isMesh){o.position.fromArray(d.position);o.quaternion.fromArray(d.quaternion);o.scale.fromArray(d.scale);}});}
|
||||
const vals=model.links.map(l=>metricValue(style.mode,l,results)).filter(finite);let min=style.autoRange&&vals.length?Math.min(...vals):style.min,max=style.autoRange&&vals.length?Math.max(...vals):style.max;
|
||||
const low=new THREE.Color(style.lowColor),high=new THREE.Color(style.highColor),missing=new THREE.Color(style.missingColor),plain=new THREE.Color(style.color);
|
||||
function color(value){if(style.mode==='uniform')return plain;if(!finite(value))return missing;if(style.mode==='direction')return value===0?new THREE.Color('#a7aab0'):value>0?high:low;return low.clone().lerp(high,max===min?.5:Math.max(0,Math.min(1,(value-min)/(max-min))));}
|
||||
model.pipeInstances.forEach((p,i)=>pipes.setColorAt(i,color(metricValue(style.mode,byId.get(p.inpId),results))));
|
||||
model.nodeInstances.forEach((p,i)=>nodes.setColorAt(i,color(style.mode==='pressure'?results?.nodes?.[p.inpId]?.pressure:null)));
|
||||
if(bends){model.bendInstances.forEach((p,i)=>bends.setColorAt(i,color(metricValue(style.mode,byId.get(p.inpId),results))));bends.instanceColor.needsUpdate=true;}pipes.instanceColor.needsUpdate=true;nodes.instanceColor.needsUpdate=true;
|
||||
for(const mesh of [pipes,nodes,bends].filter(Boolean)){mesh.material.opacity=style.opacity;mesh.material.transparent=style.opacity<1;mesh.material.depthWrite=style.opacity===1;mesh.material.roughness=style.roughness;mesh.material.metalness=style.metalness;mesh.material.needsUpdate=true;mesh.computeBoundingBox();mesh.computeBoundingSphere();}
|
||||
group.traverse(o=>{if(o.userData.layoutMode){o.visible=o.userData.layoutMode===displayMode&&o.userData.layoutScale===style.scale;if(o.isMesh){o.material.color.copy(color(metricValue(style.mode,byId.get(o.userData.inpId),results)));o.material.opacity=style.opacity;o.material.transparent=style.opacity<1;o.material.roughness=style.roughness;}}});
|
||||
group.traverse(o=>{if(o.userData.cadAttachment){const r=model.cadAttachments.find(r=>r.assetId===o.userData.assetId);o.traverse(mesh=>{if(!mesh.isMesh)return;if(mesh.userData.cadRadialScale)mesh.scale.set(style.scale,1,style.scale);if(mesh.userData.cadTerminalScale)mesh.scale.setScalar(style.scale);mesh.material.color.copy(color(style.mode==='pressure'?results?.nodes?.[r.hostNodeId]?.pressure:null));mesh.material.opacity=style.opacity;mesh.material.transparent=style.opacity<1;mesh.material.depthWrite=style.opacity===1;mesh.material.roughness=style.roughness;mesh.material.metalness=style.metalness;});}});
|
||||
if(focusScope){const zero=new THREE.Matrix4().makeScale(0,0,0);for(const [mesh,recs,isNode] of [[pipes,model.pipeInstances,false],[nodes,model.nodeInstances,true],[bends,model.bendInstances,false]]){if(!mesh)continue;recs.forEach((r,i)=>{if(!(isNode?scopeNodes:scopeIds).has(r.inpId))mesh.setMatrixAt(i,zero);else if(isNode&&displayMode==='coordinated'){mesh.getMatrixAt(i,matrix);matrix.decompose(pos,quat,scale);const radius=Math.max(.075,...model.links.filter(l=>scopeIds.has(l.id)&&[l.from,l.to].includes(r.inpId)).map(l=>l.diameterMm/2000));matrix.compose(pos,quat,new THREE.Vector3(radius,radius,radius));mesh.setMatrixAt(i,matrix);}});mesh.instanceMatrix.needsUpdate=true;mesh.computeBoundingBox();mesh.computeBoundingSphere();}}
|
||||
group.traverse(o=>{if(o.userData.cadAttachment)o.visible=!focusScope;if(o.userData.layoutMode&&focusScope)o.visible=false;});
|
||||
nodes.visible=style.nodes;let scadaCount=0;
|
||||
model.nodeInstances.forEach((record,index)=>{
|
||||
const visible=results?.nodes?.[record.inpId]?.source==='scada'&&(!focusScope||scopeNodes.has(record.inpId));
|
||||
if(!visible){dummy.position.set(0,0,0);dummy.scale.setScalar(0);dummy.quaternion.identity();dummy.updateMatrix();scadaMarkers.setMatrixAt(index,dummy.matrix);scadaRings.setMatrixAt(index,dummy.matrix);return;}
|
||||
nodes.getMatrixAt(index,matrix);matrix.decompose(pos,quat,scale);
|
||||
dummy.position.copy(pos);dummy.position.y+=1.18;dummy.quaternion.identity();dummy.scale.setScalar(.72);dummy.updateMatrix();scadaMarkers.setMatrixAt(index,dummy.matrix);
|
||||
dummy.position.copy(pos);dummy.position.y+=.2;dummy.quaternion.identity();dummy.scale.setScalar(.72);dummy.updateMatrix();scadaRings.setMatrixAt(index,dummy.matrix);scadaCount++;
|
||||
});
|
||||
for(const marker of [scadaMarkers,scadaRings]){marker.instanceMatrix.needsUpdate=true;marker.visible=scadaCount>0;marker.computeBoundingBox();marker.computeBoundingSphere();}
|
||||
let arrowCount=0;
|
||||
model.links.forEach((l,i)=>{
|
||||
const ids=partsById.get(l.id)??[],idx=ids[Math.floor(ids.length/2)];let direction=style.direction==='topology'?1:style.direction==='results'?metricValue('direction',l,results):0;
|
||||
if(!direction||idx===undefined||(focusScope&&!scopeIds.has(l.id))){dummy.position.set(0,0,0);dummy.scale.setScalar(0);dummy.quaternion.identity();}
|
||||
else{pipes.getMatrixAt(idx,matrix);matrix.decompose(pos,quat,scale);dummy.position.copy(pos);dummy.quaternion.copy(quat);if(direction<0)dummy.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0),Math.PI));const r=Math.max(.22,scale.x*1.7);dummy.scale.set(r,r*1.5,r);arrowCount++;}
|
||||
dummy.updateMatrix();arrows.setMatrixAt(i,dummy.matrix);
|
||||
});arrows.instanceMatrix.needsUpdate=true;arrows.visible=style.direction!=='none';arrows.material.color.set(style.arrowColor);arrows.computeBoundingBox();arrows.computeBoundingSphere();
|
||||
summary={focusScope,displayMode,mode:style.mode,min,max,dataLinks:vals.length,totalLinks:model.links.length,scadaMarkers:scadaCount,arrows:arrowCount,directionMode:style.direction,hasResults:!!results,resultTime:results?.timestamp??null,scale:style.scale};return summary;
|
||||
}
|
||||
const api={setFocusScope(value){focusScope=!!value;return apply();},get displayMode(){return displayMode;},setDisplayMode(value){if(!['global','coordinated'].includes(value)||value==='coordinated'&&!model.coordinatedLayouts)throw Error('不支持的展示模式');displayMode=value;return apply();},getResult(id,kind='links'){return results?.[kind]?.[id]?structuredClone(results[kind][id]):null;},get style(){return {...style};},get summary(){return {...summary};},setStyle(patch){const next={...style,...patch};if(!Number.isInteger(next.scale)||next.scale<1||next.scale>12)throw Error('倍率需为 1–12。');if(!['uniform','velocity','pressure','direction'].includes(next.mode)||!['none','topology','results'].includes(next.direction))throw Error('未知样式。');for(const k of ['color','missingColor','lowColor','highColor','arrowColor'])if(!/^#[0-9a-f]{6}$/i.test(next[k]))throw Error('颜色需为六位十六进制。');for(const k of ['opacity','roughness','metalness'])if(!finite(next[k])||next[k]<0||next[k]>1)throw Error('样式数值须在 0–1。');if(!finite(next.min)||!finite(next.max)||next.min>=next.max)throw Error('色带上限必须大于下限。');Object.assign(style,next);return apply();},setResults(input){const parsed=validateResults(input,model);results=parsed;return apply();},clearResults(){results=null;return apply();},dispose(){geo.dispose();mat.dispose();markerGeo.dispose();markerMat.dispose();ringGeo.dispose();ringMat.dispose();owned.forEach(m=>m.dispose());},pipes,nodes,arrows,scadaMarkers,scadaRings};
|
||||
apply();return api;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>湛江北站供水系统三维渲染器</title>
|
||||
<style>
|
||||
html,body{width:100%;height:100%;margin:0;overflow:hidden;background:#e2e8e9}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
|
||||
canvas{display:block;width:100%;height:100%;touch-action:none}
|
||||
#boot{position:absolute;inset:0;z-index:2;display:grid;place-items:center;color:#526777;background:#e2e8e9;transition:opacity 180ms cubic-bezier(.16,1,.3,1)}
|
||||
#boot[hidden]{display:none}
|
||||
#boot span{display:flex;align-items:center;gap:10px;font-size:13px}
|
||||
#boot i{width:18px;height:18px;border:2px solid rgba(37,125,212,.22);border-top-color:#257dd4;border-radius:50%;animation:spin .8s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
@media(prefers-reduced-motion:reduce){#boot i{animation:none;border-top-color:rgba(37,125,212,.22)}}
|
||||
</style>
|
||||
<script type="importmap">{"imports":{"three":"./vendor/three/three.module.js","three/addons/":"./vendor/three/addons/"}}</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="boot" role="status"><span><i aria-hidden="true"></i>正在载入三维模型</span></div>
|
||||
<output id="qa" hidden></output>
|
||||
<script type="module" src="./preview.mjs?v=34-detailed-network-context"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,208 @@
|
||||
import {createRenderEffects} from './render-effects.mjs';
|
||||
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=33-scada-markers';
|
||||
import {cadToGltf} from './integration.mjs';
|
||||
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,contextOpacity:.4,shadows:true,effects:true,quality:'standard'};
|
||||
|
||||
function postHost(type,detail={}){
|
||||
if(window.parent===window)return;
|
||||
window.parent.postMessage({channel:HOST_CHANNEL,version:HOST_VERSION,type,projectCode:PROJECT_CODE,modelId:net?.modelId??MODEL_ID,...detail},window.location.origin);
|
||||
}
|
||||
function trustedHostMessage(event){
|
||||
const data=event.data;
|
||||
return event.origin===window.location.origin&&event.source===window.parent&&data&&typeof data==='object'&&data.channel===HOST_CHANNEL&&data.version===HOST_VERSION&&data.projectCode===PROJECT_CODE&&data.modelId===(net?.modelId??MODEL_ID);
|
||||
}
|
||||
function runtimeState(){
|
||||
return {
|
||||
mode,status:sceneStatus,contextVisible,roofVisible,
|
||||
displayMode:networkStyle?.displayMode??'global',
|
||||
style:networkStyle?.style??{...DEFAULT_STYLE},
|
||||
styleSummary:networkStyle?.summary??{},
|
||||
appearance:{...appearanceState},
|
||||
camera:{...cameraState,views:cameraState.views.map(view=>({...view}))},
|
||||
};
|
||||
}
|
||||
function postState(){if(net&&networkStyle)postHost('scene-state',{state:runtimeState()});}
|
||||
function setStatus(value){sceneStatus=value;postState();}
|
||||
|
||||
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});});
|
||||
|
||||
const manifest=await (await fetch('./manifest.json',{cache:'no-store'})).json();
|
||||
const viewport=()=>({width:Math.max(240,innerWidth),height:Math.max(240,innerHeight)});
|
||||
const initialViewport=viewport();
|
||||
const renderer=new THREE.WebGLRenderer({antialias:true,logarithmicDepthBuffer:false});
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio,2));renderer.setSize(initialViewport.width,initialViewport.height);renderer.setClearColor(0xeaf1f8);document.body.append(renderer.domElement);renderer.info.autoReset=false;
|
||||
const scene=new THREE.Scene();
|
||||
const camera=new THREE.PerspectiveCamera(42,initialViewport.width/initialViewport.height,.05,10000);
|
||||
const controls=new OrbitControls(camera,renderer.domElement);
|
||||
const appearance=createAppearance(renderer,scene);
|
||||
const effects=createRenderEffects(renderer,scene,camera,initialViewport.width,initialViewport.height);
|
||||
const loader=new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
|
||||
const cache=new Map(),root=new THREE.Group();scene.add(root);
|
||||
|
||||
function isBuildingResource(resourceId=''){
|
||||
return /^(map_|detail_(roof|facade|interior|ceiling|pump_walls|pump_roof|pump_access))/.test(resourceId);
|
||||
}
|
||||
function applyVisibility(){
|
||||
root.visible=mode!=='hydraulic';
|
||||
for(const object of root.children){
|
||||
if(isBuildingResource(object.userData.resourceId))object.visible=contextVisible;
|
||||
if(/roof|ceiling/.test(object.userData.resourceId))object.visible=contextVisible&&roofVisible;
|
||||
}
|
||||
if(mode==='pump')for(const object of root.children)if(['detail_pump_walls','detail_pump_access'].includes(object.userData.resourceId))object.visible=false;
|
||||
}
|
||||
function fit(){
|
||||
let box=new THREE.Box3();
|
||||
if(['network','hydraulic'].includes(mode)&&networkRoot)box.setFromObject(networkRoot);
|
||||
else for(const object of root.children)if(object.visible)box.union(new THREE.Box3().setFromObject(object));
|
||||
if(mode==='detail')box=new THREE.Box3().setFromObject(root.children.find(object=>object.userData.resourceId==='detail_facade')??root);
|
||||
if(mode==='hydraulic'&&net){
|
||||
box=new THREE.Box3();
|
||||
for(const link of net.links.filter(link=>link.kind==='PUMPS'))for(const point of link.coordinates)box.expandByPoint(new THREE.Vector3(...cadToGltf(point,.35)));
|
||||
box.expandByScalar(4);
|
||||
}
|
||||
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();
|
||||
}
|
||||
async function load(id){
|
||||
if(!cache.has(id)){
|
||||
const asset=manifest.assets.find(item=>item.id===id);
|
||||
if(!asset)throw Error('场景清单中不存在资源:'+id);
|
||||
cache.set(id,loader.loadAsync(asset.file).then(gltf=>{
|
||||
enhanceMaterials(gltf.scene);gltf.scene.userData.assetId=asset.logicalId??asset.assetId;gltf.scene.userData.resourceId=id;
|
||||
gltf.scene.traverse(object=>{if(object.isMesh)for(const material of Array.isArray(object.material)?object.material:[object.material])material.userData.original={opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite};});
|
||||
return gltf.scene;
|
||||
}));
|
||||
}
|
||||
return cache.get(id);
|
||||
}
|
||||
async function show(next,{frame=true}={}){
|
||||
if(!['network','hydraulic','map','detail','pump','meters'].includes(next))throw Error('不支持的场景模式:'+next);
|
||||
const token=++generation;mode=next;let ids=next==='network'?manifest.networkContext:manifest.mapDefault;
|
||||
if(next==='detail')ids=[...manifest.mapDefault.filter(id=>!['map_roof','map_facade'].includes(id)),'detail_roof','detail_facade','detail_interior','detail_ceiling'];
|
||||
if(next==='pump')ids=['detail_pump_walls','detail_pump_access','detail_pump_auxiliary','detail_pump_piping_reference',...manifest.assets.filter(asset=>asset.id.startsWith('physical_')).map(asset=>asset.id)];
|
||||
if(next==='meters')ids=['meter_dn150','valve_dn250','pump_symbol'];
|
||||
setStatus('正在载入 '+ids.length+' 组场景资源');
|
||||
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),appearanceState.contextOpacity);});
|
||||
});
|
||||
if(networkRoot)networkRoot.visible=['network','hydraulic'].includes(next);
|
||||
networkStyle?.setFocusScope(next==='hydraulic');applyVisibility();inspector?.highlight();if(frame)fit();
|
||||
window.webQA.loaded=ids;window.webQA.mode=next;
|
||||
setStatus(next==='pump'?'六泵实体与 CAD 管路参考,接口配准待核':next==='meters'?'设备样件,主管不包含在样件内':['network','hydraulic'].includes(next)?'管网实体模型,建筑为透明背景':'已载入 '+ids.length+' 组场景资源');
|
||||
}
|
||||
function updateStyle(){appearance.refresh();inspector?.refresh();window.webQA.networkStyle={...networkStyle.summary,style:networkStyle.style};postState();}
|
||||
function setDisplayMode(value){networkStyle.setDisplayMode(value);navigation?.stop();updateStyle();}
|
||||
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;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);
|
||||
|
||||
const ray=new THREE.Raycaster();let pointerStart=null,pointerMoved=false;
|
||||
renderer.domElement.addEventListener('pointerdown',event=>{pointerStart=[event.clientX,event.clientY];pointerMoved=false;});
|
||||
renderer.domElement.addEventListener('pointermove',event=>{if(pointerStart&&Math.hypot(event.clientX-pointerStart[0],event.clientY-pointerStart[1])>6)pointerMoved=true;});
|
||||
renderer.domElement.addEventListener('click',event=>{
|
||||
if(pointerMoved||!inspector)return;
|
||||
const current=viewport();ray.setFromCamera(new THREE.Vector2(event.clientX/current.width*2-1,1-event.clientY/current.height*2),camera);
|
||||
const visible=object=>{while(object){if(!object.visible)return false;object=object.parent;}return true;};
|
||||
const hits=ray.intersectObjects([...(networkRoot?.visible?[networkRoot]:[]),...(root.visible?[root]:[])],true).filter(hit=>visible(hit.object));
|
||||
for(const hit of hits){
|
||||
let data=hit.object.userData.instances?.[hit.instanceId];
|
||||
if(!data){let object=hit.object;while(object.parent&&!object.userData.inpId&&!object.userData.hostLinkId&&!object.userData.assetId)object=object.parent;data=object.userData;}
|
||||
if(data.assetId){try{inspector.select(data.assetId);window.webQA.picked=data.assetId;break;}catch{}}
|
||||
}
|
||||
});
|
||||
|
||||
let locateRequest=0;
|
||||
async function locateAsset(item){
|
||||
const request=++locateRequest;navigation?.stop();const targetMode=net.coordination.scopeLinkIds.includes(item.id)?'hydraulic':'network';const expected=mode!==targetMode?generation+1:generation;
|
||||
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});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=[];
|
||||
renderer.setAnimationLoop(()=>{
|
||||
const now=performance.now(),delta=now-lastFrame;lastFrame=now;if(delta<250){frameTimes.push(delta);if(frameTimes.length>120)frameTimes.shift();}
|
||||
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;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-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();
|
||||
}});
|
||||
inspector=createAssetInspector({model:net,networkRoot,scene,style:networkStyle,onLocate:locateAsset,onSelectionChange(selection){postHost('selection-changed',{selection});}});
|
||||
|
||||
window.addEventListener('message',async event=>{
|
||||
if(!trustedHostMessage(event))return;
|
||||
try{
|
||||
const data=event.data;
|
||||
if(data.type==='results'){
|
||||
networkStyle.setResults(data.payload);if(networkStyle.style.mode==='uniform')networkStyle.setStyle({mode:'pressure'});updateStyle();postHost('results-applied',{summary:networkStyle.summary});return;
|
||||
}
|
||||
if(data.type==='clear-results'){
|
||||
networkStyle.clearResults();updateStyle();postHost('results-cleared');return;
|
||||
}
|
||||
if(data.type!=='command'||!data.command||typeof data.command!=='object')return;
|
||||
const command=data.command;
|
||||
if(command.name==='set-mode'){navigation.stop();await show(command.mode);}
|
||||
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-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();
|
||||
else if(command.name==='toggle-roof')toggleRoof();
|
||||
else if(command.name==='fit-view'){navigation.stop();fit();postState();}
|
||||
else if(command.name==='select-asset')inspector.select(command.assetId);
|
||||
else if(command.name==='locate-selection')await inspector.locate();
|
||||
else if(command.name==='clear-selection')inspector.clear();
|
||||
else throw Error('不支持的场景命令');
|
||||
}catch(error){const message=error instanceof Error?error.message:String(error);postHost('error',{message});}
|
||||
});
|
||||
|
||||
await navigation.visit('overview');
|
||||
document.getElementById('boot').hidden=true;
|
||||
postHost('ready',{nodeIds:net.nodes.map(node=>String(node.id)),linkIds:net.links.map(link=>String(link.id)),state:runtimeState()});
|
||||
@@ -0,0 +1,23 @@
|
||||
import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js';
|
||||
import {RenderPass} from 'three/addons/postprocessing/RenderPass.js';
|
||||
import {SSAOPass} from 'three/addons/postprocessing/SSAOPass.js';
|
||||
import {OutputPass} from 'three/addons/postprocessing/OutputPass.js';
|
||||
import {ShaderPass} from 'three/addons/postprocessing/ShaderPass.js';
|
||||
import {FXAAShader} from 'three/addons/shaders/FXAAShader.js';
|
||||
export function createRenderEffects(renderer,scene,camera,width,height){
|
||||
const composer=new EffectComposer(renderer),base=new RenderPass(scene,camera),ao=new SSAOPass(scene,camera,width,height,16),output=new OutputPass(),aa=new ShaderPass(FXAAShader);
|
||||
composer.addPass(base);composer.addPass(ao);composer.addPass(output);composer.addPass(aa);
|
||||
ao.kernelRadius=8;ao.minDistance=.001;ao.maxDistance=.035;
|
||||
const state={revision:27,quality:'standard',enabled:true,aoActive:false,antialias:'FXAA'};
|
||||
let widthNow=width,heightNow=height;function resize(w,h){widthNow=w;heightNow=h;const d=renderer.getPixelRatio();composer.setPixelRatio(d);composer.setSize(w,h);ao.setSize(Math.max(1,Math.round(w*d*(state.quality==='standard'?.5:1))),Math.max(1,Math.round(h*d*(state.quality==='standard'?.5:1))));aa.material.uniforms.resolution.value.set(1/(w*d),1/(h*d));}
|
||||
resize(width,height);
|
||||
return {state,resize,setQuality(value){if(!['standard','high'].includes(value))throw Error('未知画质');state.quality=value;renderer.setPixelRatio(Math.min(devicePixelRatio,value==='standard'?1:2));renderer.setSize(widthNow,heightNow);resize(widthNow,heightNow);},setEnabled(value){state.enabled=!!value;},render(mode){
|
||||
// Transparent architectural context and broad plans retain the clean network renderer.
|
||||
ao.enabled=state.enabled&&['hydraulic','pump','meters'].includes(mode);
|
||||
state.aoActive=ao.enabled;base.enabled=true;ao.ssaoMaterial.uniforms.cameraProjectionMatrix.value.copy(camera.projectionMatrix);ao.ssaoMaterial.uniforms.cameraInverseProjectionMatrix.value.copy(camera.projectionMatrixInverse);ao.ssaoMaterial.uniforms.cameraNear.value=camera.near;ao.ssaoMaterial.uniforms.cameraFar.value=camera.far;
|
||||
if(state.enabled)composer.render();else renderer.render(scene,camera);
|
||||
},dispose(){for(const p of [ao,output,aa])p.dispose();composer.dispose();}};
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016-2025 Arseny Kapoulkine
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright © 2010-2025 three.js authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
+1860
File diff suppressed because it is too large
Load Diff
+182
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
BackSide,
|
||||
BoxGeometry,
|
||||
InstancedMesh,
|
||||
Mesh,
|
||||
MeshLambertMaterial,
|
||||
MeshStandardMaterial,
|
||||
PointLight,
|
||||
Scene,
|
||||
Object3D,
|
||||
} from 'three';
|
||||
|
||||
/**
|
||||
* This class represents a scene with a basic room setup that can be used as
|
||||
* input for {@link PMREMGenerator#fromScene}. The resulting PMREM represents the room's
|
||||
* lighting and can be used for Image Based Lighting by assigning it to {@link Scene#environment}
|
||||
* or directly as an environment map to PBR materials.
|
||||
*
|
||||
* The implementation is based on the [EnvironmentScene](https://github.com/google/model-viewer/blob/master/packages/model-viewer/src/three-components/EnvironmentScene.ts)
|
||||
* component from the `model-viewer` project.
|
||||
*
|
||||
* ```js
|
||||
* const environment = new RoomEnvironment();
|
||||
* const pmremGenerator = new THREE.PMREMGenerator( renderer );
|
||||
*
|
||||
* const envMap = pmremGenerator.fromScene( environment ).texture;
|
||||
* scene.environment = envMap;
|
||||
* ```
|
||||
*
|
||||
* @augments Scene
|
||||
* @three_import import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||||
*/
|
||||
class RoomEnvironment extends Scene {
|
||||
|
||||
constructor() {
|
||||
|
||||
super();
|
||||
|
||||
const geometry = new BoxGeometry();
|
||||
geometry.deleteAttribute( 'uv' );
|
||||
|
||||
const roomMaterial = new MeshStandardMaterial( { side: BackSide } );
|
||||
const boxMaterial = new MeshStandardMaterial();
|
||||
|
||||
const mainLight = new PointLight( 0xffffff, 900, 28, 2 );
|
||||
mainLight.position.set( 0.418, 16.199, 0.300 );
|
||||
this.add( mainLight );
|
||||
|
||||
const room = new Mesh( geometry, roomMaterial );
|
||||
room.position.set( - 0.757, 13.219, 0.717 );
|
||||
room.scale.set( 31.713, 28.305, 28.591 );
|
||||
this.add( room );
|
||||
|
||||
const boxes = new InstancedMesh( geometry, boxMaterial, 6 );
|
||||
const transform = new Object3D();
|
||||
|
||||
// box1
|
||||
transform.position.set( - 10.906, 2.009, 1.846 );
|
||||
transform.rotation.set( 0, - 0.195, 0 );
|
||||
transform.scale.set( 2.328, 7.905, 4.651 );
|
||||
transform.updateMatrix();
|
||||
boxes.setMatrixAt( 0, transform.matrix );
|
||||
|
||||
// box2
|
||||
transform.position.set( - 5.607, - 0.754, - 0.758 );
|
||||
transform.rotation.set( 0, 0.994, 0 );
|
||||
transform.scale.set( 1.970, 1.534, 3.955 );
|
||||
transform.updateMatrix();
|
||||
boxes.setMatrixAt( 1, transform.matrix );
|
||||
|
||||
// box3
|
||||
transform.position.set( 6.167, 0.857, 7.803 );
|
||||
transform.rotation.set( 0, 0.561, 0 );
|
||||
transform.scale.set( 3.927, 6.285, 3.687 );
|
||||
transform.updateMatrix();
|
||||
boxes.setMatrixAt( 2, transform.matrix );
|
||||
|
||||
// box4
|
||||
transform.position.set( - 2.017, 0.018, 6.124 );
|
||||
transform.rotation.set( 0, 0.333, 0 );
|
||||
transform.scale.set( 2.002, 4.566, 2.064 );
|
||||
transform.updateMatrix();
|
||||
boxes.setMatrixAt( 3, transform.matrix );
|
||||
|
||||
// box5
|
||||
transform.position.set( 2.291, - 0.756, - 2.621 );
|
||||
transform.rotation.set( 0, - 0.286, 0 );
|
||||
transform.scale.set( 1.546, 1.552, 1.496 );
|
||||
transform.updateMatrix();
|
||||
boxes.setMatrixAt( 4, transform.matrix );
|
||||
|
||||
// box6
|
||||
transform.position.set( - 2.193, - 0.369, - 5.547 );
|
||||
transform.rotation.set( 0, 0.516, 0 );
|
||||
transform.scale.set( 3.875, 3.487, 2.986 );
|
||||
transform.updateMatrix();
|
||||
boxes.setMatrixAt( 5, transform.matrix );
|
||||
|
||||
this.add( boxes );
|
||||
|
||||
|
||||
// -x right
|
||||
const light1 = new Mesh( geometry, createAreaLightMaterial( 50 ) );
|
||||
light1.position.set( - 16.116, 14.37, 8.208 );
|
||||
light1.scale.set( 0.1, 2.428, 2.739 );
|
||||
this.add( light1 );
|
||||
|
||||
// -x left
|
||||
const light2 = new Mesh( geometry, createAreaLightMaterial( 50 ) );
|
||||
light2.position.set( - 16.109, 18.021, - 8.207 );
|
||||
light2.scale.set( 0.1, 2.425, 2.751 );
|
||||
this.add( light2 );
|
||||
|
||||
// +x
|
||||
const light3 = new Mesh( geometry, createAreaLightMaterial( 17 ) );
|
||||
light3.position.set( 14.904, 12.198, - 1.832 );
|
||||
light3.scale.set( 0.15, 4.265, 6.331 );
|
||||
this.add( light3 );
|
||||
|
||||
// +z
|
||||
const light4 = new Mesh( geometry, createAreaLightMaterial( 43 ) );
|
||||
light4.position.set( - 0.462, 8.89, 14.520 );
|
||||
light4.scale.set( 4.38, 5.441, 0.088 );
|
||||
this.add( light4 );
|
||||
|
||||
// -z
|
||||
const light5 = new Mesh( geometry, createAreaLightMaterial( 20 ) );
|
||||
light5.position.set( 3.235, 11.486, - 12.541 );
|
||||
light5.scale.set( 2.5, 2.0, 0.1 );
|
||||
this.add( light5 );
|
||||
|
||||
// +y
|
||||
const light6 = new Mesh( geometry, createAreaLightMaterial( 100 ) );
|
||||
light6.position.set( 0.0, 20.0, 0.0 );
|
||||
light6.scale.set( 1.0, 0.1, 1.0 );
|
||||
this.add( light6 );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees internal resources. This method should be called
|
||||
* when the environment is no longer required.
|
||||
*/
|
||||
dispose() {
|
||||
|
||||
const resources = new Set();
|
||||
|
||||
this.traverse( ( object ) => {
|
||||
|
||||
if ( object.isMesh ) {
|
||||
|
||||
resources.add( object.geometry );
|
||||
resources.add( object.material );
|
||||
|
||||
}
|
||||
|
||||
} );
|
||||
|
||||
for ( const resource of resources ) {
|
||||
|
||||
resource.dispose();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function createAreaLightMaterial( intensity ) {
|
||||
|
||||
// create an emissive-only material. see #31348
|
||||
const material = new MeshLambertMaterial( {
|
||||
color: 0x000000,
|
||||
emissive: 0xffffff,
|
||||
emissiveIntensity: intensity
|
||||
} );
|
||||
|
||||
return material;
|
||||
|
||||
}
|
||||
|
||||
export { RoomEnvironment };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* A utility class providing noise functions.
|
||||
*
|
||||
* The code is based on [Simplex noise demystified]{@link https://web.archive.org/web/20210210162332/http://staffwww.itn.liu.se/~stegu/simplexnoise/simplexnoise.pdf}
|
||||
* by Stefan Gustavson, 2005.
|
||||
*
|
||||
* @three_import import { SimplexNoise } from 'three/addons/math/SimplexNoise.js';
|
||||
*/
|
||||
class SimplexNoise {
|
||||
|
||||
/**
|
||||
* Constructs a new simplex noise object.
|
||||
*
|
||||
* @param {Object} [r=Math] - A math utility class that holds a `random()` method. This makes it
|
||||
* possible to pass in custom random number generator.
|
||||
*/
|
||||
constructor( r = Math ) {
|
||||
|
||||
this.grad3 = [[ 1, 1, 0 ], [ - 1, 1, 0 ], [ 1, - 1, 0 ], [ - 1, - 1, 0 ],
|
||||
[ 1, 0, 1 ], [ - 1, 0, 1 ], [ 1, 0, - 1 ], [ - 1, 0, - 1 ],
|
||||
[ 0, 1, 1 ], [ 0, - 1, 1 ], [ 0, 1, - 1 ], [ 0, - 1, - 1 ]];
|
||||
|
||||
this.grad4 = [[ 0, 1, 1, 1 ], [ 0, 1, 1, - 1 ], [ 0, 1, - 1, 1 ], [ 0, 1, - 1, - 1 ],
|
||||
[ 0, - 1, 1, 1 ], [ 0, - 1, 1, - 1 ], [ 0, - 1, - 1, 1 ], [ 0, - 1, - 1, - 1 ],
|
||||
[ 1, 0, 1, 1 ], [ 1, 0, 1, - 1 ], [ 1, 0, - 1, 1 ], [ 1, 0, - 1, - 1 ],
|
||||
[ - 1, 0, 1, 1 ], [ - 1, 0, 1, - 1 ], [ - 1, 0, - 1, 1 ], [ - 1, 0, - 1, - 1 ],
|
||||
[ 1, 1, 0, 1 ], [ 1, 1, 0, - 1 ], [ 1, - 1, 0, 1 ], [ 1, - 1, 0, - 1 ],
|
||||
[ - 1, 1, 0, 1 ], [ - 1, 1, 0, - 1 ], [ - 1, - 1, 0, 1 ], [ - 1, - 1, 0, - 1 ],
|
||||
[ 1, 1, 1, 0 ], [ 1, 1, - 1, 0 ], [ 1, - 1, 1, 0 ], [ 1, - 1, - 1, 0 ],
|
||||
[ - 1, 1, 1, 0 ], [ - 1, 1, - 1, 0 ], [ - 1, - 1, 1, 0 ], [ - 1, - 1, - 1, 0 ]];
|
||||
|
||||
this.p = [];
|
||||
|
||||
for ( let i = 0; i < 256; i ++ ) {
|
||||
|
||||
this.p[ i ] = Math.floor( r.random() * 256 );
|
||||
|
||||
}
|
||||
|
||||
// To remove the need for index wrapping, double the permutation table length
|
||||
this.perm = [];
|
||||
|
||||
for ( let i = 0; i < 512; i ++ ) {
|
||||
|
||||
this.perm[ i ] = this.p[ i & 255 ];
|
||||
|
||||
}
|
||||
|
||||
// A lookup table to traverse the simplex around a given point in 4D.
|
||||
// Details can be found where this table is used, in the 4D noise method.
|
||||
this.simplex = [
|
||||
[ 0, 1, 2, 3 ], [ 0, 1, 3, 2 ], [ 0, 0, 0, 0 ], [ 0, 2, 3, 1 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 1, 2, 3, 0 ],
|
||||
[ 0, 2, 1, 3 ], [ 0, 0, 0, 0 ], [ 0, 3, 1, 2 ], [ 0, 3, 2, 1 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 1, 3, 2, 0 ],
|
||||
[ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ],
|
||||
[ 1, 2, 0, 3 ], [ 0, 0, 0, 0 ], [ 1, 3, 0, 2 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 2, 3, 0, 1 ], [ 2, 3, 1, 0 ],
|
||||
[ 1, 0, 2, 3 ], [ 1, 0, 3, 2 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 2, 0, 3, 1 ], [ 0, 0, 0, 0 ], [ 2, 1, 3, 0 ],
|
||||
[ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ],
|
||||
[ 2, 0, 1, 3 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 3, 0, 1, 2 ], [ 3, 0, 2, 1 ], [ 0, 0, 0, 0 ], [ 3, 1, 2, 0 ],
|
||||
[ 2, 1, 0, 3 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 0, 0, 0, 0 ], [ 3, 1, 0, 2 ], [ 0, 0, 0, 0 ], [ 3, 2, 0, 1 ], [ 3, 2, 1, 0 ]];
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A 2D simplex noise method.
|
||||
*
|
||||
* @param {number} xin - The x coordinate.
|
||||
* @param {number} yin - The y coordinate.
|
||||
* @return {number} The noise value.
|
||||
*/
|
||||
noise( xin, yin ) {
|
||||
|
||||
let n0; // Noise contributions from the three corners
|
||||
let n1;
|
||||
let n2;
|
||||
// Skew the input space to determine which simplex cell we're in
|
||||
const F2 = 0.5 * ( Math.sqrt( 3.0 ) - 1.0 );
|
||||
const s = ( xin + yin ) * F2; // Hairy factor for 2D
|
||||
const i = Math.floor( xin + s );
|
||||
const j = Math.floor( yin + s );
|
||||
const G2 = ( 3.0 - Math.sqrt( 3.0 ) ) / 6.0;
|
||||
const t = ( i + j ) * G2;
|
||||
const X0 = i - t; // Unskew the cell origin back to (x,y) space
|
||||
const Y0 = j - t;
|
||||
const x0 = xin - X0; // The x,y distances from the cell origin
|
||||
const y0 = yin - Y0;
|
||||
|
||||
// For the 2D case, the simplex shape is an equilateral triangle.
|
||||
// Determine which simplex we are in.
|
||||
let i1; // Offsets for second (middle) corner of simplex in (i,j) coords
|
||||
|
||||
let j1;
|
||||
if ( x0 > y0 ) {
|
||||
|
||||
i1 = 1; j1 = 0;
|
||||
|
||||
// lower triangle, XY order: (0,0)->(1,0)->(1,1)
|
||||
|
||||
} else {
|
||||
|
||||
i1 = 0; j1 = 1;
|
||||
|
||||
} // upper triangle, YX order: (0,0)->(0,1)->(1,1)
|
||||
|
||||
// A step of (1,0) in (i,j) means a step of (1-c,-c) in (x,y), and
|
||||
// a step of (0,1) in (i,j) means a step of (-c,1-c) in (x,y), where
|
||||
// c = (3-sqrt(3))/6
|
||||
const x1 = x0 - i1 + G2; // Offsets for middle corner in (x,y) unskewed coords
|
||||
const y1 = y0 - j1 + G2;
|
||||
const x2 = x0 - 1.0 + 2.0 * G2; // Offsets for last corner in (x,y) unskewed coords
|
||||
const y2 = y0 - 1.0 + 2.0 * G2;
|
||||
// Work out the hashed gradient indices of the three simplex corners
|
||||
const ii = i & 255;
|
||||
const jj = j & 255;
|
||||
const gi0 = this.perm[ ii + this.perm[ jj ] ] % 12;
|
||||
const gi1 = this.perm[ ii + i1 + this.perm[ jj + j1 ] ] % 12;
|
||||
const gi2 = this.perm[ ii + 1 + this.perm[ jj + 1 ] ] % 12;
|
||||
// Calculate the contribution from the three corners
|
||||
let t0 = 0.5 - x0 * x0 - y0 * y0;
|
||||
if ( t0 < 0 ) n0 = 0.0;
|
||||
else {
|
||||
|
||||
t0 *= t0;
|
||||
n0 = t0 * t0 * this._dot( this.grad3[ gi0 ], x0, y0 ); // (x,y) of grad3 used for 2D gradient
|
||||
|
||||
}
|
||||
|
||||
let t1 = 0.5 - x1 * x1 - y1 * y1;
|
||||
if ( t1 < 0 ) n1 = 0.0;
|
||||
else {
|
||||
|
||||
t1 *= t1;
|
||||
n1 = t1 * t1 * this._dot( this.grad3[ gi1 ], x1, y1 );
|
||||
|
||||
}
|
||||
|
||||
let t2 = 0.5 - x2 * x2 - y2 * y2;
|
||||
if ( t2 < 0 ) n2 = 0.0;
|
||||
else {
|
||||
|
||||
t2 *= t2;
|
||||
n2 = t2 * t2 * this._dot( this.grad3[ gi2 ], x2, y2 );
|
||||
|
||||
}
|
||||
|
||||
// Add contributions from each corner to get the final noise value.
|
||||
// The result is scaled to return values in the interval [-1,1].
|
||||
return 70.0 * ( n0 + n1 + n2 );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A 3D simplex noise method.
|
||||
*
|
||||
* @param {number} xin - The x coordinate.
|
||||
* @param {number} yin - The y coordinate.
|
||||
* @param {number} zin - The z coordinate.
|
||||
* @return {number} The noise value.
|
||||
*/
|
||||
noise3d( xin, yin, zin ) {
|
||||
|
||||
let n0; // Noise contributions from the four corners
|
||||
let n1;
|
||||
let n2;
|
||||
let n3;
|
||||
// Skew the input space to determine which simplex cell we're in
|
||||
const F3 = 1.0 / 3.0;
|
||||
const s = ( xin + yin + zin ) * F3; // Very nice and simple skew factor for 3D
|
||||
const i = Math.floor( xin + s );
|
||||
const j = Math.floor( yin + s );
|
||||
const k = Math.floor( zin + s );
|
||||
const G3 = 1.0 / 6.0; // Very nice and simple unskew factor, too
|
||||
const t = ( i + j + k ) * G3;
|
||||
const X0 = i - t; // Unskew the cell origin back to (x,y,z) space
|
||||
const Y0 = j - t;
|
||||
const Z0 = k - t;
|
||||
const x0 = xin - X0; // The x,y,z distances from the cell origin
|
||||
const y0 = yin - Y0;
|
||||
const z0 = zin - Z0;
|
||||
|
||||
// For the 3D case, the simplex shape is a slightly irregular tetrahedron.
|
||||
// Determine which simplex we are in.
|
||||
let i1; // Offsets for second corner of simplex in (i,j,k) coords
|
||||
|
||||
let j1;
|
||||
let k1;
|
||||
let i2; // Offsets for third corner of simplex in (i,j,k) coords
|
||||
let j2;
|
||||
let k2;
|
||||
if ( x0 >= y0 ) {
|
||||
|
||||
if ( y0 >= z0 ) {
|
||||
|
||||
i1 = 1; j1 = 0; k1 = 0; i2 = 1; j2 = 1; k2 = 0;
|
||||
|
||||
// X Y Z order
|
||||
|
||||
} else if ( x0 >= z0 ) {
|
||||
|
||||
i1 = 1; j1 = 0; k1 = 0; i2 = 1; j2 = 0; k2 = 1;
|
||||
|
||||
// X Z Y order
|
||||
|
||||
} else {
|
||||
|
||||
i1 = 0; j1 = 0; k1 = 1; i2 = 1; j2 = 0; k2 = 1;
|
||||
|
||||
} // Z X Y order
|
||||
|
||||
} else { // x0<y0
|
||||
|
||||
if ( y0 < z0 ) {
|
||||
|
||||
i1 = 0; j1 = 0; k1 = 1; i2 = 0; j2 = 1; k2 = 1;
|
||||
|
||||
// Z Y X order
|
||||
|
||||
} else if ( x0 < z0 ) {
|
||||
|
||||
i1 = 0; j1 = 1; k1 = 0; i2 = 0; j2 = 1; k2 = 1;
|
||||
|
||||
// Y Z X order
|
||||
|
||||
} else {
|
||||
|
||||
i1 = 0; j1 = 1; k1 = 0; i2 = 1; j2 = 1; k2 = 0;
|
||||
|
||||
} // Y X Z order
|
||||
|
||||
}
|
||||
|
||||
// A step of (1,0,0) in (i,j,k) means a step of (1-c,-c,-c) in (x,y,z),
|
||||
// a step of (0,1,0) in (i,j,k) means a step of (-c,1-c,-c) in (x,y,z), and
|
||||
// a step of (0,0,1) in (i,j,k) means a step of (-c,-c,1-c) in (x,y,z), where
|
||||
// c = 1/6.
|
||||
const x1 = x0 - i1 + G3; // Offsets for second corner in (x,y,z) coords
|
||||
const y1 = y0 - j1 + G3;
|
||||
const z1 = z0 - k1 + G3;
|
||||
const x2 = x0 - i2 + 2.0 * G3; // Offsets for third corner in (x,y,z) coords
|
||||
const y2 = y0 - j2 + 2.0 * G3;
|
||||
const z2 = z0 - k2 + 2.0 * G3;
|
||||
const x3 = x0 - 1.0 + 3.0 * G3; // Offsets for last corner in (x,y,z) coords
|
||||
const y3 = y0 - 1.0 + 3.0 * G3;
|
||||
const z3 = z0 - 1.0 + 3.0 * G3;
|
||||
// Work out the hashed gradient indices of the four simplex corners
|
||||
const ii = i & 255;
|
||||
const jj = j & 255;
|
||||
const kk = k & 255;
|
||||
const gi0 = this.perm[ ii + this.perm[ jj + this.perm[ kk ] ] ] % 12;
|
||||
const gi1 = this.perm[ ii + i1 + this.perm[ jj + j1 + this.perm[ kk + k1 ] ] ] % 12;
|
||||
const gi2 = this.perm[ ii + i2 + this.perm[ jj + j2 + this.perm[ kk + k2 ] ] ] % 12;
|
||||
const gi3 = this.perm[ ii + 1 + this.perm[ jj + 1 + this.perm[ kk + 1 ] ] ] % 12;
|
||||
// Calculate the contribution from the four corners
|
||||
let t0 = 0.6 - x0 * x0 - y0 * y0 - z0 * z0;
|
||||
if ( t0 < 0 ) n0 = 0.0;
|
||||
else {
|
||||
|
||||
t0 *= t0;
|
||||
n0 = t0 * t0 * this._dot3( this.grad3[ gi0 ], x0, y0, z0 );
|
||||
|
||||
}
|
||||
|
||||
let t1 = 0.6 - x1 * x1 - y1 * y1 - z1 * z1;
|
||||
if ( t1 < 0 ) n1 = 0.0;
|
||||
else {
|
||||
|
||||
t1 *= t1;
|
||||
n1 = t1 * t1 * this._dot3( this.grad3[ gi1 ], x1, y1, z1 );
|
||||
|
||||
}
|
||||
|
||||
let t2 = 0.6 - x2 * x2 - y2 * y2 - z2 * z2;
|
||||
if ( t2 < 0 ) n2 = 0.0;
|
||||
else {
|
||||
|
||||
t2 *= t2;
|
||||
n2 = t2 * t2 * this._dot3( this.grad3[ gi2 ], x2, y2, z2 );
|
||||
|
||||
}
|
||||
|
||||
let t3 = 0.6 - x3 * x3 - y3 * y3 - z3 * z3;
|
||||
if ( t3 < 0 ) n3 = 0.0;
|
||||
else {
|
||||
|
||||
t3 *= t3;
|
||||
n3 = t3 * t3 * this._dot3( this.grad3[ gi3 ], x3, y3, z3 );
|
||||
|
||||
}
|
||||
|
||||
// Add contributions from each corner to get the final noise value.
|
||||
// The result is scaled to stay just inside [-1,1]
|
||||
return 32.0 * ( n0 + n1 + n2 + n3 );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A 4D simplex noise method.
|
||||
*
|
||||
* @param {number} x - The x coordinate.
|
||||
* @param {number} y - The y coordinate.
|
||||
* @param {number} z - The z coordinate.
|
||||
* @param {number} w - The w coordinate.
|
||||
* @return {number} The noise value.
|
||||
*/
|
||||
noise4d( x, y, z, w ) {
|
||||
|
||||
// For faster and easier lookups
|
||||
const grad4 = this.grad4;
|
||||
const simplex = this.simplex;
|
||||
const perm = this.perm;
|
||||
|
||||
// The skewing and unskewing factors are hairy again for the 4D case
|
||||
const F4 = ( Math.sqrt( 5.0 ) - 1.0 ) / 4.0;
|
||||
const G4 = ( 5.0 - Math.sqrt( 5.0 ) ) / 20.0;
|
||||
let n0; // Noise contributions from the five corners
|
||||
let n1;
|
||||
let n2;
|
||||
let n3;
|
||||
let n4;
|
||||
// Skew the (x,y,z,w) space to determine which cell of 24 simplices we're in
|
||||
const s = ( x + y + z + w ) * F4; // Factor for 4D skewing
|
||||
const i = Math.floor( x + s );
|
||||
const j = Math.floor( y + s );
|
||||
const k = Math.floor( z + s );
|
||||
const l = Math.floor( w + s );
|
||||
const t = ( i + j + k + l ) * G4; // Factor for 4D unskewing
|
||||
const X0 = i - t; // Unskew the cell origin back to (x,y,z,w) space
|
||||
const Y0 = j - t;
|
||||
const Z0 = k - t;
|
||||
const W0 = l - t;
|
||||
const x0 = x - X0; // The x,y,z,w distances from the cell origin
|
||||
const y0 = y - Y0;
|
||||
const z0 = z - Z0;
|
||||
const w0 = w - W0;
|
||||
|
||||
// For the 4D case, the simplex is a 4D shape I won't even try to describe.
|
||||
// To find out which of the 24 possible simplices we're in, we need to
|
||||
// determine the magnitude ordering of x0, y0, z0 and w0.
|
||||
// The method below is a good way of finding the ordering of x,y,z,w and
|
||||
// then find the correct traversal order for the simplex we’re in.
|
||||
// First, six pair-wise comparisons are performed between each possible pair
|
||||
// of the four coordinates, and the results are used to add up binary bits
|
||||
// for an integer index.
|
||||
const c1 = ( x0 > y0 ) ? 32 : 0;
|
||||
const c2 = ( x0 > z0 ) ? 16 : 0;
|
||||
const c3 = ( y0 > z0 ) ? 8 : 0;
|
||||
const c4 = ( x0 > w0 ) ? 4 : 0;
|
||||
const c5 = ( y0 > w0 ) ? 2 : 0;
|
||||
const c6 = ( z0 > w0 ) ? 1 : 0;
|
||||
const c = c1 + c2 + c3 + c4 + c5 + c6;
|
||||
|
||||
// simplex[c] is a 4-vector with the numbers 0, 1, 2 and 3 in some order.
|
||||
// Many values of c will never occur, since e.g. x>y>z>w makes x<z, y<w and x<w
|
||||
// impossible. Only the 24 indices which have non-zero entries make any sense.
|
||||
// We use a thresholding to set the coordinates in turn from the largest magnitude.
|
||||
// The number 3 in the "simplex" array is at the position of the largest coordinate.
|
||||
const i1 = simplex[ c ][ 0 ] >= 3 ? 1 : 0;
|
||||
const j1 = simplex[ c ][ 1 ] >= 3 ? 1 : 0;
|
||||
const k1 = simplex[ c ][ 2 ] >= 3 ? 1 : 0;
|
||||
const l1 = simplex[ c ][ 3 ] >= 3 ? 1 : 0;
|
||||
// The number 2 in the "simplex" array is at the second largest coordinate.
|
||||
const i2 = simplex[ c ][ 0 ] >= 2 ? 1 : 0;
|
||||
const j2 = simplex[ c ][ 1 ] >= 2 ? 1 : 0;
|
||||
const k2 = simplex[ c ][ 2 ] >= 2 ? 1 : 0;
|
||||
const l2 = simplex[ c ][ 3 ] >= 2 ? 1 : 0;
|
||||
// The number 1 in the "simplex" array is at the second smallest coordinate.
|
||||
const i3 = simplex[ c ][ 0 ] >= 1 ? 1 : 0;
|
||||
const j3 = simplex[ c ][ 1 ] >= 1 ? 1 : 0;
|
||||
const k3 = simplex[ c ][ 2 ] >= 1 ? 1 : 0;
|
||||
const l3 = simplex[ c ][ 3 ] >= 1 ? 1 : 0;
|
||||
// The fifth corner has all coordinate offsets = 1, so no need to look that up.
|
||||
const x1 = x0 - i1 + G4; // Offsets for second corner in (x,y,z,w) coords
|
||||
const y1 = y0 - j1 + G4;
|
||||
const z1 = z0 - k1 + G4;
|
||||
const w1 = w0 - l1 + G4;
|
||||
const x2 = x0 - i2 + 2.0 * G4; // Offsets for third corner in (x,y,z,w) coords
|
||||
const y2 = y0 - j2 + 2.0 * G4;
|
||||
const z2 = z0 - k2 + 2.0 * G4;
|
||||
const w2 = w0 - l2 + 2.0 * G4;
|
||||
const x3 = x0 - i3 + 3.0 * G4; // Offsets for fourth corner in (x,y,z,w) coords
|
||||
const y3 = y0 - j3 + 3.0 * G4;
|
||||
const z3 = z0 - k3 + 3.0 * G4;
|
||||
const w3 = w0 - l3 + 3.0 * G4;
|
||||
const x4 = x0 - 1.0 + 4.0 * G4; // Offsets for last corner in (x,y,z,w) coords
|
||||
const y4 = y0 - 1.0 + 4.0 * G4;
|
||||
const z4 = z0 - 1.0 + 4.0 * G4;
|
||||
const w4 = w0 - 1.0 + 4.0 * G4;
|
||||
// Work out the hashed gradient indices of the five simplex corners
|
||||
const ii = i & 255;
|
||||
const jj = j & 255;
|
||||
const kk = k & 255;
|
||||
const ll = l & 255;
|
||||
const gi0 = perm[ ii + perm[ jj + perm[ kk + perm[ ll ] ] ] ] % 32;
|
||||
const gi1 = perm[ ii + i1 + perm[ jj + j1 + perm[ kk + k1 + perm[ ll + l1 ] ] ] ] % 32;
|
||||
const gi2 = perm[ ii + i2 + perm[ jj + j2 + perm[ kk + k2 + perm[ ll + l2 ] ] ] ] % 32;
|
||||
const gi3 = perm[ ii + i3 + perm[ jj + j3 + perm[ kk + k3 + perm[ ll + l3 ] ] ] ] % 32;
|
||||
const gi4 = perm[ ii + 1 + perm[ jj + 1 + perm[ kk + 1 + perm[ ll + 1 ] ] ] ] % 32;
|
||||
// Calculate the contribution from the five corners
|
||||
let t0 = 0.6 - x0 * x0 - y0 * y0 - z0 * z0 - w0 * w0;
|
||||
if ( t0 < 0 ) n0 = 0.0;
|
||||
else {
|
||||
|
||||
t0 *= t0;
|
||||
n0 = t0 * t0 * this._dot4( grad4[ gi0 ], x0, y0, z0, w0 );
|
||||
|
||||
}
|
||||
|
||||
let t1 = 0.6 - x1 * x1 - y1 * y1 - z1 * z1 - w1 * w1;
|
||||
if ( t1 < 0 ) n1 = 0.0;
|
||||
else {
|
||||
|
||||
t1 *= t1;
|
||||
n1 = t1 * t1 * this._dot4( grad4[ gi1 ], x1, y1, z1, w1 );
|
||||
|
||||
}
|
||||
|
||||
let t2 = 0.6 - x2 * x2 - y2 * y2 - z2 * z2 - w2 * w2;
|
||||
if ( t2 < 0 ) n2 = 0.0;
|
||||
else {
|
||||
|
||||
t2 *= t2;
|
||||
n2 = t2 * t2 * this._dot4( grad4[ gi2 ], x2, y2, z2, w2 );
|
||||
|
||||
}
|
||||
|
||||
let t3 = 0.6 - x3 * x3 - y3 * y3 - z3 * z3 - w3 * w3;
|
||||
if ( t3 < 0 ) n3 = 0.0;
|
||||
else {
|
||||
|
||||
t3 *= t3;
|
||||
n3 = t3 * t3 * this._dot4( grad4[ gi3 ], x3, y3, z3, w3 );
|
||||
|
||||
}
|
||||
|
||||
let t4 = 0.6 - x4 * x4 - y4 * y4 - z4 * z4 - w4 * w4;
|
||||
if ( t4 < 0 ) n4 = 0.0;
|
||||
else {
|
||||
|
||||
t4 *= t4;
|
||||
n4 = t4 * t4 * this._dot4( grad4[ gi4 ], x4, y4, z4, w4 );
|
||||
|
||||
}
|
||||
|
||||
// Sum up and scale the result to cover the range [-1,1]
|
||||
return 27.0 * ( n0 + n1 + n2 + n3 + n4 );
|
||||
|
||||
}
|
||||
|
||||
// private
|
||||
|
||||
_dot( g, x, y ) {
|
||||
|
||||
return g[ 0 ] * x + g[ 1 ] * y;
|
||||
|
||||
}
|
||||
|
||||
_dot3( g, x, y, z ) {
|
||||
|
||||
return g[ 0 ] * x + g[ 1 ] * y + g[ 2 ] * z;
|
||||
|
||||
}
|
||||
|
||||
_dot4( g, x, y, z, w ) {
|
||||
|
||||
return g[ 0 ] * x + g[ 1 ] * y + g[ 2 ] * z + g[ 3 ] * w;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { SimplexNoise };
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
Clock,
|
||||
HalfFloatType,
|
||||
NoBlending,
|
||||
Vector2,
|
||||
WebGLRenderTarget
|
||||
} from 'three';
|
||||
import { CopyShader } from '../shaders/CopyShader.js';
|
||||
import { ShaderPass } from './ShaderPass.js';
|
||||
import { ClearMaskPass, MaskPass } from './MaskPass.js';
|
||||
|
||||
/**
|
||||
* Used to implement post-processing effects in three.js.
|
||||
* The class manages a chain of post-processing passes to produce the final visual result.
|
||||
* Post-processing passes are executed in order of their addition/insertion.
|
||||
* The last pass is automatically rendered to screen.
|
||||
*
|
||||
* This module can only be used with {@link WebGLRenderer}.
|
||||
*
|
||||
* ```js
|
||||
* const composer = new EffectComposer( renderer );
|
||||
*
|
||||
* // adding some passes
|
||||
* const renderPass = new RenderPass( scene, camera );
|
||||
* composer.addPass( renderPass );
|
||||
*
|
||||
* const glitchPass = new GlitchPass();
|
||||
* composer.addPass( glitchPass );
|
||||
*
|
||||
* const outputPass = new OutputPass()
|
||||
* composer.addPass( outputPass );
|
||||
*
|
||||
* function animate() {
|
||||
*
|
||||
* composer.render(); // instead of renderer.render()
|
||||
*
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @three_import import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
*/
|
||||
class EffectComposer {
|
||||
|
||||
/**
|
||||
* Constructs a new effect composer.
|
||||
*
|
||||
* @param {WebGLRenderer} renderer - The renderer.
|
||||
* @param {WebGLRenderTarget} [renderTarget] - This render target and a clone will
|
||||
* be used as the internal read and write buffers. If not given, the composer creates
|
||||
* the buffers automatically.
|
||||
*/
|
||||
constructor( renderer, renderTarget ) {
|
||||
|
||||
/**
|
||||
* The renderer.
|
||||
*
|
||||
* @type {WebGLRenderer}
|
||||
*/
|
||||
this.renderer = renderer;
|
||||
|
||||
this._pixelRatio = renderer.getPixelRatio();
|
||||
|
||||
if ( renderTarget === undefined ) {
|
||||
|
||||
const size = renderer.getSize( new Vector2() );
|
||||
this._width = size.width;
|
||||
this._height = size.height;
|
||||
|
||||
renderTarget = new WebGLRenderTarget( this._width * this._pixelRatio, this._height * this._pixelRatio, { type: HalfFloatType } );
|
||||
renderTarget.texture.name = 'EffectComposer.rt1';
|
||||
|
||||
} else {
|
||||
|
||||
this._width = renderTarget.width;
|
||||
this._height = renderTarget.height;
|
||||
|
||||
}
|
||||
|
||||
this.renderTarget1 = renderTarget;
|
||||
this.renderTarget2 = renderTarget.clone();
|
||||
this.renderTarget2.texture.name = 'EffectComposer.rt2';
|
||||
|
||||
/**
|
||||
* A reference to the internal write buffer. Passes usually write
|
||||
* their result into this buffer.
|
||||
*
|
||||
* @type {WebGLRenderTarget}
|
||||
*/
|
||||
this.writeBuffer = this.renderTarget1;
|
||||
|
||||
/**
|
||||
* A reference to the internal read buffer. Passes usually read
|
||||
* the previous render result from this buffer.
|
||||
*
|
||||
* @type {WebGLRenderTarget}
|
||||
*/
|
||||
this.readBuffer = this.renderTarget2;
|
||||
|
||||
/**
|
||||
* Whether the final pass is rendered to the screen (default framebuffer) or not.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default true
|
||||
*/
|
||||
this.renderToScreen = true;
|
||||
|
||||
/**
|
||||
* An array representing the (ordered) chain of post-processing passes.
|
||||
*
|
||||
* @type {Array<Pass>}
|
||||
*/
|
||||
this.passes = [];
|
||||
|
||||
/**
|
||||
* A copy pass used for internal swap operations.
|
||||
*
|
||||
* @private
|
||||
* @type {ShaderPass}
|
||||
*/
|
||||
this.copyPass = new ShaderPass( CopyShader );
|
||||
this.copyPass.material.blending = NoBlending;
|
||||
|
||||
/**
|
||||
* The internal clock for managing time data.
|
||||
*
|
||||
* @private
|
||||
* @type {Clock}
|
||||
*/
|
||||
this.clock = new Clock();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the internal read/write buffers.
|
||||
*/
|
||||
swapBuffers() {
|
||||
|
||||
const tmp = this.readBuffer;
|
||||
this.readBuffer = this.writeBuffer;
|
||||
this.writeBuffer = tmp;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given pass to the pass chain.
|
||||
*
|
||||
* @param {Pass} pass - The pass to add.
|
||||
*/
|
||||
addPass( pass ) {
|
||||
|
||||
this.passes.push( pass );
|
||||
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the given pass at a given index.
|
||||
*
|
||||
* @param {Pass} pass - The pass to insert.
|
||||
* @param {number} index - The index into the pass chain.
|
||||
*/
|
||||
insertPass( pass, index ) {
|
||||
|
||||
this.passes.splice( index, 0, pass );
|
||||
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the given pass from the pass chain.
|
||||
*
|
||||
* @param {Pass} pass - The pass to remove.
|
||||
*/
|
||||
removePass( pass ) {
|
||||
|
||||
const index = this.passes.indexOf( pass );
|
||||
|
||||
if ( index !== - 1 ) {
|
||||
|
||||
this.passes.splice( index, 1 );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the pass for the given index is the last enabled pass in the pass chain.
|
||||
*
|
||||
* @param {number} passIndex - The pass index.
|
||||
* @return {boolean} Whether the pass for the given index is the last pass in the pass chain.
|
||||
*/
|
||||
isLastEnabledPass( passIndex ) {
|
||||
|
||||
for ( let i = passIndex + 1; i < this.passes.length; i ++ ) {
|
||||
|
||||
if ( this.passes[ i ].enabled ) {
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes all enabled post-processing passes in order to produce the final frame.
|
||||
*
|
||||
* @param {number} deltaTime - The delta time in seconds. If not given, the composer computes
|
||||
* its own time delta value.
|
||||
*/
|
||||
render( deltaTime ) {
|
||||
|
||||
// deltaTime value is in seconds
|
||||
|
||||
if ( deltaTime === undefined ) {
|
||||
|
||||
deltaTime = this.clock.getDelta();
|
||||
|
||||
}
|
||||
|
||||
const currentRenderTarget = this.renderer.getRenderTarget();
|
||||
|
||||
let maskActive = false;
|
||||
|
||||
for ( let i = 0, il = this.passes.length; i < il; i ++ ) {
|
||||
|
||||
const pass = this.passes[ i ];
|
||||
|
||||
if ( pass.enabled === false ) continue;
|
||||
|
||||
pass.renderToScreen = ( this.renderToScreen && this.isLastEnabledPass( i ) );
|
||||
pass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime, maskActive );
|
||||
|
||||
if ( pass.needsSwap ) {
|
||||
|
||||
if ( maskActive ) {
|
||||
|
||||
const context = this.renderer.getContext();
|
||||
const stencil = this.renderer.state.buffers.stencil;
|
||||
|
||||
//context.stencilFunc( context.NOTEQUAL, 1, 0xffffffff );
|
||||
stencil.setFunc( context.NOTEQUAL, 1, 0xffffffff );
|
||||
|
||||
this.copyPass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime );
|
||||
|
||||
//context.stencilFunc( context.EQUAL, 1, 0xffffffff );
|
||||
stencil.setFunc( context.EQUAL, 1, 0xffffffff );
|
||||
|
||||
}
|
||||
|
||||
this.swapBuffers();
|
||||
|
||||
}
|
||||
|
||||
if ( MaskPass !== undefined ) {
|
||||
|
||||
if ( pass instanceof MaskPass ) {
|
||||
|
||||
maskActive = true;
|
||||
|
||||
} else if ( pass instanceof ClearMaskPass ) {
|
||||
|
||||
maskActive = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
this.renderer.setRenderTarget( currentRenderTarget );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the internal state of the EffectComposer.
|
||||
*
|
||||
* @param {WebGLRenderTarget} [renderTarget] - This render target has the same purpose like
|
||||
* the one from the constructor. If set, it is used to setup the read and write buffers.
|
||||
*/
|
||||
reset( renderTarget ) {
|
||||
|
||||
if ( renderTarget === undefined ) {
|
||||
|
||||
const size = this.renderer.getSize( new Vector2() );
|
||||
this._pixelRatio = this.renderer.getPixelRatio();
|
||||
this._width = size.width;
|
||||
this._height = size.height;
|
||||
|
||||
renderTarget = this.renderTarget1.clone();
|
||||
renderTarget.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
|
||||
|
||||
}
|
||||
|
||||
this.renderTarget1.dispose();
|
||||
this.renderTarget2.dispose();
|
||||
this.renderTarget1 = renderTarget;
|
||||
this.renderTarget2 = renderTarget.clone();
|
||||
|
||||
this.writeBuffer = this.renderTarget1;
|
||||
this.readBuffer = this.renderTarget2;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes the internal read and write buffers as well as all passes. Similar to {@link WebGLRenderer#setSize},
|
||||
* this method honors the current pixel ration.
|
||||
*
|
||||
* @param {number} width - The width in logical pixels.
|
||||
* @param {number} height - The height in logical pixels.
|
||||
*/
|
||||
setSize( width, height ) {
|
||||
|
||||
this._width = width;
|
||||
this._height = height;
|
||||
|
||||
const effectiveWidth = this._width * this._pixelRatio;
|
||||
const effectiveHeight = this._height * this._pixelRatio;
|
||||
|
||||
this.renderTarget1.setSize( effectiveWidth, effectiveHeight );
|
||||
this.renderTarget2.setSize( effectiveWidth, effectiveHeight );
|
||||
|
||||
for ( let i = 0; i < this.passes.length; i ++ ) {
|
||||
|
||||
this.passes[ i ].setSize( effectiveWidth, effectiveHeight );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets device pixel ratio. This is usually used for HiDPI device to prevent blurring output.
|
||||
* Setting the pixel ratio will automatically resize the composer.
|
||||
*
|
||||
* @param {number} pixelRatio - The pixel ratio to set.
|
||||
*/
|
||||
setPixelRatio( pixelRatio ) {
|
||||
|
||||
this._pixelRatio = pixelRatio;
|
||||
|
||||
this.setSize( this._width, this._height );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees the GPU-related resources allocated by this instance. Call this
|
||||
* method whenever the composer is no longer used in your app.
|
||||
*/
|
||||
dispose() {
|
||||
|
||||
this.renderTarget1.dispose();
|
||||
this.renderTarget2.dispose();
|
||||
|
||||
this.copyPass.dispose();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { EffectComposer };
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import { Pass } from './Pass.js';
|
||||
|
||||
/**
|
||||
* This pass can be used to define a mask during post processing.
|
||||
* Meaning only areas of subsequent post processing are affected
|
||||
* which lie in the masking area of this pass. Internally, the masking
|
||||
* is implemented with the stencil buffer.
|
||||
*
|
||||
* ```js
|
||||
* const maskPass = new MaskPass( scene, camera );
|
||||
* composer.addPass( maskPass );
|
||||
* ```
|
||||
*
|
||||
* @augments Pass
|
||||
* @three_import import { MaskPass } from 'three/addons/postprocessing/MaskPass.js';
|
||||
*/
|
||||
class MaskPass extends Pass {
|
||||
|
||||
/**
|
||||
* Constructs a new mask pass.
|
||||
*
|
||||
* @param {Scene} scene - The 3D objects in this scene will define the mask.
|
||||
* @param {Camera} camera - The camera.
|
||||
*/
|
||||
constructor( scene, camera ) {
|
||||
|
||||
super();
|
||||
|
||||
/**
|
||||
* The scene that defines the mask.
|
||||
*
|
||||
* @type {Scene}
|
||||
*/
|
||||
this.scene = scene;
|
||||
|
||||
/**
|
||||
* The camera.
|
||||
*
|
||||
* @type {Camera}
|
||||
*/
|
||||
this.camera = camera;
|
||||
|
||||
/**
|
||||
* Overwritten to perform a clear operation by default.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default true
|
||||
*/
|
||||
this.clear = true;
|
||||
|
||||
/**
|
||||
* Overwritten to disable the swap.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
*/
|
||||
this.needsSwap = false;
|
||||
|
||||
/**
|
||||
* Whether to inverse the mask or not.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
*/
|
||||
this.inverse = false;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a mask pass with the configured scene and camera.
|
||||
*
|
||||
* @param {WebGLRenderer} renderer - The renderer.
|
||||
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
|
||||
* destination for the pass.
|
||||
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
|
||||
* previous pass from this buffer.
|
||||
* @param {number} deltaTime - The delta time in seconds.
|
||||
* @param {boolean} maskActive - Whether masking is active or not.
|
||||
*/
|
||||
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
|
||||
|
||||
const context = renderer.getContext();
|
||||
const state = renderer.state;
|
||||
|
||||
// don't update color or depth
|
||||
|
||||
state.buffers.color.setMask( false );
|
||||
state.buffers.depth.setMask( false );
|
||||
|
||||
// lock buffers
|
||||
|
||||
state.buffers.color.setLocked( true );
|
||||
state.buffers.depth.setLocked( true );
|
||||
|
||||
// set up stencil
|
||||
|
||||
let writeValue, clearValue;
|
||||
|
||||
if ( this.inverse ) {
|
||||
|
||||
writeValue = 0;
|
||||
clearValue = 1;
|
||||
|
||||
} else {
|
||||
|
||||
writeValue = 1;
|
||||
clearValue = 0;
|
||||
|
||||
}
|
||||
|
||||
state.buffers.stencil.setTest( true );
|
||||
state.buffers.stencil.setOp( context.REPLACE, context.REPLACE, context.REPLACE );
|
||||
state.buffers.stencil.setFunc( context.ALWAYS, writeValue, 0xffffffff );
|
||||
state.buffers.stencil.setClear( clearValue );
|
||||
state.buffers.stencil.setLocked( true );
|
||||
|
||||
// draw into the stencil buffer
|
||||
|
||||
renderer.setRenderTarget( readBuffer );
|
||||
if ( this.clear ) renderer.clear();
|
||||
renderer.render( this.scene, this.camera );
|
||||
|
||||
renderer.setRenderTarget( writeBuffer );
|
||||
if ( this.clear ) renderer.clear();
|
||||
renderer.render( this.scene, this.camera );
|
||||
|
||||
// unlock color and depth buffer and make them writable for subsequent rendering/clearing
|
||||
|
||||
state.buffers.color.setLocked( false );
|
||||
state.buffers.depth.setLocked( false );
|
||||
|
||||
state.buffers.color.setMask( true );
|
||||
state.buffers.depth.setMask( true );
|
||||
|
||||
// only render where stencil is set to 1
|
||||
|
||||
state.buffers.stencil.setLocked( false );
|
||||
state.buffers.stencil.setFunc( context.EQUAL, 1, 0xffffffff ); // draw if == 1
|
||||
state.buffers.stencil.setOp( context.KEEP, context.KEEP, context.KEEP );
|
||||
state.buffers.stencil.setLocked( true );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This pass can be used to clear a mask previously defined with {@link MaskPass}.
|
||||
*
|
||||
* ```js
|
||||
* const clearPass = new ClearMaskPass();
|
||||
* composer.addPass( clearPass );
|
||||
* ```
|
||||
*
|
||||
* @augments Pass
|
||||
*/
|
||||
class ClearMaskPass extends Pass {
|
||||
|
||||
/**
|
||||
* Constructs a new clear mask pass.
|
||||
*/
|
||||
constructor() {
|
||||
|
||||
super();
|
||||
|
||||
/**
|
||||
* Overwritten to disable the swap.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
*/
|
||||
this.needsSwap = false;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the clear of the currently defined mask.
|
||||
*
|
||||
* @param {WebGLRenderer} renderer - The renderer.
|
||||
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
|
||||
* destination for the pass.
|
||||
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
|
||||
* previous pass from this buffer.
|
||||
* @param {number} deltaTime - The delta time in seconds.
|
||||
* @param {boolean} maskActive - Whether masking is active or not.
|
||||
*/
|
||||
render( renderer /*, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
|
||||
|
||||
renderer.state.buffers.stencil.setLocked( false );
|
||||
renderer.state.buffers.stencil.setTest( false );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { MaskPass, ClearMaskPass };
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
ColorManagement,
|
||||
RawShaderMaterial,
|
||||
UniformsUtils,
|
||||
LinearToneMapping,
|
||||
ReinhardToneMapping,
|
||||
CineonToneMapping,
|
||||
AgXToneMapping,
|
||||
ACESFilmicToneMapping,
|
||||
NeutralToneMapping,
|
||||
CustomToneMapping,
|
||||
SRGBTransfer
|
||||
} from 'three';
|
||||
import { Pass, FullScreenQuad } from './Pass.js';
|
||||
import { OutputShader } from '../shaders/OutputShader.js';
|
||||
|
||||
/**
|
||||
* This pass is responsible for including tone mapping and color space conversion
|
||||
* into your pass chain. In most cases, this pass should be included at the end
|
||||
* of each pass chain. If a pass requires sRGB input (e.g. like FXAA), the pass
|
||||
* must follow `OutputPass` in the pass chain.
|
||||
*
|
||||
* The tone mapping and color space settings are extracted from the renderer.
|
||||
*
|
||||
* ```js
|
||||
* const outputPass = new OutputPass();
|
||||
* composer.addPass( outputPass );
|
||||
* ```
|
||||
*
|
||||
* @augments Pass
|
||||
* @three_import import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
|
||||
*/
|
||||
class OutputPass extends Pass {
|
||||
|
||||
/**
|
||||
* Constructs a new output pass.
|
||||
*/
|
||||
constructor() {
|
||||
|
||||
super();
|
||||
|
||||
/**
|
||||
* The pass uniforms.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.uniforms = UniformsUtils.clone( OutputShader.uniforms );
|
||||
|
||||
/**
|
||||
* The pass material.
|
||||
*
|
||||
* @type {RawShaderMaterial}
|
||||
*/
|
||||
this.material = new RawShaderMaterial( {
|
||||
name: OutputShader.name,
|
||||
uniforms: this.uniforms,
|
||||
vertexShader: OutputShader.vertexShader,
|
||||
fragmentShader: OutputShader.fragmentShader
|
||||
} );
|
||||
|
||||
// internals
|
||||
|
||||
this._fsQuad = new FullScreenQuad( this.material );
|
||||
|
||||
this._outputColorSpace = null;
|
||||
this._toneMapping = null;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the output pass.
|
||||
*
|
||||
* @param {WebGLRenderer} renderer - The renderer.
|
||||
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
|
||||
* destination for the pass.
|
||||
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
|
||||
* previous pass from this buffer.
|
||||
* @param {number} deltaTime - The delta time in seconds.
|
||||
* @param {boolean} maskActive - Whether masking is active or not.
|
||||
*/
|
||||
render( renderer, writeBuffer, readBuffer/*, deltaTime, maskActive */ ) {
|
||||
|
||||
this.uniforms[ 'tDiffuse' ].value = readBuffer.texture;
|
||||
this.uniforms[ 'toneMappingExposure' ].value = renderer.toneMappingExposure;
|
||||
|
||||
// rebuild defines if required
|
||||
|
||||
if ( this._outputColorSpace !== renderer.outputColorSpace || this._toneMapping !== renderer.toneMapping ) {
|
||||
|
||||
this._outputColorSpace = renderer.outputColorSpace;
|
||||
this._toneMapping = renderer.toneMapping;
|
||||
|
||||
this.material.defines = {};
|
||||
|
||||
if ( ColorManagement.getTransfer( this._outputColorSpace ) === SRGBTransfer ) this.material.defines.SRGB_TRANSFER = '';
|
||||
|
||||
if ( this._toneMapping === LinearToneMapping ) this.material.defines.LINEAR_TONE_MAPPING = '';
|
||||
else if ( this._toneMapping === ReinhardToneMapping ) this.material.defines.REINHARD_TONE_MAPPING = '';
|
||||
else if ( this._toneMapping === CineonToneMapping ) this.material.defines.CINEON_TONE_MAPPING = '';
|
||||
else if ( this._toneMapping === ACESFilmicToneMapping ) this.material.defines.ACES_FILMIC_TONE_MAPPING = '';
|
||||
else if ( this._toneMapping === AgXToneMapping ) this.material.defines.AGX_TONE_MAPPING = '';
|
||||
else if ( this._toneMapping === NeutralToneMapping ) this.material.defines.NEUTRAL_TONE_MAPPING = '';
|
||||
else if ( this._toneMapping === CustomToneMapping ) this.material.defines.CUSTOM_TONE_MAPPING = '';
|
||||
|
||||
this.material.needsUpdate = true;
|
||||
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
if ( this.renderToScreen === true ) {
|
||||
|
||||
renderer.setRenderTarget( null );
|
||||
this._fsQuad.render( renderer );
|
||||
|
||||
} else {
|
||||
|
||||
renderer.setRenderTarget( writeBuffer );
|
||||
if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
|
||||
this._fsQuad.render( renderer );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees the GPU-related resources allocated by this instance. Call this
|
||||
* method whenever the pass is no longer used in your app.
|
||||
*/
|
||||
dispose() {
|
||||
|
||||
this.material.dispose();
|
||||
this._fsQuad.dispose();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { OutputPass };
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
BufferGeometry,
|
||||
Float32BufferAttribute,
|
||||
OrthographicCamera,
|
||||
Mesh
|
||||
} from 'three';
|
||||
|
||||
/**
|
||||
* Abstract base class for all post processing passes.
|
||||
*
|
||||
* This module is only relevant for post processing with {@link WebGLRenderer}.
|
||||
*
|
||||
* @abstract
|
||||
* @three_import import { Pass } from 'three/addons/postprocessing/Pass.js';
|
||||
*/
|
||||
class Pass {
|
||||
|
||||
/**
|
||||
* Constructs a new pass.
|
||||
*/
|
||||
constructor() {
|
||||
|
||||
/**
|
||||
* This flag can be used for type testing.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @readonly
|
||||
* @default true
|
||||
*/
|
||||
this.isPass = true;
|
||||
|
||||
/**
|
||||
* If set to `true`, the pass is processed by the composer.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default true
|
||||
*/
|
||||
this.enabled = true;
|
||||
|
||||
/**
|
||||
* If set to `true`, the pass indicates to swap read and write buffer after rendering.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default true
|
||||
*/
|
||||
this.needsSwap = true;
|
||||
|
||||
/**
|
||||
* If set to `true`, the pass clears its buffer before rendering
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
*/
|
||||
this.clear = false;
|
||||
|
||||
/**
|
||||
* If set to `true`, the result of the pass is rendered to screen. The last pass in the composers
|
||||
* pass chain gets automatically rendered to screen, no matter how this property is configured.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
*/
|
||||
this.renderToScreen = false;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the size of the pass.
|
||||
*
|
||||
* @abstract
|
||||
* @param {number} width - The width to set.
|
||||
* @param {number} height - The height to set.
|
||||
*/
|
||||
setSize( /* width, height */ ) {}
|
||||
|
||||
/**
|
||||
* This method holds the render logic of a pass. It must be implemented in all derived classes.
|
||||
*
|
||||
* @abstract
|
||||
* @param {WebGLRenderer} renderer - The renderer.
|
||||
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
|
||||
* destination for the pass.
|
||||
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
|
||||
* previous pass from this buffer.
|
||||
* @param {number} deltaTime - The delta time in seconds.
|
||||
* @param {boolean} maskActive - Whether masking is active or not.
|
||||
*/
|
||||
render( /* renderer, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
|
||||
|
||||
console.error( 'THREE.Pass: .render() must be implemented in derived pass.' );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees the GPU-related resources allocated by this instance. Call this
|
||||
* method whenever the pass is no longer used in your app.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
dispose() {}
|
||||
|
||||
}
|
||||
|
||||
// Helper for passes that need to fill the viewport with a single quad.
|
||||
|
||||
const _camera = new OrthographicCamera( - 1, 1, 1, - 1, 0, 1 );
|
||||
|
||||
// https://github.com/mrdoob/three.js/pull/21358
|
||||
|
||||
class FullscreenTriangleGeometry extends BufferGeometry {
|
||||
|
||||
constructor() {
|
||||
|
||||
super();
|
||||
|
||||
this.setAttribute( 'position', new Float32BufferAttribute( [ - 1, 3, 0, - 1, - 1, 0, 3, - 1, 0 ], 3 ) );
|
||||
this.setAttribute( 'uv', new Float32BufferAttribute( [ 0, 2, 0, 0, 2, 0 ], 2 ) );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const _geometry = new FullscreenTriangleGeometry();
|
||||
|
||||
|
||||
/**
|
||||
* This module is a helper for passes which need to render a full
|
||||
* screen effect which is quite common in context of post processing.
|
||||
*
|
||||
* The intended usage is to reuse a single full screen quad for rendering
|
||||
* subsequent passes by just reassigning the `material` reference.
|
||||
*
|
||||
* This module can only be used with {@link WebGLRenderer}.
|
||||
*
|
||||
* @augments Mesh
|
||||
* @three_import import { FullScreenQuad } from 'three/addons/postprocessing/Pass.js';
|
||||
*/
|
||||
class FullScreenQuad {
|
||||
|
||||
/**
|
||||
* Constructs a new full screen quad.
|
||||
*
|
||||
* @param {?Material} material - The material to render te full screen quad with.
|
||||
*/
|
||||
constructor( material ) {
|
||||
|
||||
this._mesh = new Mesh( _geometry, material );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees the GPU-related resources allocated by this instance. Call this
|
||||
* method whenever the instance is no longer used in your app.
|
||||
*/
|
||||
dispose() {
|
||||
|
||||
this._mesh.geometry.dispose();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the full screen quad.
|
||||
*
|
||||
* @param {WebGLRenderer} renderer - The renderer.
|
||||
*/
|
||||
render( renderer ) {
|
||||
|
||||
renderer.render( this._mesh, _camera );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* The quad's material.
|
||||
*
|
||||
* @type {?Material}
|
||||
*/
|
||||
get material() {
|
||||
|
||||
return this._mesh.material;
|
||||
|
||||
}
|
||||
|
||||
set material( value ) {
|
||||
|
||||
this._mesh.material = value;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Pass, FullScreenQuad };
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
Color
|
||||
} from 'three';
|
||||
import { Pass } from './Pass.js';
|
||||
|
||||
/**
|
||||
* This class represents a render pass. It takes a camera and a scene and produces
|
||||
* a beauty pass for subsequent post processing effects.
|
||||
*
|
||||
* ```js
|
||||
* const renderPass = new RenderPass( scene, camera );
|
||||
* composer.addPass( renderPass );
|
||||
* ```
|
||||
*
|
||||
* @augments Pass
|
||||
* @three_import import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
*/
|
||||
class RenderPass extends Pass {
|
||||
|
||||
/**
|
||||
* Constructs a new render pass.
|
||||
*
|
||||
* @param {Scene} scene - The scene to render.
|
||||
* @param {Camera} camera - The camera.
|
||||
* @param {?Material} [overrideMaterial=null] - The override material. If set, this material is used
|
||||
* for all objects in the scene.
|
||||
* @param {?(number|Color|string)} [clearColor=null] - The clear color of the render pass.
|
||||
* @param {?number} [clearAlpha=null] - The clear alpha of the render pass.
|
||||
*/
|
||||
constructor( scene, camera, overrideMaterial = null, clearColor = null, clearAlpha = null ) {
|
||||
|
||||
super();
|
||||
|
||||
/**
|
||||
* The scene to render.
|
||||
*
|
||||
* @type {Scene}
|
||||
*/
|
||||
this.scene = scene;
|
||||
|
||||
/**
|
||||
* The camera.
|
||||
*
|
||||
* @type {Camera}
|
||||
*/
|
||||
this.camera = camera;
|
||||
|
||||
/**
|
||||
* The override material. If set, this material is used
|
||||
* for all objects in the scene.
|
||||
*
|
||||
* @type {?Material}
|
||||
* @default null
|
||||
*/
|
||||
this.overrideMaterial = overrideMaterial;
|
||||
|
||||
/**
|
||||
* The clear color of the render pass.
|
||||
*
|
||||
* @type {?(number|Color|string)}
|
||||
* @default null
|
||||
*/
|
||||
this.clearColor = clearColor;
|
||||
|
||||
/**
|
||||
* The clear alpha of the render pass.
|
||||
*
|
||||
* @type {?number}
|
||||
* @default null
|
||||
*/
|
||||
this.clearAlpha = clearAlpha;
|
||||
|
||||
/**
|
||||
* Overwritten to perform a clear operation by default.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default true
|
||||
*/
|
||||
this.clear = true;
|
||||
|
||||
/**
|
||||
* If set to `true`, only the depth can be cleared when `clear` is to `false`.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
*/
|
||||
this.clearDepth = false;
|
||||
|
||||
/**
|
||||
* Overwritten to disable the swap.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
*/
|
||||
this.needsSwap = false;
|
||||
this._oldClearColor = new Color();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a beauty pass with the configured scene and camera.
|
||||
*
|
||||
* @param {WebGLRenderer} renderer - The renderer.
|
||||
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
|
||||
* destination for the pass.
|
||||
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
|
||||
* previous pass from this buffer.
|
||||
* @param {number} deltaTime - The delta time in seconds.
|
||||
* @param {boolean} maskActive - Whether masking is active or not.
|
||||
*/
|
||||
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
|
||||
|
||||
const oldAutoClear = renderer.autoClear;
|
||||
renderer.autoClear = false;
|
||||
|
||||
let oldClearAlpha, oldOverrideMaterial;
|
||||
|
||||
if ( this.overrideMaterial !== null ) {
|
||||
|
||||
oldOverrideMaterial = this.scene.overrideMaterial;
|
||||
|
||||
this.scene.overrideMaterial = this.overrideMaterial;
|
||||
|
||||
}
|
||||
|
||||
if ( this.clearColor !== null ) {
|
||||
|
||||
renderer.getClearColor( this._oldClearColor );
|
||||
renderer.setClearColor( this.clearColor, renderer.getClearAlpha() );
|
||||
|
||||
}
|
||||
|
||||
if ( this.clearAlpha !== null ) {
|
||||
|
||||
oldClearAlpha = renderer.getClearAlpha();
|
||||
renderer.setClearAlpha( this.clearAlpha );
|
||||
|
||||
}
|
||||
|
||||
if ( this.clearDepth == true ) {
|
||||
|
||||
renderer.clearDepth();
|
||||
|
||||
}
|
||||
|
||||
renderer.setRenderTarget( this.renderToScreen ? null : readBuffer );
|
||||
|
||||
if ( this.clear === true ) {
|
||||
|
||||
// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
|
||||
renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
|
||||
|
||||
}
|
||||
|
||||
renderer.render( this.scene, this.camera );
|
||||
|
||||
// restore
|
||||
|
||||
if ( this.clearColor !== null ) {
|
||||
|
||||
renderer.setClearColor( this._oldClearColor );
|
||||
|
||||
}
|
||||
|
||||
if ( this.clearAlpha !== null ) {
|
||||
|
||||
renderer.setClearAlpha( oldClearAlpha );
|
||||
|
||||
}
|
||||
|
||||
if ( this.overrideMaterial !== null ) {
|
||||
|
||||
this.scene.overrideMaterial = oldOverrideMaterial;
|
||||
|
||||
}
|
||||
|
||||
renderer.autoClear = oldAutoClear;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { RenderPass };
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
import {
|
||||
AddEquation,
|
||||
Color,
|
||||
CustomBlending,
|
||||
DataTexture,
|
||||
DepthTexture,
|
||||
DstAlphaFactor,
|
||||
DstColorFactor,
|
||||
FloatType,
|
||||
HalfFloatType,
|
||||
MathUtils,
|
||||
MeshNormalMaterial,
|
||||
NearestFilter,
|
||||
NoBlending,
|
||||
RedFormat,
|
||||
DepthStencilFormat,
|
||||
UnsignedInt248Type,
|
||||
RepeatWrapping,
|
||||
ShaderMaterial,
|
||||
UniformsUtils,
|
||||
Vector3,
|
||||
WebGLRenderTarget,
|
||||
ZeroFactor
|
||||
} from 'three';
|
||||
import { Pass, FullScreenQuad } from './Pass.js';
|
||||
import { SimplexNoise } from '../math/SimplexNoise.js';
|
||||
import { SSAOBlurShader, SSAODepthShader, SSAOShader } from '../shaders/SSAOShader.js';
|
||||
import { CopyShader } from '../shaders/CopyShader.js';
|
||||
|
||||
/**
|
||||
* A pass for a basic SSAO effect.
|
||||
*
|
||||
* {@link SAOPass} and {@link GTAPass} produce a more advanced AO but are also
|
||||
* more expensive.
|
||||
*
|
||||
* ```js
|
||||
* const ssaoPass = new SSAOPass( scene, camera, width, height );
|
||||
* composer.addPass( ssaoPass );
|
||||
* ```
|
||||
*
|
||||
* @augments Pass
|
||||
* @three_import import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js';
|
||||
*/
|
||||
class SSAOPass extends Pass {
|
||||
|
||||
/**
|
||||
* Constructs a new SSAO pass.
|
||||
*
|
||||
* @param {Scene} scene - The scene to compute the AO for.
|
||||
* @param {Camera} camera - The camera.
|
||||
* @param {number} [width=512] - The width of the effect.
|
||||
* @param {number} [height=512] - The height of the effect.
|
||||
* @param {number} [kernelSize=32] - The kernel size.
|
||||
*/
|
||||
constructor( scene, camera, width = 512, height = 512, kernelSize = 32 ) {
|
||||
|
||||
super();
|
||||
|
||||
/**
|
||||
* The width of the effect.
|
||||
*
|
||||
* @type {number}
|
||||
* @default 512
|
||||
*/
|
||||
this.width = width;
|
||||
|
||||
/**
|
||||
* The height of the effect.
|
||||
*
|
||||
* @type {number}
|
||||
* @default 512
|
||||
*/
|
||||
this.height = height;
|
||||
|
||||
/**
|
||||
* Overwritten to perform a clear operation by default.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default true
|
||||
*/
|
||||
this.clear = true;
|
||||
|
||||
/**
|
||||
* Overwritten to disable the swap.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
*/
|
||||
this.needsSwap = false;
|
||||
|
||||
/**
|
||||
* The camera.
|
||||
*
|
||||
* @type {Camera}
|
||||
*/
|
||||
this.camera = camera;
|
||||
|
||||
/**
|
||||
* The scene to render the AO for.
|
||||
*
|
||||
* @type {Scene}
|
||||
*/
|
||||
this.scene = scene;
|
||||
|
||||
/**
|
||||
* The kernel radius controls how wide the
|
||||
* AO spreads.
|
||||
*
|
||||
* @type {number}
|
||||
* @default 8
|
||||
*/
|
||||
this.kernelRadius = 8;
|
||||
this.kernel = [];
|
||||
this.noiseTexture = null;
|
||||
|
||||
/**
|
||||
* The output configuration.
|
||||
*
|
||||
* @type {number}
|
||||
* @default 0
|
||||
*/
|
||||
this.output = 0;
|
||||
|
||||
/**
|
||||
* Defines the minimum distance that should be
|
||||
* affected by the AO.
|
||||
*
|
||||
* @type {number}
|
||||
* @default 0.005
|
||||
*/
|
||||
this.minDistance = 0.005;
|
||||
|
||||
/**
|
||||
* Defines the maximum distance that should be
|
||||
* affected by the AO.
|
||||
*
|
||||
* @type {number}
|
||||
* @default 0.1
|
||||
*/
|
||||
this.maxDistance = 0.1;
|
||||
|
||||
this._visibilityCache = [];
|
||||
|
||||
//
|
||||
|
||||
this._generateSampleKernel( kernelSize );
|
||||
this._generateRandomKernelRotations();
|
||||
|
||||
// depth texture
|
||||
|
||||
const depthTexture = new DepthTexture();
|
||||
depthTexture.format = DepthStencilFormat;
|
||||
depthTexture.type = UnsignedInt248Type;
|
||||
|
||||
// normal render target with depth buffer
|
||||
|
||||
this.normalRenderTarget = new WebGLRenderTarget( this.width, this.height, {
|
||||
minFilter: NearestFilter,
|
||||
magFilter: NearestFilter,
|
||||
type: HalfFloatType,
|
||||
depthTexture: depthTexture
|
||||
} );
|
||||
|
||||
// ssao render target
|
||||
|
||||
this.ssaoRenderTarget = new WebGLRenderTarget( this.width, this.height, { type: HalfFloatType } );
|
||||
|
||||
this.blurRenderTarget = this.ssaoRenderTarget.clone();
|
||||
|
||||
// ssao material
|
||||
|
||||
this.ssaoMaterial = new ShaderMaterial( {
|
||||
defines: Object.assign( {}, SSAOShader.defines ),
|
||||
uniforms: UniformsUtils.clone( SSAOShader.uniforms ),
|
||||
vertexShader: SSAOShader.vertexShader,
|
||||
fragmentShader: SSAOShader.fragmentShader,
|
||||
blending: NoBlending
|
||||
} );
|
||||
|
||||
this.ssaoMaterial.defines[ 'KERNEL_SIZE' ] = kernelSize;
|
||||
|
||||
this.ssaoMaterial.uniforms[ 'tNormal' ].value = this.normalRenderTarget.texture;
|
||||
this.ssaoMaterial.uniforms[ 'tDepth' ].value = this.normalRenderTarget.depthTexture;
|
||||
this.ssaoMaterial.uniforms[ 'tNoise' ].value = this.noiseTexture;
|
||||
this.ssaoMaterial.uniforms[ 'kernel' ].value = this.kernel;
|
||||
this.ssaoMaterial.uniforms[ 'cameraNear' ].value = this.camera.near;
|
||||
this.ssaoMaterial.uniforms[ 'cameraFar' ].value = this.camera.far;
|
||||
this.ssaoMaterial.uniforms[ 'resolution' ].value.set( this.width, this.height );
|
||||
this.ssaoMaterial.uniforms[ 'cameraProjectionMatrix' ].value.copy( this.camera.projectionMatrix );
|
||||
this.ssaoMaterial.uniforms[ 'cameraInverseProjectionMatrix' ].value.copy( this.camera.projectionMatrixInverse );
|
||||
|
||||
// normal material
|
||||
|
||||
this.normalMaterial = new MeshNormalMaterial();
|
||||
this.normalMaterial.blending = NoBlending;
|
||||
|
||||
// blur material
|
||||
|
||||
this.blurMaterial = new ShaderMaterial( {
|
||||
defines: Object.assign( {}, SSAOBlurShader.defines ),
|
||||
uniforms: UniformsUtils.clone( SSAOBlurShader.uniforms ),
|
||||
vertexShader: SSAOBlurShader.vertexShader,
|
||||
fragmentShader: SSAOBlurShader.fragmentShader
|
||||
} );
|
||||
this.blurMaterial.uniforms[ 'tDiffuse' ].value = this.ssaoRenderTarget.texture;
|
||||
this.blurMaterial.uniforms[ 'resolution' ].value.set( this.width, this.height );
|
||||
|
||||
// material for rendering the depth
|
||||
|
||||
this.depthRenderMaterial = new ShaderMaterial( {
|
||||
defines: Object.assign( {}, SSAODepthShader.defines ),
|
||||
uniforms: UniformsUtils.clone( SSAODepthShader.uniforms ),
|
||||
vertexShader: SSAODepthShader.vertexShader,
|
||||
fragmentShader: SSAODepthShader.fragmentShader,
|
||||
blending: NoBlending
|
||||
} );
|
||||
this.depthRenderMaterial.uniforms[ 'tDepth' ].value = this.normalRenderTarget.depthTexture;
|
||||
this.depthRenderMaterial.uniforms[ 'cameraNear' ].value = this.camera.near;
|
||||
this.depthRenderMaterial.uniforms[ 'cameraFar' ].value = this.camera.far;
|
||||
|
||||
// material for rendering the content of a render target
|
||||
|
||||
this.copyMaterial = new ShaderMaterial( {
|
||||
uniforms: UniformsUtils.clone( CopyShader.uniforms ),
|
||||
vertexShader: CopyShader.vertexShader,
|
||||
fragmentShader: CopyShader.fragmentShader,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
blendSrc: DstColorFactor,
|
||||
blendDst: ZeroFactor,
|
||||
blendEquation: AddEquation,
|
||||
blendSrcAlpha: DstAlphaFactor,
|
||||
blendDstAlpha: ZeroFactor,
|
||||
blendEquationAlpha: AddEquation
|
||||
} );
|
||||
|
||||
// internals
|
||||
|
||||
this._fsQuad = new FullScreenQuad( null );
|
||||
|
||||
this._originalClearColor = new Color();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees the GPU-related resources allocated by this instance. Call this
|
||||
* method whenever the pass is no longer used in your app.
|
||||
*/
|
||||
dispose() {
|
||||
|
||||
// dispose render targets
|
||||
|
||||
this.normalRenderTarget.dispose();
|
||||
this.ssaoRenderTarget.dispose();
|
||||
this.blurRenderTarget.dispose();
|
||||
|
||||
// dispose materials
|
||||
|
||||
this.normalMaterial.dispose();
|
||||
this.blurMaterial.dispose();
|
||||
this.copyMaterial.dispose();
|
||||
this.depthRenderMaterial.dispose();
|
||||
|
||||
// dispose full screen quad
|
||||
|
||||
this._fsQuad.dispose();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the SSAO pass.
|
||||
*
|
||||
* @param {WebGLRenderer} renderer - The renderer.
|
||||
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
|
||||
* destination for the pass.
|
||||
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
|
||||
* previous pass from this buffer.
|
||||
* @param {number} deltaTime - The delta time in seconds.
|
||||
* @param {boolean} maskActive - Whether masking is active or not.
|
||||
*/
|
||||
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
|
||||
|
||||
// render normals and depth (honor only meshes, points and lines do not contribute to SSAO)
|
||||
|
||||
this._overrideVisibility();
|
||||
this._renderOverride( renderer, this.normalMaterial, this.normalRenderTarget, 0x7777ff, 1.0 );
|
||||
this._restoreVisibility();
|
||||
|
||||
// render SSAO
|
||||
|
||||
this.ssaoMaterial.uniforms[ 'kernelRadius' ].value = this.kernelRadius;
|
||||
this.ssaoMaterial.uniforms[ 'minDistance' ].value = this.minDistance;
|
||||
this.ssaoMaterial.uniforms[ 'maxDistance' ].value = this.maxDistance;
|
||||
this._renderPass( renderer, this.ssaoMaterial, this.ssaoRenderTarget );
|
||||
|
||||
// render blur
|
||||
|
||||
this._renderPass( renderer, this.blurMaterial, this.blurRenderTarget );
|
||||
|
||||
// output result to screen
|
||||
|
||||
switch ( this.output ) {
|
||||
|
||||
case SSAOPass.OUTPUT.SSAO:
|
||||
|
||||
this.copyMaterial.uniforms[ 'tDiffuse' ].value = this.ssaoRenderTarget.texture;
|
||||
this.copyMaterial.blending = NoBlending;
|
||||
this._renderPass( renderer, this.copyMaterial, this.renderToScreen ? null : readBuffer );
|
||||
|
||||
break;
|
||||
|
||||
case SSAOPass.OUTPUT.Blur:
|
||||
|
||||
this.copyMaterial.uniforms[ 'tDiffuse' ].value = this.blurRenderTarget.texture;
|
||||
this.copyMaterial.blending = NoBlending;
|
||||
this._renderPass( renderer, this.copyMaterial, this.renderToScreen ? null : readBuffer );
|
||||
|
||||
break;
|
||||
|
||||
case SSAOPass.OUTPUT.Depth:
|
||||
|
||||
this._renderPass( renderer, this.depthRenderMaterial, this.renderToScreen ? null : readBuffer );
|
||||
|
||||
break;
|
||||
|
||||
case SSAOPass.OUTPUT.Normal:
|
||||
|
||||
this.copyMaterial.uniforms[ 'tDiffuse' ].value = this.normalRenderTarget.texture;
|
||||
this.copyMaterial.blending = NoBlending;
|
||||
this._renderPass( renderer, this.copyMaterial, this.renderToScreen ? null : readBuffer );
|
||||
|
||||
break;
|
||||
|
||||
case SSAOPass.OUTPUT.Default:
|
||||
|
||||
this.copyMaterial.uniforms[ 'tDiffuse' ].value = this.blurRenderTarget.texture;
|
||||
this.copyMaterial.blending = CustomBlending;
|
||||
this._renderPass( renderer, this.copyMaterial, this.renderToScreen ? null : readBuffer );
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn( 'THREE.SSAOPass: Unknown output type.' );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the size of the pass.
|
||||
*
|
||||
* @param {number} width - The width to set.
|
||||
* @param {number} height - The height to set.
|
||||
*/
|
||||
setSize( width, height ) {
|
||||
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
||||
this.ssaoRenderTarget.setSize( width, height );
|
||||
this.normalRenderTarget.setSize( width, height );
|
||||
this.blurRenderTarget.setSize( width, height );
|
||||
|
||||
this.ssaoMaterial.uniforms[ 'resolution' ].value.set( width, height );
|
||||
this.ssaoMaterial.uniforms[ 'cameraProjectionMatrix' ].value.copy( this.camera.projectionMatrix );
|
||||
this.ssaoMaterial.uniforms[ 'cameraInverseProjectionMatrix' ].value.copy( this.camera.projectionMatrixInverse );
|
||||
|
||||
this.blurMaterial.uniforms[ 'resolution' ].value.set( width, height );
|
||||
|
||||
}
|
||||
|
||||
// internals
|
||||
|
||||
_renderPass( renderer, passMaterial, renderTarget, clearColor, clearAlpha ) {
|
||||
|
||||
// save original state
|
||||
renderer.getClearColor( this._originalClearColor );
|
||||
const originalClearAlpha = renderer.getClearAlpha();
|
||||
const originalAutoClear = renderer.autoClear;
|
||||
|
||||
renderer.setRenderTarget( renderTarget );
|
||||
|
||||
// setup pass state
|
||||
renderer.autoClear = false;
|
||||
if ( ( clearColor !== undefined ) && ( clearColor !== null ) ) {
|
||||
|
||||
renderer.setClearColor( clearColor );
|
||||
renderer.setClearAlpha( clearAlpha || 0.0 );
|
||||
renderer.clear();
|
||||
|
||||
}
|
||||
|
||||
this._fsQuad.material = passMaterial;
|
||||
this._fsQuad.render( renderer );
|
||||
|
||||
// restore original state
|
||||
renderer.autoClear = originalAutoClear;
|
||||
renderer.setClearColor( this._originalClearColor );
|
||||
renderer.setClearAlpha( originalClearAlpha );
|
||||
|
||||
}
|
||||
|
||||
_renderOverride( renderer, overrideMaterial, renderTarget, clearColor, clearAlpha ) {
|
||||
|
||||
renderer.getClearColor( this._originalClearColor );
|
||||
const originalClearAlpha = renderer.getClearAlpha();
|
||||
const originalAutoClear = renderer.autoClear;
|
||||
|
||||
renderer.setRenderTarget( renderTarget );
|
||||
renderer.autoClear = false;
|
||||
|
||||
clearColor = overrideMaterial.clearColor || clearColor;
|
||||
clearAlpha = overrideMaterial.clearAlpha || clearAlpha;
|
||||
|
||||
if ( ( clearColor !== undefined ) && ( clearColor !== null ) ) {
|
||||
|
||||
renderer.setClearColor( clearColor );
|
||||
renderer.setClearAlpha( clearAlpha || 0.0 );
|
||||
renderer.clear();
|
||||
|
||||
}
|
||||
|
||||
this.scene.overrideMaterial = overrideMaterial;
|
||||
renderer.render( this.scene, this.camera );
|
||||
this.scene.overrideMaterial = null;
|
||||
|
||||
// restore original state
|
||||
|
||||
renderer.autoClear = originalAutoClear;
|
||||
renderer.setClearColor( this._originalClearColor );
|
||||
renderer.setClearAlpha( originalClearAlpha );
|
||||
|
||||
}
|
||||
|
||||
_generateSampleKernel( kernelSize ) {
|
||||
|
||||
const kernel = this.kernel;
|
||||
|
||||
for ( let i = 0; i < kernelSize; i ++ ) {
|
||||
|
||||
const sample = new Vector3();
|
||||
sample.x = ( Math.random() * 2 ) - 1;
|
||||
sample.y = ( Math.random() * 2 ) - 1;
|
||||
sample.z = Math.random();
|
||||
|
||||
sample.normalize();
|
||||
|
||||
let scale = i / kernelSize;
|
||||
scale = MathUtils.lerp( 0.1, 1, scale * scale );
|
||||
sample.multiplyScalar( scale );
|
||||
|
||||
kernel.push( sample );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
_generateRandomKernelRotations() {
|
||||
|
||||
const width = 4, height = 4;
|
||||
|
||||
const simplex = new SimplexNoise();
|
||||
|
||||
const size = width * height;
|
||||
const data = new Float32Array( size );
|
||||
|
||||
for ( let i = 0; i < size; i ++ ) {
|
||||
|
||||
const x = ( Math.random() * 2 ) - 1;
|
||||
const y = ( Math.random() * 2 ) - 1;
|
||||
const z = 0;
|
||||
|
||||
data[ i ] = simplex.noise3d( x, y, z );
|
||||
|
||||
}
|
||||
|
||||
this.noiseTexture = new DataTexture( data, width, height, RedFormat, FloatType );
|
||||
this.noiseTexture.wrapS = RepeatWrapping;
|
||||
this.noiseTexture.wrapT = RepeatWrapping;
|
||||
this.noiseTexture.needsUpdate = true;
|
||||
|
||||
}
|
||||
|
||||
_overrideVisibility() {
|
||||
|
||||
const scene = this.scene;
|
||||
const cache = this._visibilityCache;
|
||||
|
||||
scene.traverse( function ( object ) {
|
||||
|
||||
if ( ( object.isPoints || object.isLine || object.isLine2 ) && object.visible ) {
|
||||
|
||||
object.visible = false;
|
||||
cache.push( object );
|
||||
|
||||
}
|
||||
|
||||
} );
|
||||
|
||||
}
|
||||
|
||||
_restoreVisibility() {
|
||||
|
||||
const cache = this._visibilityCache;
|
||||
|
||||
for ( let i = 0; i < cache.length; i ++ ) {
|
||||
|
||||
cache[ i ].visible = true;
|
||||
|
||||
}
|
||||
|
||||
cache.length = 0;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
SSAOPass.OUTPUT = {
|
||||
'Default': 0,
|
||||
'SSAO': 1,
|
||||
'Blur': 2,
|
||||
'Depth': 3,
|
||||
'Normal': 4
|
||||
};
|
||||
|
||||
export { SSAOPass };
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
ShaderMaterial,
|
||||
UniformsUtils
|
||||
} from 'three';
|
||||
import { Pass, FullScreenQuad } from './Pass.js';
|
||||
|
||||
/**
|
||||
* This pass can be used to create a post processing effect
|
||||
* with a raw GLSL shader object. Useful for implementing custom
|
||||
* effects.
|
||||
*
|
||||
* ```js
|
||||
* const fxaaPass = new ShaderPass( FXAAShader );
|
||||
* composer.addPass( fxaaPass );
|
||||
* ```
|
||||
*
|
||||
* @augments Pass
|
||||
* @three_import import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
|
||||
*/
|
||||
class ShaderPass extends Pass {
|
||||
|
||||
/**
|
||||
* Constructs a new shader pass.
|
||||
*
|
||||
* @param {Object|ShaderMaterial} [shader] - A shader object holding vertex and fragment shader as well as
|
||||
* defines and uniforms. It's also valid to pass a custom shader material.
|
||||
* @param {string} [textureID='tDiffuse'] - The name of the texture uniform that should sample
|
||||
* the read buffer.
|
||||
*/
|
||||
constructor( shader, textureID = 'tDiffuse' ) {
|
||||
|
||||
super();
|
||||
|
||||
/**
|
||||
* The name of the texture uniform that should sample the read buffer.
|
||||
*
|
||||
* @type {string}
|
||||
* @default 'tDiffuse'
|
||||
*/
|
||||
this.textureID = textureID;
|
||||
|
||||
/**
|
||||
* The pass uniforms.
|
||||
*
|
||||
* @type {?Object}
|
||||
*/
|
||||
this.uniforms = null;
|
||||
|
||||
/**
|
||||
* The pass material.
|
||||
*
|
||||
* @type {?ShaderMaterial}
|
||||
*/
|
||||
this.material = null;
|
||||
|
||||
if ( shader instanceof ShaderMaterial ) {
|
||||
|
||||
this.uniforms = shader.uniforms;
|
||||
|
||||
this.material = shader;
|
||||
|
||||
} else if ( shader ) {
|
||||
|
||||
this.uniforms = UniformsUtils.clone( shader.uniforms );
|
||||
|
||||
this.material = new ShaderMaterial( {
|
||||
|
||||
name: ( shader.name !== undefined ) ? shader.name : 'unspecified',
|
||||
defines: Object.assign( {}, shader.defines ),
|
||||
uniforms: this.uniforms,
|
||||
vertexShader: shader.vertexShader,
|
||||
fragmentShader: shader.fragmentShader
|
||||
|
||||
} );
|
||||
|
||||
}
|
||||
|
||||
// internals
|
||||
|
||||
this._fsQuad = new FullScreenQuad( this.material );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the shader pass.
|
||||
*
|
||||
* @param {WebGLRenderer} renderer - The renderer.
|
||||
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
|
||||
* destination for the pass.
|
||||
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
|
||||
* previous pass from this buffer.
|
||||
* @param {number} deltaTime - The delta time in seconds.
|
||||
* @param {boolean} maskActive - Whether masking is active or not.
|
||||
*/
|
||||
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
|
||||
|
||||
if ( this.uniforms[ this.textureID ] ) {
|
||||
|
||||
this.uniforms[ this.textureID ].value = readBuffer.texture;
|
||||
|
||||
}
|
||||
|
||||
this._fsQuad.material = this.material;
|
||||
|
||||
if ( this.renderToScreen ) {
|
||||
|
||||
renderer.setRenderTarget( null );
|
||||
this._fsQuad.render( renderer );
|
||||
|
||||
} else {
|
||||
|
||||
renderer.setRenderTarget( writeBuffer );
|
||||
// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
|
||||
if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
|
||||
this._fsQuad.render( renderer );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees the GPU-related resources allocated by this instance. Call this
|
||||
* method whenever the pass is no longer used in your app.
|
||||
*/
|
||||
dispose() {
|
||||
|
||||
this.material.dispose();
|
||||
|
||||
this._fsQuad.dispose();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { ShaderPass };
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @module CopyShader
|
||||
* @three_import import { CopyShader } from 'three/addons/shaders/CopyShader.js';
|
||||
*/
|
||||
|
||||
/**
|
||||
* Full-screen copy shader pass.
|
||||
*
|
||||
* @constant
|
||||
* @type {ShaderMaterial~Shader}
|
||||
*/
|
||||
const CopyShader = {
|
||||
|
||||
name: 'CopyShader',
|
||||
|
||||
uniforms: {
|
||||
|
||||
'tDiffuse': { value: null },
|
||||
'opacity': { value: 1.0 }
|
||||
|
||||
},
|
||||
|
||||
vertexShader: /* glsl */`
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
|
||||
}`,
|
||||
|
||||
fragmentShader: /* glsl */`
|
||||
|
||||
uniform float opacity;
|
||||
|
||||
uniform sampler2D tDiffuse;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
|
||||
vec4 texel = texture2D( tDiffuse, vUv );
|
||||
gl_FragColor = opacity * texel;
|
||||
|
||||
|
||||
}`
|
||||
|
||||
};
|
||||
|
||||
export { CopyShader };
|
||||
@@ -0,0 +1,298 @@
|
||||
import {
|
||||
Vector2
|
||||
} from 'three';
|
||||
|
||||
/**
|
||||
* @module FXAAShader
|
||||
* @three_import import { FXAAShader } from 'three/addons/shaders/FXAAShader.js';
|
||||
*/
|
||||
|
||||
/**
|
||||
* FXAA algorithm from NVIDIA, C# implementation by Jasper Flick, GLSL port by Dave Hoskins.
|
||||
*
|
||||
* References:
|
||||
* - {@link http://developer.download.nvidia.com/assets/gamedev/files/sdk/11/FXAA_WhitePaper.pdf}.
|
||||
* - {@link https://catlikecoding.com/unity/tutorials/advanced-rendering/fxaa/}.
|
||||
*
|
||||
* @constant
|
||||
* @type {ShaderMaterial~Shader}
|
||||
*/
|
||||
const FXAAShader = {
|
||||
|
||||
name: 'FXAAShader',
|
||||
|
||||
uniforms: {
|
||||
|
||||
'tDiffuse': { value: null },
|
||||
'resolution': { value: new Vector2( 1 / 1024, 1 / 512 ) }
|
||||
|
||||
},
|
||||
|
||||
vertexShader: /* glsl */`
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
|
||||
}`,
|
||||
|
||||
fragmentShader: /* glsl */`
|
||||
|
||||
uniform sampler2D tDiffuse;
|
||||
uniform vec2 resolution;
|
||||
varying vec2 vUv;
|
||||
|
||||
#define EDGE_STEP_COUNT 6
|
||||
#define EDGE_GUESS 8.0
|
||||
#define EDGE_STEPS 1.0, 1.5, 2.0, 2.0, 2.0, 4.0
|
||||
const float edgeSteps[EDGE_STEP_COUNT] = float[EDGE_STEP_COUNT]( EDGE_STEPS );
|
||||
|
||||
float _ContrastThreshold = 0.0312;
|
||||
float _RelativeThreshold = 0.063;
|
||||
float _SubpixelBlending = 1.0;
|
||||
|
||||
vec4 Sample( sampler2D tex2D, vec2 uv ) {
|
||||
|
||||
return texture( tex2D, uv );
|
||||
|
||||
}
|
||||
|
||||
float SampleLuminance( sampler2D tex2D, vec2 uv ) {
|
||||
|
||||
return dot( Sample( tex2D, uv ).rgb, vec3( 0.3, 0.59, 0.11 ) );
|
||||
|
||||
}
|
||||
|
||||
float SampleLuminance( sampler2D tex2D, vec2 texSize, vec2 uv, float uOffset, float vOffset ) {
|
||||
|
||||
uv += texSize * vec2(uOffset, vOffset);
|
||||
return SampleLuminance(tex2D, uv);
|
||||
|
||||
}
|
||||
|
||||
struct LuminanceData {
|
||||
|
||||
float m, n, e, s, w;
|
||||
float ne, nw, se, sw;
|
||||
float highest, lowest, contrast;
|
||||
|
||||
};
|
||||
|
||||
LuminanceData SampleLuminanceNeighborhood( sampler2D tex2D, vec2 texSize, vec2 uv ) {
|
||||
|
||||
LuminanceData l;
|
||||
l.m = SampleLuminance( tex2D, uv );
|
||||
l.n = SampleLuminance( tex2D, texSize, uv, 0.0, 1.0 );
|
||||
l.e = SampleLuminance( tex2D, texSize, uv, 1.0, 0.0 );
|
||||
l.s = SampleLuminance( tex2D, texSize, uv, 0.0, -1.0 );
|
||||
l.w = SampleLuminance( tex2D, texSize, uv, -1.0, 0.0 );
|
||||
|
||||
l.ne = SampleLuminance( tex2D, texSize, uv, 1.0, 1.0 );
|
||||
l.nw = SampleLuminance( tex2D, texSize, uv, -1.0, 1.0 );
|
||||
l.se = SampleLuminance( tex2D, texSize, uv, 1.0, -1.0 );
|
||||
l.sw = SampleLuminance( tex2D, texSize, uv, -1.0, -1.0 );
|
||||
|
||||
l.highest = max( max( max( max( l.n, l.e ), l.s ), l.w ), l.m );
|
||||
l.lowest = min( min( min( min( l.n, l.e ), l.s ), l.w ), l.m );
|
||||
l.contrast = l.highest - l.lowest;
|
||||
return l;
|
||||
|
||||
}
|
||||
|
||||
bool ShouldSkipPixel( LuminanceData l ) {
|
||||
|
||||
float threshold = max( _ContrastThreshold, _RelativeThreshold * l.highest );
|
||||
return l.contrast < threshold;
|
||||
|
||||
}
|
||||
|
||||
float DeterminePixelBlendFactor( LuminanceData l ) {
|
||||
|
||||
float f = 2.0 * ( l.n + l.e + l.s + l.w );
|
||||
f += l.ne + l.nw + l.se + l.sw;
|
||||
f *= 1.0 / 12.0;
|
||||
f = abs( f - l.m );
|
||||
f = clamp( f / l.contrast, 0.0, 1.0 );
|
||||
|
||||
float blendFactor = smoothstep( 0.0, 1.0, f );
|
||||
return blendFactor * blendFactor * _SubpixelBlending;
|
||||
|
||||
}
|
||||
|
||||
struct EdgeData {
|
||||
|
||||
bool isHorizontal;
|
||||
float pixelStep;
|
||||
float oppositeLuminance, gradient;
|
||||
|
||||
};
|
||||
|
||||
EdgeData DetermineEdge( vec2 texSize, LuminanceData l ) {
|
||||
|
||||
EdgeData e;
|
||||
float horizontal =
|
||||
abs( l.n + l.s - 2.0 * l.m ) * 2.0 +
|
||||
abs( l.ne + l.se - 2.0 * l.e ) +
|
||||
abs( l.nw + l.sw - 2.0 * l.w );
|
||||
float vertical =
|
||||
abs( l.e + l.w - 2.0 * l.m ) * 2.0 +
|
||||
abs( l.ne + l.nw - 2.0 * l.n ) +
|
||||
abs( l.se + l.sw - 2.0 * l.s );
|
||||
e.isHorizontal = horizontal >= vertical;
|
||||
|
||||
float pLuminance = e.isHorizontal ? l.n : l.e;
|
||||
float nLuminance = e.isHorizontal ? l.s : l.w;
|
||||
float pGradient = abs( pLuminance - l.m );
|
||||
float nGradient = abs( nLuminance - l.m );
|
||||
|
||||
e.pixelStep = e.isHorizontal ? texSize.y : texSize.x;
|
||||
|
||||
if (pGradient < nGradient) {
|
||||
|
||||
e.pixelStep = -e.pixelStep;
|
||||
e.oppositeLuminance = nLuminance;
|
||||
e.gradient = nGradient;
|
||||
|
||||
} else {
|
||||
|
||||
e.oppositeLuminance = pLuminance;
|
||||
e.gradient = pGradient;
|
||||
|
||||
}
|
||||
|
||||
return e;
|
||||
|
||||
}
|
||||
|
||||
float DetermineEdgeBlendFactor( sampler2D tex2D, vec2 texSize, LuminanceData l, EdgeData e, vec2 uv ) {
|
||||
|
||||
vec2 uvEdge = uv;
|
||||
vec2 edgeStep;
|
||||
if (e.isHorizontal) {
|
||||
|
||||
uvEdge.y += e.pixelStep * 0.5;
|
||||
edgeStep = vec2( texSize.x, 0.0 );
|
||||
|
||||
} else {
|
||||
|
||||
uvEdge.x += e.pixelStep * 0.5;
|
||||
edgeStep = vec2( 0.0, texSize.y );
|
||||
|
||||
}
|
||||
|
||||
float edgeLuminance = ( l.m + e.oppositeLuminance ) * 0.5;
|
||||
float gradientThreshold = e.gradient * 0.25;
|
||||
|
||||
vec2 puv = uvEdge + edgeStep * edgeSteps[0];
|
||||
float pLuminanceDelta = SampleLuminance( tex2D, puv ) - edgeLuminance;
|
||||
bool pAtEnd = abs( pLuminanceDelta ) >= gradientThreshold;
|
||||
|
||||
for ( int i = 1; i < EDGE_STEP_COUNT && !pAtEnd; i++ ) {
|
||||
|
||||
puv += edgeStep * edgeSteps[i];
|
||||
pLuminanceDelta = SampleLuminance( tex2D, puv ) - edgeLuminance;
|
||||
pAtEnd = abs( pLuminanceDelta ) >= gradientThreshold;
|
||||
|
||||
}
|
||||
|
||||
if ( !pAtEnd ) {
|
||||
|
||||
puv += edgeStep * EDGE_GUESS;
|
||||
|
||||
}
|
||||
|
||||
vec2 nuv = uvEdge - edgeStep * edgeSteps[0];
|
||||
float nLuminanceDelta = SampleLuminance( tex2D, nuv ) - edgeLuminance;
|
||||
bool nAtEnd = abs( nLuminanceDelta ) >= gradientThreshold;
|
||||
|
||||
for ( int i = 1; i < EDGE_STEP_COUNT && !nAtEnd; i++ ) {
|
||||
|
||||
nuv -= edgeStep * edgeSteps[i];
|
||||
nLuminanceDelta = SampleLuminance( tex2D, nuv ) - edgeLuminance;
|
||||
nAtEnd = abs( nLuminanceDelta ) >= gradientThreshold;
|
||||
|
||||
}
|
||||
|
||||
if ( !nAtEnd ) {
|
||||
|
||||
nuv -= edgeStep * EDGE_GUESS;
|
||||
|
||||
}
|
||||
|
||||
float pDistance, nDistance;
|
||||
if ( e.isHorizontal ) {
|
||||
|
||||
pDistance = puv.x - uv.x;
|
||||
nDistance = uv.x - nuv.x;
|
||||
|
||||
} else {
|
||||
|
||||
pDistance = puv.y - uv.y;
|
||||
nDistance = uv.y - nuv.y;
|
||||
|
||||
}
|
||||
|
||||
float shortestDistance;
|
||||
bool deltaSign;
|
||||
if ( pDistance <= nDistance ) {
|
||||
|
||||
shortestDistance = pDistance;
|
||||
deltaSign = pLuminanceDelta >= 0.0;
|
||||
|
||||
} else {
|
||||
|
||||
shortestDistance = nDistance;
|
||||
deltaSign = nLuminanceDelta >= 0.0;
|
||||
|
||||
}
|
||||
|
||||
if ( deltaSign == ( l.m - edgeLuminance >= 0.0 ) ) {
|
||||
|
||||
return 0.0;
|
||||
|
||||
}
|
||||
|
||||
return 0.5 - shortestDistance / ( pDistance + nDistance );
|
||||
|
||||
}
|
||||
|
||||
vec4 ApplyFXAA( sampler2D tex2D, vec2 texSize, vec2 uv ) {
|
||||
|
||||
LuminanceData luminance = SampleLuminanceNeighborhood( tex2D, texSize, uv );
|
||||
if ( ShouldSkipPixel( luminance ) ) {
|
||||
|
||||
return Sample( tex2D, uv );
|
||||
|
||||
}
|
||||
|
||||
float pixelBlend = DeterminePixelBlendFactor( luminance );
|
||||
EdgeData edge = DetermineEdge( texSize, luminance );
|
||||
float edgeBlend = DetermineEdgeBlendFactor( tex2D, texSize, luminance, edge, uv );
|
||||
float finalBlend = max( pixelBlend, edgeBlend );
|
||||
|
||||
if (edge.isHorizontal) {
|
||||
|
||||
uv.y += edge.pixelStep * finalBlend;
|
||||
|
||||
} else {
|
||||
|
||||
uv.x += edge.pixelStep * finalBlend;
|
||||
|
||||
}
|
||||
|
||||
return Sample( tex2D, uv );
|
||||
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
||||
gl_FragColor = ApplyFXAA( tDiffuse, resolution.xy, vUv );
|
||||
|
||||
}`
|
||||
|
||||
};
|
||||
|
||||
export { FXAAShader };
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @module OutputShader
|
||||
* @three_import import { OutputShader } from 'three/addons/shaders/OutputShader.js';
|
||||
*/
|
||||
|
||||
/**
|
||||
* Performs tone mapping and color space conversion for
|
||||
* FX workflows.
|
||||
*
|
||||
* Used by {@link OutputPass}.
|
||||
*
|
||||
* @constant
|
||||
* @type {ShaderMaterial~Shader}
|
||||
*/
|
||||
const OutputShader = {
|
||||
|
||||
name: 'OutputShader',
|
||||
|
||||
uniforms: {
|
||||
|
||||
'tDiffuse': { value: null },
|
||||
'toneMappingExposure': { value: 1 }
|
||||
|
||||
},
|
||||
|
||||
vertexShader: /* glsl */`
|
||||
precision highp float;
|
||||
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
|
||||
attribute vec3 position;
|
||||
attribute vec2 uv;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
|
||||
}`,
|
||||
|
||||
fragmentShader: /* glsl */`
|
||||
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tDiffuse;
|
||||
|
||||
#include <tonemapping_pars_fragment>
|
||||
#include <colorspace_pars_fragment>
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
|
||||
gl_FragColor = texture2D( tDiffuse, vUv );
|
||||
|
||||
// tone mapping
|
||||
|
||||
#ifdef LINEAR_TONE_MAPPING
|
||||
|
||||
gl_FragColor.rgb = LinearToneMapping( gl_FragColor.rgb );
|
||||
|
||||
#elif defined( REINHARD_TONE_MAPPING )
|
||||
|
||||
gl_FragColor.rgb = ReinhardToneMapping( gl_FragColor.rgb );
|
||||
|
||||
#elif defined( CINEON_TONE_MAPPING )
|
||||
|
||||
gl_FragColor.rgb = CineonToneMapping( gl_FragColor.rgb );
|
||||
|
||||
#elif defined( ACES_FILMIC_TONE_MAPPING )
|
||||
|
||||
gl_FragColor.rgb = ACESFilmicToneMapping( gl_FragColor.rgb );
|
||||
|
||||
#elif defined( AGX_TONE_MAPPING )
|
||||
|
||||
gl_FragColor.rgb = AgXToneMapping( gl_FragColor.rgb );
|
||||
|
||||
#elif defined( NEUTRAL_TONE_MAPPING )
|
||||
|
||||
gl_FragColor.rgb = NeutralToneMapping( gl_FragColor.rgb );
|
||||
|
||||
#elif defined( CUSTOM_TONE_MAPPING )
|
||||
|
||||
gl_FragColor.rgb = CustomToneMapping( gl_FragColor.rgb );
|
||||
|
||||
#endif
|
||||
|
||||
// color space
|
||||
|
||||
#ifdef SRGB_TRANSFER
|
||||
|
||||
gl_FragColor = sRGBTransferOETF( gl_FragColor );
|
||||
|
||||
#endif
|
||||
|
||||
}`
|
||||
|
||||
};
|
||||
|
||||
export { OutputShader };
|
||||
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
Matrix4,
|
||||
Vector2
|
||||
} from 'three';
|
||||
|
||||
/**
|
||||
* @module SSAOShader
|
||||
* @three_import import { SSAOShader } from 'three/addons/shaders/SSAOShader.js';
|
||||
*/
|
||||
|
||||
/**
|
||||
* SSAO shader.
|
||||
*
|
||||
* References:
|
||||
* - {@link http://john-chapman-graphics.blogspot.com/2013/01/ssao-tutorial.html}
|
||||
* - {@link https://learnopengl.com/Advanced-Lighting/SSAO}
|
||||
* - {@link https://github.com/McNopper/OpenGL/blob/master/Example28/shader/ssao.frag.glsl}
|
||||
*
|
||||
* @constant
|
||||
* @type {ShaderMaterial~Shader}
|
||||
*/
|
||||
const SSAOShader = {
|
||||
|
||||
name: 'SSAOShader',
|
||||
|
||||
defines: {
|
||||
'PERSPECTIVE_CAMERA': 1,
|
||||
'KERNEL_SIZE': 32
|
||||
},
|
||||
|
||||
uniforms: {
|
||||
|
||||
'tNormal': { value: null },
|
||||
'tDepth': { value: null },
|
||||
'tNoise': { value: null },
|
||||
'kernel': { value: null },
|
||||
'cameraNear': { value: null },
|
||||
'cameraFar': { value: null },
|
||||
'resolution': { value: new Vector2() },
|
||||
'cameraProjectionMatrix': { value: new Matrix4() },
|
||||
'cameraInverseProjectionMatrix': { value: new Matrix4() },
|
||||
'kernelRadius': { value: 8 },
|
||||
'minDistance': { value: 0.005 },
|
||||
'maxDistance': { value: 0.05 },
|
||||
|
||||
},
|
||||
|
||||
vertexShader: /* glsl */`
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
|
||||
vUv = uv;
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
|
||||
}`,
|
||||
|
||||
fragmentShader: /* glsl */`
|
||||
uniform highp sampler2D tNormal;
|
||||
uniform highp sampler2D tDepth;
|
||||
uniform sampler2D tNoise;
|
||||
|
||||
uniform vec3 kernel[ KERNEL_SIZE ];
|
||||
|
||||
uniform vec2 resolution;
|
||||
|
||||
uniform float cameraNear;
|
||||
uniform float cameraFar;
|
||||
uniform mat4 cameraProjectionMatrix;
|
||||
uniform mat4 cameraInverseProjectionMatrix;
|
||||
|
||||
uniform float kernelRadius;
|
||||
uniform float minDistance; // avoid artifacts caused by neighbour fragments with minimal depth difference
|
||||
uniform float maxDistance; // avoid the influence of fragments which are too far away
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
#include <packing>
|
||||
|
||||
float getDepth( const in vec2 screenPosition ) {
|
||||
|
||||
return texture2D( tDepth, screenPosition ).x;
|
||||
|
||||
}
|
||||
|
||||
float getLinearDepth( const in vec2 screenPosition ) {
|
||||
|
||||
#if PERSPECTIVE_CAMERA == 1
|
||||
|
||||
float fragCoordZ = texture2D( tDepth, screenPosition ).x;
|
||||
float viewZ = perspectiveDepthToViewZ( fragCoordZ, cameraNear, cameraFar );
|
||||
return viewZToOrthographicDepth( viewZ, cameraNear, cameraFar );
|
||||
|
||||
#else
|
||||
|
||||
return texture2D( tDepth, screenPosition ).x;
|
||||
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
float getViewZ( const in float depth ) {
|
||||
|
||||
#if PERSPECTIVE_CAMERA == 1
|
||||
|
||||
return perspectiveDepthToViewZ( depth, cameraNear, cameraFar );
|
||||
|
||||
#else
|
||||
|
||||
return orthographicDepthToViewZ( depth, cameraNear, cameraFar );
|
||||
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
vec3 getViewPosition( const in vec2 screenPosition, const in float depth, const in float viewZ ) {
|
||||
|
||||
float clipW = cameraProjectionMatrix[2][3] * viewZ + cameraProjectionMatrix[3][3];
|
||||
|
||||
vec4 clipPosition = vec4( ( vec3( screenPosition, depth ) - 0.5 ) * 2.0, 1.0 );
|
||||
|
||||
clipPosition *= clipW; // unprojection.
|
||||
|
||||
return ( cameraInverseProjectionMatrix * clipPosition ).xyz;
|
||||
|
||||
}
|
||||
|
||||
vec3 getViewNormal( const in vec2 screenPosition ) {
|
||||
|
||||
return unpackRGBToNormal( texture2D( tNormal, screenPosition ).xyz );
|
||||
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
||||
float depth = getDepth( vUv );
|
||||
|
||||
if ( depth == 1.0 ) {
|
||||
|
||||
gl_FragColor = vec4( 1.0 ); // don't influence background
|
||||
|
||||
} else {
|
||||
|
||||
float viewZ = getViewZ( depth );
|
||||
|
||||
vec3 viewPosition = getViewPosition( vUv, depth, viewZ );
|
||||
vec3 viewNormal = getViewNormal( vUv );
|
||||
|
||||
vec2 noiseScale = vec2( resolution.x / 4.0, resolution.y / 4.0 );
|
||||
vec3 random = vec3( texture2D( tNoise, vUv * noiseScale ).r );
|
||||
|
||||
// compute matrix used to reorient a kernel vector
|
||||
|
||||
vec3 tangent = normalize( random - viewNormal * dot( random, viewNormal ) );
|
||||
vec3 bitangent = cross( viewNormal, tangent );
|
||||
mat3 kernelMatrix = mat3( tangent, bitangent, viewNormal );
|
||||
|
||||
float occlusion = 0.0;
|
||||
|
||||
for ( int i = 0; i < KERNEL_SIZE; i ++ ) {
|
||||
|
||||
vec3 sampleVector = kernelMatrix * kernel[ i ]; // reorient sample vector in view space
|
||||
vec3 samplePoint = viewPosition + ( sampleVector * kernelRadius ); // calculate sample point
|
||||
|
||||
vec4 samplePointNDC = cameraProjectionMatrix * vec4( samplePoint, 1.0 ); // project point and calculate NDC
|
||||
samplePointNDC /= samplePointNDC.w;
|
||||
|
||||
vec2 samplePointUv = samplePointNDC.xy * 0.5 + 0.5; // compute uv coordinates
|
||||
|
||||
float realDepth = getLinearDepth( samplePointUv ); // get linear depth from depth texture
|
||||
float sampleDepth = viewZToOrthographicDepth( samplePoint.z, cameraNear, cameraFar ); // compute linear depth of the sample view Z value
|
||||
float delta = sampleDepth - realDepth;
|
||||
|
||||
if ( delta > minDistance && delta < maxDistance ) { // if fragment is before sample point, increase occlusion
|
||||
|
||||
occlusion += 1.0;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
occlusion = clamp( occlusion / float( KERNEL_SIZE ), 0.0, 1.0 );
|
||||
|
||||
gl_FragColor = vec4( vec3( 1.0 - occlusion ), 1.0 );
|
||||
|
||||
}
|
||||
|
||||
}`
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* SSAO depth shader.
|
||||
*
|
||||
* @constant
|
||||
* @type {ShaderMaterial~Shader}
|
||||
*/
|
||||
const SSAODepthShader = {
|
||||
|
||||
name: 'SSAODepthShader',
|
||||
|
||||
defines: {
|
||||
'PERSPECTIVE_CAMERA': 1
|
||||
},
|
||||
|
||||
uniforms: {
|
||||
|
||||
'tDepth': { value: null },
|
||||
'cameraNear': { value: null },
|
||||
'cameraFar': { value: null },
|
||||
|
||||
},
|
||||
|
||||
vertexShader:
|
||||
|
||||
`varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
|
||||
}`,
|
||||
|
||||
fragmentShader:
|
||||
|
||||
`uniform sampler2D tDepth;
|
||||
|
||||
uniform float cameraNear;
|
||||
uniform float cameraFar;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
#include <packing>
|
||||
|
||||
float getLinearDepth( const in vec2 screenPosition ) {
|
||||
|
||||
#if PERSPECTIVE_CAMERA == 1
|
||||
|
||||
float fragCoordZ = texture2D( tDepth, screenPosition ).x;
|
||||
float viewZ = perspectiveDepthToViewZ( fragCoordZ, cameraNear, cameraFar );
|
||||
return viewZToOrthographicDepth( viewZ, cameraNear, cameraFar );
|
||||
|
||||
#else
|
||||
|
||||
return texture2D( tDepth, screenPosition ).x;
|
||||
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
||||
float depth = getLinearDepth( vUv );
|
||||
gl_FragColor = vec4( vec3( 1.0 - depth ), 1.0 );
|
||||
|
||||
}`
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* SSAO blur shader.
|
||||
*
|
||||
* @constant
|
||||
* @type {Object}
|
||||
*/
|
||||
const SSAOBlurShader = {
|
||||
|
||||
name: 'SSAOBlurShader',
|
||||
|
||||
uniforms: {
|
||||
|
||||
'tDiffuse': { value: null },
|
||||
'resolution': { value: new Vector2() }
|
||||
|
||||
},
|
||||
|
||||
vertexShader:
|
||||
|
||||
`varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
|
||||
}`,
|
||||
|
||||
fragmentShader:
|
||||
|
||||
`uniform sampler2D tDiffuse;
|
||||
|
||||
uniform vec2 resolution;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
|
||||
vec2 texelSize = ( 1.0 / resolution );
|
||||
float result = 0.0;
|
||||
|
||||
for ( int i = - 2; i <= 2; i ++ ) {
|
||||
|
||||
for ( int j = - 2; j <= 2; j ++ ) {
|
||||
|
||||
vec2 offset = ( vec2( float( i ), float( j ) ) ) * texelSize;
|
||||
result += texture2D( tDiffuse, vUv + offset ).r;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
gl_FragColor = vec4( vec3( result / ( 5.0 * 5.0 ) ), 1.0 );
|
||||
|
||||
}`
|
||||
|
||||
};
|
||||
|
||||
export { SSAOShader, SSAODepthShader, SSAOBlurShader };
|
||||
+1435
File diff suppressed because it is too large
Load Diff
+58773
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,38 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const projectDir = process.cwd();
|
||||
const standaloneDir = path.join(projectDir, ".next", "standalone");
|
||||
const standalonePublicDir = path.join(standaloneDir, "public");
|
||||
const standaloneStaticDir = path.join(standaloneDir, ".next", "static");
|
||||
|
||||
fs.rmSync(standalonePublicDir, { recursive: true, force: true });
|
||||
fs.rmSync(standaloneStaticDir, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(projectDir, "public"), standalonePublicDir, {
|
||||
recursive: true,
|
||||
});
|
||||
fs.mkdirSync(path.dirname(standaloneStaticDir), { recursive: true });
|
||||
fs.cpSync(path.join(projectDir, ".next", "static"), standaloneStaticDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const server = spawn(process.execPath, [path.join(standaloneDir, "server.js")], {
|
||||
cwd: standaloneDir,
|
||||
env: {
|
||||
...process.env,
|
||||
HOSTNAME: "127.0.0.1",
|
||||
PORT: "3100",
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const stopServer = (signal) => {
|
||||
if (!server.killed) server.kill(signal);
|
||||
};
|
||||
|
||||
process.once("SIGINT", () => stopServer("SIGINT"));
|
||||
process.once("SIGTERM", () => stopServer("SIGTERM"));
|
||||
server.once("exit", (code, signal) => {
|
||||
process.exitCode = signal ? 1 : (code ?? 1);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
const ThreeDimensionalScene = dynamic(
|
||||
() => import("@components/threeDimensional/ThreeDimensionalScene"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Box sx={{ height: "100%", display: "grid", placeItems: "center" }}>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function ThreeDimensionalScenePage() {
|
||||
return <ThreeDimensionalScene />;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { App } from "./RefineContext";
|
||||
|
||||
let mockSessionState: {
|
||||
data: {
|
||||
accessToken: string;
|
||||
user: { id: string; name: string };
|
||||
} | null;
|
||||
status: "authenticated" | "unauthenticated";
|
||||
} = {
|
||||
data: {
|
||||
accessToken: "expired-access-token",
|
||||
user: { id: "user-1", name: "Test User" },
|
||||
},
|
||||
status: "authenticated",
|
||||
};
|
||||
|
||||
jest.mock("next-auth/react", () => ({
|
||||
SessionProvider: ({ children }: { children: ReactNode }) => children,
|
||||
signIn: jest.fn().mockResolvedValue(undefined),
|
||||
useSession: () => mockSessionState,
|
||||
}));
|
||||
|
||||
jest.mock("next/navigation", () => ({
|
||||
usePathname: () => "/network-simulation",
|
||||
}));
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
Refine: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
jest.mock("@refinedev/kbar", () => ({
|
||||
RefineKbar: () => null,
|
||||
RefineKbarProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
jest.mock("@refinedev/mui", () => ({
|
||||
RefineSnackbarProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
jest.mock("@refinedev/nextjs-router", () => ({}));
|
||||
jest.mock("@providers/data-provider", () => ({ dataProvider: {} }));
|
||||
jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({
|
||||
useAppNotificationProvider: {},
|
||||
}));
|
||||
jest.mock("@contexts/color-mode", () => ({
|
||||
ColorModeContextProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
jest.mock("@/contexts/ProjectContext", () => ({
|
||||
ProjectProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
jest.mock("@/lib/authToken", () => ({
|
||||
getAccessToken: jest.fn().mockResolvedValue("expired-access-token"),
|
||||
}));
|
||||
|
||||
describe("RefineContext access authentication", () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalRequest = global.Request;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessionState = {
|
||||
data: {
|
||||
accessToken: "expired-access-token",
|
||||
user: { id: "user-1", name: "Test User" },
|
||||
},
|
||||
status: "authenticated",
|
||||
};
|
||||
useAuthStore.setState({
|
||||
accessToken: null,
|
||||
sessionExpired: false,
|
||||
sessionExpiryReason: null,
|
||||
});
|
||||
useAccessStore.setState({
|
||||
context: null,
|
||||
permissions: [],
|
||||
loading: true,
|
||||
});
|
||||
global.Request = class TestRequest {} as unknown as typeof Request;
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
headers: new Headers(),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
global.fetch = originalFetch;
|
||||
global.Request = originalRequest;
|
||||
});
|
||||
|
||||
it("marks the session expired when access-context rejects an expired token", async () => {
|
||||
render(
|
||||
<App>
|
||||
<div>应用内容</div>
|
||||
</App>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/access-context"),
|
||||
expect.not.objectContaining({ skipAuthRedirect: true }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
sessionExpired: true,
|
||||
sessionExpiryReason: "unauthorized",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("prioritizes an unauthenticated session over route permissions", async () => {
|
||||
mockSessionState = { data: null, status: "unauthenticated" };
|
||||
|
||||
render(
|
||||
<App>
|
||||
<div>应用内容</div>
|
||||
</App>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
||||
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
sessionExpired: true,
|
||||
sessionExpiryReason: "unauthorized",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
|
||||
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
||||
import { config } from "@config/config";
|
||||
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
||||
import { supportsThreeDimensionalScene } from "@components/threeDimensional/sceneData";
|
||||
|
||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||
import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
ManageAccounts as ManageAccountsIcon,
|
||||
MyLocation as MyLocationIcon,
|
||||
Search as SearchIcon,
|
||||
ViewInAr as ViewInArIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
type RefineContextProps = {
|
||||
@@ -56,13 +58,16 @@ type AppProps = {
|
||||
defaultMode?: string;
|
||||
};
|
||||
|
||||
const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
const { data, status } = useSession();
|
||||
const to = usePathname();
|
||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
|
||||
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
|
||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||
const currentProjectCode = useProjectStore(
|
||||
(state) => state.currentProjectCode,
|
||||
);
|
||||
const permissions = useAccessStore((state) => state.permissions);
|
||||
const setAccessContext = useAccessStore((state) => state.setContext);
|
||||
const setAccessLoading = useAccessStore((state) => state.setLoading);
|
||||
@@ -84,6 +89,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
markSessionExpired("refresh_failed");
|
||||
return;
|
||||
}
|
||||
if (status === "unauthenticated") {
|
||||
markSessionExpired("unauthorized");
|
||||
return;
|
||||
}
|
||||
if (status === "authenticated") {
|
||||
clearSessionExpired();
|
||||
}
|
||||
@@ -99,7 +108,6 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
setAccessLoading(true);
|
||||
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
||||
projectHeaderMode: currentProjectId ? "include" : "omit",
|
||||
skipAuthRedirect: true,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (cancelled) return;
|
||||
@@ -206,6 +214,19 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
};
|
||||
|
||||
const resources = [
|
||||
...(supportsThreeDimensionalScene(currentProjectCode) &&
|
||||
can(permissionCodes.webgisView)
|
||||
? [
|
||||
{
|
||||
name: "三维场景",
|
||||
list: "/three-dimensional-scene",
|
||||
meta: {
|
||||
icon: <ViewInArIcon />,
|
||||
label: "三维场景",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.simulationView)
|
||||
? [
|
||||
{
|
||||
@@ -368,7 +389,9 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
}}
|
||||
>
|
||||
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||
<RoutePermissionGuard authenticated={status === "authenticated"}>
|
||||
{props.children}
|
||||
</RoutePermissionGuard>
|
||||
<RefineKbar />
|
||||
</Refine>
|
||||
</RefineSnackbarProvider>
|
||||
|
||||
@@ -657,7 +657,6 @@ export const SystemAdminPanel = () => {
|
||||
try {
|
||||
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
if (!adminResponse.ok) {
|
||||
if (!cancelled) {
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("RoutePermissionGuard", () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<RoutePermissionGuard>
|
||||
<RoutePermissionGuard authenticated>
|
||||
<div>受保护内容</div>
|
||||
</RoutePermissionGuard>,
|
||||
);
|
||||
@@ -43,7 +43,7 @@ describe("RoutePermissionGuard", () => {
|
||||
|
||||
it("shows the permission error when the session is still valid", () => {
|
||||
render(
|
||||
<RoutePermissionGuard>
|
||||
<RoutePermissionGuard authenticated>
|
||||
<div>受保护内容</div>
|
||||
</RoutePermissionGuard>,
|
||||
);
|
||||
@@ -51,4 +51,15 @@ describe("RoutePermissionGuard", () => {
|
||||
expect(screen.getByText("无权访问此功能")).toBeInTheDocument();
|
||||
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("checks authentication before the expired state effect runs", () => {
|
||||
render(
|
||||
<RoutePermissionGuard authenticated={false}>
|
||||
<div>受保护内容</div>
|
||||
</RoutePermissionGuard>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
||||
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,10 @@ import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
export const RoutePermissionGuard = ({
|
||||
children,
|
||||
authenticated,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
authenticated: boolean;
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const permissions = useAccessStore((state) => state.permissions);
|
||||
@@ -20,7 +22,7 @@ export const RoutePermissionGuard = ({
|
||||
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||
const requiredPermission = permissionForPath(pathname);
|
||||
|
||||
if (sessionExpired) {
|
||||
if (!authenticated || sessionExpired) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert severity="warning">
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { AgentActivityTimeline } from "./AgentActivityTimeline";
|
||||
|
||||
describe("AgentActivityTimeline", () => {
|
||||
it("shows only the first lines of a long tool command until expanded", () => {
|
||||
const target = [
|
||||
"cd /app/data/conversation-workspaces/conversation-example",
|
||||
"python3 - <<'EOF'",
|
||||
"import json, glob",
|
||||
"print('fourth line')",
|
||||
"EOF",
|
||||
].join("\n");
|
||||
render(
|
||||
<AgentActivityTimeline
|
||||
activities={[
|
||||
{
|
||||
id: "analysis",
|
||||
title: "处理分析数据",
|
||||
reason: "正在核查结果。",
|
||||
status: "completed",
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
actions: [
|
||||
{
|
||||
id: "long-command",
|
||||
tool: "bash",
|
||||
title: "运行本地分析",
|
||||
status: "completed",
|
||||
target,
|
||||
startedAt: 1100,
|
||||
endedAt: 1900,
|
||||
},
|
||||
{
|
||||
id: "short-command",
|
||||
tool: "tjwater_cli",
|
||||
title: "查询后端数据",
|
||||
status: "completed",
|
||||
target: "help data",
|
||||
startedAt: 1900,
|
||||
endedAt: 1950,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
|
||||
expect(screen.getByText(/import json, glob/u)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/fourth line/u)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("help data")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "展开完整内容" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "展开完整内容" }));
|
||||
expect(screen.getByText(/fourth line/u)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "收起内容" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "收起内容" }));
|
||||
expect(screen.queryByText(/fourth line/u)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
@@ -89,6 +90,11 @@ const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] })
|
||||
|
||||
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
|
||||
const elapsed = getElapsedMs(action, now);
|
||||
const [targetExpanded, setTargetExpanded] = useState(false);
|
||||
const targetId = React.useId();
|
||||
const target = action.target ?? "";
|
||||
const targetPreview = target.split(/\r?\n/u).slice(0, 3).join("\n").slice(0, 240);
|
||||
const hasLongTarget = targetPreview.length < target.length;
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -111,19 +117,45 @@ const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }
|
||||
</Typography>
|
||||
</Stack>
|
||||
{action.target ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: "block",
|
||||
mt: 0.2,
|
||||
fontFamily: "monospace",
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{action.target}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 0.2 }}>
|
||||
<Typography
|
||||
id={targetId}
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: !targetExpanded && hasLongTarget ? "-webkit-box" : "block",
|
||||
fontFamily: "monospace",
|
||||
overflow: !targetExpanded && hasLongTarget ? "hidden" : "visible",
|
||||
overflowWrap: "anywhere",
|
||||
whiteSpace: "pre-wrap",
|
||||
...(!targetExpanded && hasLongTarget
|
||||
? { WebkitBoxOrient: "vertical", WebkitLineClamp: 3 }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{targetExpanded ? target : targetPreview}
|
||||
</Typography>
|
||||
{hasLongTarget ? (
|
||||
<Button
|
||||
size="small"
|
||||
aria-controls={targetId}
|
||||
aria-expanded={targetExpanded}
|
||||
onClick={() => setTargetExpanded((current) => !current)}
|
||||
sx={{
|
||||
mt: 0.2,
|
||||
p: 0,
|
||||
minWidth: 0,
|
||||
color: activityAccent,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 650,
|
||||
lineHeight: 1.5,
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
{targetExpanded ? "收起内容" : "展开完整内容"}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
{action.error ? (
|
||||
<Typography
|
||||
|
||||
@@ -79,7 +79,7 @@ const approvalModeOptions: ReadonlyArray<{
|
||||
{
|
||||
value: "always",
|
||||
label: "始终允许",
|
||||
description: "除明确禁止项外自动放行",
|
||||
description: "常规权限自动放行,敏感路径仍需确认",
|
||||
icon: WarningAmberRounded,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
|
||||
import { PermissionRequestGroup } from "./AgentPermissionRequests";
|
||||
|
||||
jest.mock("framer-motion", () => ({
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
motion: {
|
||||
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("PermissionRequestGroup", () => {
|
||||
it("shows an external path approval request and forwards the user's decision", () => {
|
||||
const onReply = jest.fn();
|
||||
render(
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<PermissionRequestGroup
|
||||
permissions={[
|
||||
{
|
||||
requestId: "external-1",
|
||||
sessionId: "session-1",
|
||||
permission: "external_directory",
|
||||
patterns: ["/app/private/*"],
|
||||
target: "/app/private/report.json",
|
||||
always: ["/app/private/*"],
|
||||
createdAt: 1,
|
||||
status: "pending",
|
||||
},
|
||||
]}
|
||||
isRunning
|
||||
onReply={onReply}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("访问工作区外目录")).toBeInTheDocument();
|
||||
expect(screen.getByText("/app/private/report.json")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "允许一次" }));
|
||||
expect(onReply).toHaveBeenCalledWith("external-1", "once");
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存授权" }));
|
||||
expect(onReply).toHaveBeenCalledWith("external-1", "always");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { parseApplyLayerStylePayload } from "./toolCallStyleHelpers";
|
||||
import { createDefaultLayerStyleState } from "@components/olmap/core/Controls/styleEditorPresets";
|
||||
import { validateStyleConfig } from "@components/olmap/core/Controls/styleEditorUtils";
|
||||
|
||||
describe("parseApplyLayerStylePayload", () => {
|
||||
it("accepts a valid snake_case interval contract", () => {
|
||||
@@ -47,4 +49,17 @@ describe("parseApplyLayerStylePayload", () => {
|
||||
}),
|
||||
).toMatchObject({ layerId: "junctions", styleConfig: { opacity: 0.5 } });
|
||||
});
|
||||
|
||||
it("preserves default palette indexes when only some style fields are provided", () => {
|
||||
const payload = parseApplyLayerStylePayload({
|
||||
layer_id: "pipes",
|
||||
style_config: { property: "flow", color_type: "gradient" },
|
||||
});
|
||||
expect(payload).not.toBeNull();
|
||||
const merged = {
|
||||
...createDefaultLayerStyleState("pipes").styleConfig,
|
||||
...payload?.styleConfig,
|
||||
};
|
||||
expect(validateStyleConfig(merged)).toEqual({ valid: true, errors: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,10 +198,15 @@ export const parseApplyLayerStylePayload = (
|
||||
) return null;
|
||||
}
|
||||
|
||||
const definedStyleConfig = styleConfig
|
||||
? (Object.fromEntries(
|
||||
Object.entries(styleConfig).filter(([, value]) => value !== undefined),
|
||||
) as Partial<StyleConfig>)
|
||||
: undefined;
|
||||
const hasStyleOverrides =
|
||||
styleConfig &&
|
||||
Object.values(styleConfig).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : value !== undefined,
|
||||
definedStyleConfig &&
|
||||
Object.values(definedStyleConfig).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : true,
|
||||
);
|
||||
|
||||
if (!resetToDefault && !hasStyleOverrides) {
|
||||
@@ -211,7 +216,7 @@ export const parseApplyLayerStylePayload = (
|
||||
return {
|
||||
layerId,
|
||||
resetToDefault,
|
||||
styleConfig: hasStyleOverrides ? styleConfig : undefined,
|
||||
styleConfig: hasStyleOverrides ? definedStyleConfig : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
const [showProjectSelector, setShowProjectSelector] = useState(false);
|
||||
const [showChatbox, setShowChatbox] = useState(false);
|
||||
const open = Boolean(anchorEl);
|
||||
const setCurrentProjectId = useProjectStore(
|
||||
(state) => state.setCurrentProjectId,
|
||||
const setActiveProjectContext = useProjectStore(
|
||||
(state) => state.setCurrentProject,
|
||||
);
|
||||
|
||||
const { data: user } = useGetIdentity<IUser>();
|
||||
@@ -78,7 +78,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
localStorage.setItem(NETWORK_NAME_STORAGE_KEY, networkName);
|
||||
localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(","));
|
||||
localStorage.removeItem(`${workspace}_map_view`);
|
||||
setCurrentProjectId(projectId || networkName || workspace);
|
||||
setActiveProjectContext(projectId || networkName || workspace, networkName);
|
||||
setShowProjectSelector(false);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import SCADADataPanel from "./SCADADataPanel";
|
||||
|
||||
jest.mock("@/lib/apiFetch", () => ({ apiFetch: jest.fn() }));
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
useGetIdentity: () => ({ data: { id: "test-user" } }),
|
||||
useNotification: () => ({ open: jest.fn() }),
|
||||
}));
|
||||
jest.mock("@mui/x-date-pickers", () => ({
|
||||
DateTimePicker: () => null,
|
||||
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
jest.mock("echarts-for-react", () => () => null);
|
||||
|
||||
const response = (payload: unknown) =>
|
||||
({ ok: true, json: async () => payload }) as Response;
|
||||
|
||||
describe("SCADADataPanel requests", () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(apiFetch).mockImplementation(async (url) =>
|
||||
response(
|
||||
String(url).includes("/scada-devices")
|
||||
? [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1", link_id: null },
|
||||
{ device_id: "S-2", device_type: "pressure", node_id: "J-2", link_id: null },
|
||||
]
|
||||
: { series: [] },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it("does not request history again when the same device IDs arrive in a new array", async () => {
|
||||
const { rerender } = render(<SCADADataPanel deviceIds={["S-1"]} />);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||
|
||||
rerender(<SCADADataPanel deviceIds={["S-1"]} />);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||
|
||||
rerender(<SCADADataPanel deviceIds={["S-1", "S-2"]} />);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(4));
|
||||
expect(jest.mocked(apiFetch).mock.calls[3][1]?.body).toContain('"device_ids":["S-2"]');
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(6));
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,19 @@ import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
import {
|
||||
ElementHistoryResult,
|
||||
ElementHistorySeries,
|
||||
ElementHistoryTarget,
|
||||
fetchElementHistory,
|
||||
historySeriesKey,
|
||||
TimeSeriesPoint,
|
||||
toTimeSeriesPoints,
|
||||
} from "@/lib/elementHistory";
|
||||
import {
|
||||
FLOW_DISPLAY_UNIT,
|
||||
PRESSURE_DISPLAY_UNIT,
|
||||
} from "@/utils/units";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -50,13 +63,6 @@ type IUser = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
/** ISO8601 时间戳 */
|
||||
timestamp: string;
|
||||
/** 每个设备对应的值 */
|
||||
values: Record<string, number | null | undefined>;
|
||||
}
|
||||
|
||||
export interface SCADADataPanelProps {
|
||||
/** 选中的设备 ID 列表 */
|
||||
deviceIds: string[];
|
||||
@@ -90,164 +96,75 @@ const panelHeaderActionSx = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 从后端 API 获取 SCADA 数据
|
||||
*/
|
||||
interface ScadaDeviceMetadata {
|
||||
device_id: string;
|
||||
device_type: string;
|
||||
node_id: string | null;
|
||||
link_id: string | null;
|
||||
}
|
||||
|
||||
/** 用设备元数据组装统一元素历史查询,一次返回监测与模拟数据。 */
|
||||
const fetchFromBackend = async (
|
||||
deviceIds: string[],
|
||||
range: { from: Date; to: Date },
|
||||
): Promise<TimeSeriesPoint[]> => {
|
||||
): Promise<ElementHistoryResult> => {
|
||||
if (deviceIds.length === 0) {
|
||||
return [];
|
||||
return { points: [], series: [] };
|
||||
}
|
||||
|
||||
const device_ids = deviceIds.join(",");
|
||||
const start_time = dayjs(range.from).toISOString();
|
||||
const end_time = dayjs(range.to).toISOString();
|
||||
// 清洗数据接口
|
||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 原始数据
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 模拟数据接口
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||
try {
|
||||
// 优先查询清洗数据和模拟数据
|
||||
const [cleaningRes, simulationRes] = await Promise.all([
|
||||
apiFetch(cleaningDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(simulationDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const cleaningData = transformBackendData(cleaningRes, deviceIds);
|
||||
const simulationData = transformBackendData(simulationRes, deviceIds);
|
||||
|
||||
// 如果清洗数据有数据,返回清洗和模拟数据
|
||||
if (cleaningData.length > 0) {
|
||||
return mergeTimeSeriesData(
|
||||
cleaningData,
|
||||
simulationData,
|
||||
deviceIds,
|
||||
"clean",
|
||||
"sim",
|
||||
);
|
||||
} else {
|
||||
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
|
||||
const rawRes = await apiFetch(rawDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null);
|
||||
const rawData = transformBackendData(rawRes, deviceIds);
|
||||
return mergeTimeSeriesData(
|
||||
simulationData,
|
||||
rawData,
|
||||
deviceIds,
|
||||
"sim",
|
||||
"raw",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换后端数据格式
|
||||
* 根据实际后端返回的数据结构进行调整
|
||||
*/
|
||||
const transformBackendData = (
|
||||
backendData: any,
|
||||
deviceIds: string[],
|
||||
): TimeSeriesPoint[] => {
|
||||
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
|
||||
if (backendData && !Array.isArray(backendData)) {
|
||||
// 检查是否是设备ID为键的对象格式
|
||||
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
|
||||
|
||||
if (hasDeviceKeys) {
|
||||
// 获取所有时间点的集合
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const deviceData = backendData[deviceId];
|
||||
if (Array.isArray(deviceData)) {
|
||||
deviceData.forEach((item: any) => {
|
||||
const timestamp = item.time || item.timestamp || item._time;
|
||||
if (timestamp) {
|
||||
if (!timeMap.has(timestamp)) {
|
||||
timeMap.set(timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(timestamp)!;
|
||||
values[deviceId] =
|
||||
typeof item.value === "number" ? item.value : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 转换为 TimeSeriesPoint 数组并按时间排序
|
||||
const result = Array.from(timeMap.entries()).map(
|
||||
([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}),
|
||||
);
|
||||
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// 默认返回空数组
|
||||
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 合并两个时间序列数据,为每个设备添加后缀
|
||||
*/
|
||||
const mergeTimeSeriesData = (
|
||||
data1: TimeSeriesPoint[],
|
||||
data2: TimeSeriesPoint[],
|
||||
deviceIds: string[],
|
||||
suffix1: string,
|
||||
suffix2: string,
|
||||
): TimeSeriesPoint[] => {
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
const processData = (data: TimeSeriesPoint[], suffix: string) => {
|
||||
data.forEach((point) => {
|
||||
if (!timeMap.has(point.timestamp)) {
|
||||
timeMap.set(point.timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(point.timestamp)!;
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const value = point.values[deviceId];
|
||||
if (value !== undefined) {
|
||||
values[`${deviceId}_${suffix}`] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
processData(data1, suffix1);
|
||||
processData(data2, suffix2);
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}));
|
||||
|
||||
result.sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
const metadataResponse = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/scada-devices`,
|
||||
);
|
||||
if (!metadataResponse.ok) {
|
||||
throw new Error(`SCADA 设备信息请求失败: HTTP ${metadataResponse.status}`);
|
||||
}
|
||||
const allDevices = (await metadataResponse.json()) as ScadaDeviceMetadata[];
|
||||
const requested = new Set(deviceIds);
|
||||
const devices = allDevices.filter((device) => requested.has(device.device_id));
|
||||
const missing = deviceIds.filter(
|
||||
(deviceId) => !devices.some((device) => device.device_id === deviceId),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`SCADA 设备不存在: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
const targetsByElement = new Map<string, ElementHistoryTarget>();
|
||||
devices.forEach((device) => {
|
||||
const isFlow = ["pipe_flow", "flow"].includes(
|
||||
device.device_type.toLowerCase(),
|
||||
);
|
||||
const elementId = isFlow ? device.link_id : device.node_id;
|
||||
const elementType = isFlow ? "pipe" : "junction";
|
||||
if (!elementId) {
|
||||
throw new Error(`SCADA 设备 ${device.device_id} 未关联管网元素`);
|
||||
}
|
||||
const key = `${elementType}:${elementId}`;
|
||||
const target = targetsByElement.get(key) ?? {
|
||||
element_id: elementId,
|
||||
element_type: elementType,
|
||||
device_ids: [],
|
||||
};
|
||||
target.device_ids!.push(device.device_id);
|
||||
targetsByElement.set(key, target);
|
||||
});
|
||||
const targets = Array.from(targetsByElement.values());
|
||||
const result = await fetchElementHistory(
|
||||
targets,
|
||||
range,
|
||||
"realtime_comparison",
|
||||
);
|
||||
const expandedSeries = result.series.flatMap((series) => {
|
||||
if (series.device_id || series.source.startsWith("scada_")) return [series];
|
||||
const target = targets.find(
|
||||
(item) =>
|
||||
item.element_id === series.element_id &&
|
||||
item.element_type === series.element_type,
|
||||
);
|
||||
return (target?.device_ids ?? []).map((deviceId) => ({
|
||||
...series,
|
||||
device_id: deviceId,
|
||||
}));
|
||||
});
|
||||
return { series: expandedSeries, points: toTimeSeriesPoints(expandedSeries) };
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) =>
|
||||
@@ -337,83 +254,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const { open } = useNotification();
|
||||
const { data: user } = useGetIdentity<IUser>();
|
||||
|
||||
const customFetcher = useMemo(() => {
|
||||
if (!showCleaning) {
|
||||
return fetchFromBackend;
|
||||
}
|
||||
|
||||
return async (
|
||||
deviceIds: string[],
|
||||
range: { from: Date; to: Date },
|
||||
): Promise<TimeSeriesPoint[]> => {
|
||||
const device_ids = deviceIds.join(",");
|
||||
const start_time = dayjs(range.from).toISOString();
|
||||
const end_time = dayjs(range.to).toISOString();
|
||||
|
||||
// 清洗数据接口
|
||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 原始数据
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 模拟数据接口
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||
try {
|
||||
const [cleanRes, rawRes, simRes] = await Promise.all([
|
||||
apiFetch(cleaningDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(rawDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(simulationDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
const processData = (data: any, suffix: string) => {
|
||||
if (!data) return;
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const deviceData = data[deviceId];
|
||||
if (Array.isArray(deviceData)) {
|
||||
deviceData.forEach((item: any) => {
|
||||
const timestamp = item.time || item.timestamp || item._time;
|
||||
if (timestamp) {
|
||||
if (!timeMap.has(timestamp)) {
|
||||
timeMap.set(timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(timestamp)!;
|
||||
values[`${deviceId}_${suffix}`] =
|
||||
typeof item.value === "number" ? item.value : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processData(cleanRes, "clean");
|
||||
processData(rawRes, "raw");
|
||||
processData(simRes, "sim");
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(
|
||||
([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}),
|
||||
);
|
||||
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("[SCADADataPanel] 获取三种数据失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}, [showCleaning]);
|
||||
const customFetcher = fetchFromBackend;
|
||||
|
||||
const [from, setFrom] = useState<Dayjs>(() => {
|
||||
if (start_time) {
|
||||
@@ -435,6 +276,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||
@@ -465,19 +307,44 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
|
||||
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||
|
||||
const hasDevices = deviceIds.length > 0;
|
||||
// Parent map updates can pass a new array with identical IDs. Keep the
|
||||
// request dependency tied to the IDs themselves, not the array identity.
|
||||
const deviceIdsKey = JSON.stringify(deviceIds);
|
||||
const requestedDeviceIds = useMemo(
|
||||
() => JSON.parse(deviceIdsKey) as string[],
|
||||
[deviceIdsKey],
|
||||
);
|
||||
const hasDevices = requestedDeviceIds.length > 0;
|
||||
const hasData = timeSeries.length > 0;
|
||||
const deviceIdsKey = useMemo(() => deviceIds.join(","), [deviceIds]);
|
||||
|
||||
const dataset = useMemo(
|
||||
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
|
||||
[timeSeries, deviceIds, fractionDigits, showCleaning],
|
||||
);
|
||||
const seriesByKey = useMemo(
|
||||
() =>
|
||||
new Map(historySeries.map((series) => [historySeriesKey(series), series])),
|
||||
[historySeries],
|
||||
);
|
||||
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
|
||||
const hasPressureSeries = historySeries.some(
|
||||
(item) => item.metric === "pressure",
|
||||
);
|
||||
const unitForKey = useCallback(
|
||||
(key: string) => seriesByKey.get(key)?.display_unit ?? "",
|
||||
[seriesByKey],
|
||||
);
|
||||
const axisForKey = useCallback(
|
||||
(key: string) =>
|
||||
seriesByKey.get(key)?.metric === "pressure" && hasFlowSeries ? 1 : 0,
|
||||
[hasFlowSeries, seriesByKey],
|
||||
);
|
||||
|
||||
const handleFetch = useCallback(
|
||||
async (reason: string) => {
|
||||
if (!hasDevices) {
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
setLoadingState("idle");
|
||||
setError(null);
|
||||
return;
|
||||
@@ -487,18 +354,19 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
setError(null);
|
||||
try {
|
||||
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
||||
const result = await customFetcher(deviceIds, {
|
||||
const result = await customFetcher(requestedDeviceIds, {
|
||||
from: rangeFrom.toDate(),
|
||||
to: rangeTo.toDate(),
|
||||
});
|
||||
setTimeSeries(result);
|
||||
setTimeSeries(result.points);
|
||||
setHistorySeries(result.series);
|
||||
setLoadingState("success");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "未知错误");
|
||||
setLoadingState("error");
|
||||
}
|
||||
},
|
||||
[deviceIds, customFetcher, hasDevices, normalizedRange],
|
||||
[requestedDeviceIds, customFetcher, hasDevices, normalizedRange],
|
||||
);
|
||||
|
||||
// 处理数据清洗
|
||||
@@ -583,8 +451,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
handleFetch("device-change");
|
||||
} else {
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
}
|
||||
}, [deviceIdsKey, handleFetch, hasDevices]);
|
||||
}, [handleFetch, hasDevices]);
|
||||
|
||||
// 当设备数量变化时,调整数据源选择
|
||||
useEffect(() => {
|
||||
@@ -610,7 +479,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
return deviceIds.flatMap<GridColDef>((id) => [
|
||||
{
|
||||
field: `${id}_raw`,
|
||||
headerName: `${id} (原始)`,
|
||||
headerName: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -623,7 +492,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
},
|
||||
{
|
||||
field: `${id}_clean`,
|
||||
headerName: `${id} (清洗)`,
|
||||
headerName: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -636,7 +505,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
},
|
||||
{
|
||||
field: `${id}_sim`,
|
||||
headerName: `${id} (模拟)`,
|
||||
headerName: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -652,7 +521,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
// 单一数据源模式:只显示选中的数据源
|
||||
return deviceIds.map<GridColDef>((id) => ({
|
||||
field: `${id}_${selectedSource}`,
|
||||
headerName: id,
|
||||
headerName: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -688,7 +557,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
if (hasData) {
|
||||
cols.push({
|
||||
field: fieldKey,
|
||||
headerName: `${deviceName} (${name})`,
|
||||
headerName: `${deviceName} (${name}) [${unitForKey(fieldKey)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -708,7 +577,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
})();
|
||||
|
||||
return [...base, ...dynamic];
|
||||
}, [deviceIds, fractionDigits, showCleaning, selectedSource, dataset]);
|
||||
}, [
|
||||
deviceIds,
|
||||
fractionDigits,
|
||||
showCleaning,
|
||||
selectedSource,
|
||||
dataset,
|
||||
unitForKey,
|
||||
]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
@@ -766,8 +642,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
if (selectedSource === "all") {
|
||||
return deviceIds.flatMap((id, index) => [
|
||||
{
|
||||
name: `${id} (原始)`,
|
||||
name: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_raw`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -775,8 +652,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
data: dataset.map((item) => item[`${id}_raw`]),
|
||||
},
|
||||
{
|
||||
name: `${id} (清洗)`,
|
||||
name: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_clean`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -784,8 +662,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
data: dataset.map((item) => item[`${id}_clean`]),
|
||||
},
|
||||
{
|
||||
name: `${id} (模拟)`,
|
||||
name: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_sim`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -795,8 +674,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
]);
|
||||
} else {
|
||||
return deviceIds.map((id, index) => ({
|
||||
name: id,
|
||||
name: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_${selectedSource}`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -820,8 +700,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
: suffix === "clean"
|
||||
? "清洗"
|
||||
: "模拟"
|
||||
})`,
|
||||
}) [${unitForKey(key)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(key),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -908,10 +789,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
boundaryGap: false,
|
||||
data: xData,
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
scale: true,
|
||||
},
|
||||
yAxis: [
|
||||
...(hasFlowSeries
|
||||
? [
|
||||
{
|
||||
type: "value",
|
||||
scale: true,
|
||||
name: `流量 (${FLOW_DISPLAY_UNIT})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasPressureSeries
|
||||
? [
|
||||
{
|
||||
type: "value",
|
||||
scale: true,
|
||||
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: "inside",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { fetchHistoryData } from "./HistoryDataPanel";
|
||||
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
|
||||
|
||||
jest.mock("@/lib/apiFetch", () => ({
|
||||
apiFetch: jest.fn(),
|
||||
@@ -9,6 +11,12 @@ jest.mock("@components/olmap/core/MapComponent", () => ({
|
||||
useData: () => null,
|
||||
}));
|
||||
|
||||
jest.mock("@mui/x-date-pickers", () => ({
|
||||
DateTimePicker: () => null,
|
||||
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
jest.mock("echarts-for-react", () => () => null);
|
||||
|
||||
const jsonResponse = (payload: unknown, status = 200) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
@@ -24,14 +32,8 @@ const range = {
|
||||
describe("fetchHistoryData", () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it("queries SCADA readings once per selected network element", async () => {
|
||||
jest.mocked(apiFetch).mockImplementation(async (input) => {
|
||||
const url = new URL(String(input));
|
||||
const elementId = url.searchParams.get("element_id") ?? "";
|
||||
return jsonResponse({
|
||||
[elementId]: [{ time: range.from.toISOString(), value: 1 }],
|
||||
});
|
||||
});
|
||||
it("queries all selected elements in one batch request", async () => {
|
||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||
|
||||
await fetchHistoryData(
|
||||
[
|
||||
@@ -42,16 +44,20 @@ describe("fetchHistoryData", () => {
|
||||
"none",
|
||||
);
|
||||
|
||||
const elementIds = jest
|
||||
.mocked(apiFetch)
|
||||
.mock.calls.map(([input]) =>
|
||||
new URL(String(input)).searchParams.get("element_id"),
|
||||
);
|
||||
expect(elementIds).toEqual(["J-1", "J-2", "J-1", "J-2"]);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = jest.mocked(apiFetch).mock.calls[0];
|
||||
expect(String(url)).toContain("/element-history/query");
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
mode: "observed",
|
||||
elements: [
|
||||
{ element_id: "J-1", element_type: "junction" },
|
||||
{ element_id: "J-2", element_type: "junction" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the analysis run ID for historical scheme simulation data", async () => {
|
||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ "P-1": [] }));
|
||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||
|
||||
await fetchHistoryData(
|
||||
[["P-1", "pipe"]],
|
||||
@@ -60,15 +66,12 @@ describe("fetchHistoryData", () => {
|
||||
"99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||
);
|
||||
|
||||
const simulationUrls = jest
|
||||
.mocked(apiFetch)
|
||||
.mock.calls.map(([input]) => new URL(String(input)))
|
||||
.filter((url) => url.pathname.endsWith("/element-simulations"));
|
||||
|
||||
expect(simulationUrls).toHaveLength(2);
|
||||
expect(
|
||||
simulationUrls.map((url) => url.searchParams.get("run_id")),
|
||||
).toEqual([null, "99dd4142-368b-54cb-bfca-d59ee48f6298"]);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
const [, init] = jest.mocked(apiFetch).mock.calls[0];
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
mode: "analysis_comparison",
|
||||
run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects oversized element selections before issuing requests", async () => {
|
||||
@@ -84,17 +87,8 @@ describe("fetchHistoryData", () => {
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("limits concurrent SCADA requests for multi-element history", async () => {
|
||||
let activeRequests = 0;
|
||||
let peakRequests = 0;
|
||||
jest.mocked(apiFetch).mockImplementation(async (input) => {
|
||||
activeRequests += 1;
|
||||
peakRequests = Math.max(peakRequests, activeRequests);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
activeRequests -= 1;
|
||||
const elementId = new URL(String(input)).searchParams.get("element_id") ?? "";
|
||||
return jsonResponse({ [elementId]: [] });
|
||||
});
|
||||
it("does not create N+1 requests for multi-element history", async () => {
|
||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||
|
||||
await fetchHistoryData(
|
||||
Array.from(
|
||||
@@ -105,6 +99,42 @@ describe("fetchHistoryData", () => {
|
||||
"none",
|
||||
);
|
||||
|
||||
expect(peakRequests).toBeLessThanOrEqual(8);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HistoryDataPanel requests", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it("does not reload unchanged elements when its parent rerenders", async () => {
|
||||
const props = { type: "none" as const, onClose: jest.fn() };
|
||||
const { rerender } = render(
|
||||
React.createElement(HistoryDataPanel, {
|
||||
...props,
|
||||
featureInfos: [["J-1", "junction"]],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
React.createElement(HistoryDataPanel, {
|
||||
...props,
|
||||
featureInfos: [["J-1", "junction"]],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
React.createElement(HistoryDataPanel, {
|
||||
...props,
|
||||
featureInfos: [["J-2", "junction"]],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,20 +34,23 @@ import timezone from "dayjs/plugin/timezone";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
||||
import config from "@/config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
import {
|
||||
ElementHistoryResult,
|
||||
ElementHistorySeries,
|
||||
fetchElementHistory,
|
||||
historySeriesKey,
|
||||
historySeriesLabel,
|
||||
TimeSeriesPoint,
|
||||
} from "@/lib/elementHistory";
|
||||
import {
|
||||
FLOW_DISPLAY_UNIT,
|
||||
PRESSURE_DISPLAY_UNIT,
|
||||
} from "@/utils/units";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
/** ISO8601 时间戳 */
|
||||
timestamp: string;
|
||||
/** 每个设备对应的值 */
|
||||
values: Record<string, number | null | undefined>;
|
||||
}
|
||||
|
||||
export interface SCADADataPanelProps {
|
||||
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
|
||||
featureInfos: [string, string][];
|
||||
@@ -73,7 +76,6 @@ type LoadingState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
const MAX_HISTORY_ELEMENTS = 200;
|
||||
const MAX_HISTORY_ELEMENT_ID_LENGTH = 128;
|
||||
const HISTORY_SCADA_CONCURRENCY = 4;
|
||||
|
||||
const panelHeaderActionSx = {
|
||||
color: "primary.contrastText",
|
||||
@@ -83,48 +85,6 @@ const panelHeaderActionSx = {
|
||||
},
|
||||
};
|
||||
|
||||
const buildApiUrl = (
|
||||
path: string,
|
||||
params: Record<string, string | boolean>,
|
||||
) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
return `${config.BACKEND_URL}${path}?${searchParams.toString()}`;
|
||||
};
|
||||
|
||||
const fetchOptionalJson = async (url: string, signal?: AbortSignal) => {
|
||||
const response = await apiFetch(url, { signal });
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`历史数据请求失败: HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const mapWithConcurrency = async <Input, Output>(
|
||||
items: Input[],
|
||||
limit: number,
|
||||
mapper: (item: Input, index: number) => Promise<Output>,
|
||||
): Promise<Output[]> => {
|
||||
const results = new Array<Output>(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(limit, items.length);
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const currentIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/** 从后端 API 获取管网元素的监测、实时模拟和方案模拟数据。 */
|
||||
export const fetchHistoryData = async (
|
||||
featureInfos: [string, string][],
|
||||
@@ -132,9 +92,9 @@ export const fetchHistoryData = async (
|
||||
type: "realtime" | "scheme" | "none",
|
||||
schemeRunId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TimeSeriesPoint[]> => {
|
||||
): Promise<ElementHistoryResult> => {
|
||||
if (featureInfos.length === 0) {
|
||||
return [];
|
||||
return { points: [], series: [] };
|
||||
}
|
||||
if (featureInfos.length > MAX_HISTORY_ELEMENTS) {
|
||||
throw new Error(`历史数据一次最多查询 ${MAX_HISTORY_ELEMENTS} 个管网元素`);
|
||||
@@ -149,285 +109,36 @@ export const fetchHistoryData = async (
|
||||
}
|
||||
|
||||
const uniqueFeatureInfos = Array.from(
|
||||
new Map(featureInfos.map((featureInfo) => [featureInfo[0], featureInfo])).values(),
|
||||
);
|
||||
const featureIds = uniqueFeatureInfos.map(([id]) => id);
|
||||
|
||||
const start_time = dayjs(range.from).toISOString();
|
||||
const end_time = dayjs(range.to).toISOString();
|
||||
|
||||
// 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2
|
||||
const feature_infos = uniqueFeatureInfos
|
||||
.map(([id, type]) => `${id}:${type}`)
|
||||
.join(",");
|
||||
|
||||
const fetchElementScadaData = async (useCleaned: boolean) => {
|
||||
const results = await mapWithConcurrency(
|
||||
featureIds,
|
||||
HISTORY_SCADA_CONCURRENCY,
|
||||
(elementId) =>
|
||||
fetchOptionalJson(
|
||||
buildApiUrl("/api/v1/timeseries/views/element-scada-readings", {
|
||||
element_id: elementId,
|
||||
start_time,
|
||||
end_time,
|
||||
use_cleaned: useCleaned,
|
||||
}),
|
||||
signal,
|
||||
),
|
||||
);
|
||||
return Object.assign({}, ...results.filter(Boolean));
|
||||
};
|
||||
|
||||
const simulationDataUrl = buildApiUrl(
|
||||
"/api/v1/timeseries/views/element-simulations",
|
||||
{ feature_infos, start_time, end_time },
|
||||
new Map(
|
||||
featureInfos.map((featureInfo) => [featureInfo.join(":"), featureInfo]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
if (type === "scheme" && !schemeRunId) {
|
||||
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
|
||||
}
|
||||
|
||||
const schemeSimulationDataUrl = schemeRunId
|
||||
? buildApiUrl("/api/v1/timeseries/views/element-simulations", {
|
||||
feature_infos,
|
||||
start_time,
|
||||
end_time,
|
||||
run_id: schemeRunId,
|
||||
})
|
||||
: null;
|
||||
|
||||
try {
|
||||
if (type === "none") {
|
||||
// 查询清洗值和监测值
|
||||
const [cleanedRes, rawRes] = await Promise.all([
|
||||
fetchElementScadaData(true),
|
||||
fetchElementScadaData(false),
|
||||
]);
|
||||
|
||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||
// 如果清洗数据有值,则不显示原始监测值
|
||||
const rawData =
|
||||
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
|
||||
|
||||
return mergeTimeSeriesData(
|
||||
cleanedData,
|
||||
rawData,
|
||||
featureIds,
|
||||
"clean",
|
||||
"raw"
|
||||
);
|
||||
} else if (type === "scheme") {
|
||||
// 查询策略模拟值、实时模拟值、清洗值和监测值
|
||||
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
|
||||
fetchElementScadaData(true),
|
||||
fetchElementScadaData(false),
|
||||
fetchOptionalJson(simulationDataUrl, signal),
|
||||
fetchOptionalJson(schemeSimulationDataUrl!, signal),
|
||||
]);
|
||||
|
||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||
// 如果清洗数据有值,则不显示原始监测值
|
||||
const rawData =
|
||||
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
|
||||
const simulationData = transformBackendData(simulationRes, featureIds);
|
||||
const schemeSimData = transformBackendData(schemeSimRes, featureIds);
|
||||
|
||||
return mergeMultipleTimeSeriesData(
|
||||
[
|
||||
{ data: cleanedData, suffix: "clean" },
|
||||
{ data: rawData, suffix: "raw" },
|
||||
{ data: simulationData, suffix: "sim" },
|
||||
{ data: schemeSimData, suffix: "scheme_sim" },
|
||||
],
|
||||
featureIds
|
||||
);
|
||||
} else {
|
||||
// realtime: 查询模拟值、清洗值和监测值
|
||||
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
||||
fetchElementScadaData(true),
|
||||
fetchElementScadaData(false),
|
||||
fetchOptionalJson(simulationDataUrl, signal),
|
||||
]);
|
||||
|
||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||
// 如果清洗数据有值,则不显示原始监测值
|
||||
const rawData =
|
||||
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
|
||||
const simulationData = transformBackendData(simulationRes, featureIds);
|
||||
|
||||
// 合并三组数据
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
[cleanedData, rawData, simulationData].forEach((data, index) => {
|
||||
const suffix = ["clean", "raw", "sim"][index];
|
||||
data.forEach((point) => {
|
||||
if (!timeMap.has(point.timestamp)) {
|
||||
timeMap.set(point.timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(point.timestamp)!;
|
||||
featureIds.forEach((deviceId) => {
|
||||
const value = point.values[deviceId];
|
||||
if (value !== undefined) {
|
||||
values[`${deviceId}_${suffix}`] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(
|
||||
([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
})
|
||||
);
|
||||
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
return await fetchElementHistory(
|
||||
uniqueFeatureInfos.map(([id, elementType]) => ({
|
||||
element_id: id.trim(),
|
||||
element_type: elementType.toLowerCase() as "pipe" | "junction",
|
||||
})),
|
||||
range,
|
||||
type === "none"
|
||||
? "observed"
|
||||
: type === "scheme"
|
||||
? "analysis_comparison"
|
||||
: "realtime_comparison",
|
||||
schemeRunId,
|
||||
signal,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
|
||||
console.error("[HistoryDataPanel] 从后端获取数据失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换后端数据格式
|
||||
* 根据实际后端返回的数据结构进行调整
|
||||
*/
|
||||
const transformBackendData = (
|
||||
backendData: any,
|
||||
deviceIds: string[]
|
||||
): TimeSeriesPoint[] => {
|
||||
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
|
||||
if (backendData && !Array.isArray(backendData)) {
|
||||
// 检查是否是设备ID为键的对象格式
|
||||
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
|
||||
|
||||
if (hasDeviceKeys) {
|
||||
// 获取所有时间点的集合
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const deviceData = backendData[deviceId];
|
||||
if (Array.isArray(deviceData)) {
|
||||
deviceData.forEach((item: any) => {
|
||||
const timestamp = item.time || item.timestamp || item._time;
|
||||
if (timestamp) {
|
||||
if (!timeMap.has(timestamp)) {
|
||||
timeMap.set(timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(timestamp)!;
|
||||
values[deviceId] =
|
||||
typeof item.value === "number" ? item.value : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 转换为 TimeSeriesPoint 数组并按时间排序
|
||||
const result = Array.from(timeMap.entries()).map(
|
||||
([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
})
|
||||
);
|
||||
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// 默认返回空数组
|
||||
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 合并两个时间序列数据,为每个设备添加后缀
|
||||
*/
|
||||
const mergeTimeSeriesData = (
|
||||
data1: TimeSeriesPoint[],
|
||||
data2: TimeSeriesPoint[],
|
||||
deviceIds: string[],
|
||||
suffix1: string,
|
||||
suffix2: string
|
||||
): TimeSeriesPoint[] => {
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
const processData = (data: TimeSeriesPoint[], suffix: string) => {
|
||||
data.forEach((point) => {
|
||||
if (!timeMap.has(point.timestamp)) {
|
||||
timeMap.set(point.timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(point.timestamp)!;
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const value = point.values[deviceId];
|
||||
if (value !== undefined) {
|
||||
values[`${deviceId}_${suffix}`] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
processData(data1, suffix1);
|
||||
processData(data2, suffix2);
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}));
|
||||
|
||||
result.sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const mergeMultipleTimeSeriesData = (
|
||||
datasets: Array<{
|
||||
data: TimeSeriesPoint[];
|
||||
suffix: string;
|
||||
}>,
|
||||
deviceIds: string[]
|
||||
): TimeSeriesPoint[] => {
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
datasets.forEach(({ data, suffix }) => {
|
||||
data.forEach((point) => {
|
||||
if (!timeMap.has(point.timestamp)) {
|
||||
timeMap.set(point.timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(point.timestamp)!;
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const value = point.values[deviceId];
|
||||
if (value !== undefined) {
|
||||
values[`${deviceId}_${suffix}`] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}));
|
||||
|
||||
result.sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) =>
|
||||
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
|
||||
|
||||
@@ -443,7 +154,7 @@ const ensureValidRange = (
|
||||
|
||||
const buildDataset = (
|
||||
points: TimeSeriesPoint[],
|
||||
deviceIds: string[],
|
||||
series: ElementHistorySeries[],
|
||||
fractionDigits: number
|
||||
) => {
|
||||
return points.map((point) => {
|
||||
@@ -452,19 +163,17 @@ const buildDataset = (
|
||||
label: formatTimestamp(point.timestamp),
|
||||
};
|
||||
|
||||
deviceIds.forEach((id) => {
|
||||
["clean", "raw", "sim", "scheme_sim"].forEach((suffix) => {
|
||||
const key = `${id}_${suffix}`;
|
||||
const value = point.values[key];
|
||||
if (value !== undefined && value !== null) {
|
||||
entry[key] =
|
||||
typeof value === "number"
|
||||
? Number.isFinite(value)
|
||||
? parseFloat(value.toFixed(fractionDigits))
|
||||
: null
|
||||
: value ?? null;
|
||||
}
|
||||
});
|
||||
series.forEach((metadata) => {
|
||||
const key = historySeriesKey(metadata);
|
||||
const value = point.values[key];
|
||||
if (value !== undefined && value !== null) {
|
||||
entry[key] =
|
||||
typeof value === "number"
|
||||
? Number.isFinite(value)
|
||||
? parseFloat(value.toFixed(fractionDigits))
|
||||
: null
|
||||
: value ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
return entry;
|
||||
@@ -521,11 +230,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSource, setSelectedSource] = useState<
|
||||
"raw" | "clean" | "sim" | "all"
|
||||
>(() => (featureInfos.length === 1 ? "all" : "clean"));
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const requestControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -551,16 +258,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
|
||||
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||
|
||||
const hasDevices = deviceIds.length > 0;
|
||||
const hasData = timeSeries.length > 0;
|
||||
const featureInfosKey = useMemo(
|
||||
() => JSON.stringify(featureInfos),
|
||||
[featureInfos]
|
||||
const featureInfosKey = JSON.stringify(featureInfos);
|
||||
const requestedFeatureInfos = useMemo(
|
||||
() => JSON.parse(featureInfosKey) as [string, string][],
|
||||
[featureInfosKey],
|
||||
);
|
||||
const hasDevices = requestedFeatureInfos.length > 0;
|
||||
const hasData = timeSeries.length > 0;
|
||||
|
||||
const dataset = useMemo(
|
||||
() => buildDataset(timeSeries, deviceIds, fractionDigits),
|
||||
[timeSeries, deviceIds, fractionDigits]
|
||||
() => buildDataset(timeSeries, historySeries, fractionDigits),
|
||||
[timeSeries, historySeries, fractionDigits]
|
||||
);
|
||||
|
||||
const handleFetch = useCallback(
|
||||
@@ -568,6 +276,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
if (!hasDevices) {
|
||||
requestControllerRef.current?.abort();
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
setLoadingState("idle");
|
||||
setError(null);
|
||||
return;
|
||||
@@ -581,7 +290,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
try {
|
||||
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
||||
const result = await fetchHistoryData(
|
||||
featureInfos,
|
||||
requestedFeatureInfos,
|
||||
{
|
||||
from: rangeFrom.toDate(),
|
||||
to: rangeTo.toDate(),
|
||||
@@ -591,7 +300,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
requestController.signal,
|
||||
);
|
||||
if (requestControllerRef.current !== requestController) return;
|
||||
setTimeSeries(result);
|
||||
setTimeSeries(result.points);
|
||||
setHistorySeries(result.series);
|
||||
setLoadingState("success");
|
||||
} catch (err) {
|
||||
if (
|
||||
@@ -604,7 +314,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
setLoadingState("error");
|
||||
}
|
||||
},
|
||||
[featureInfos, hasDevices, normalizedRange, runId, type],
|
||||
[requestedFeatureInfos, hasDevices, normalizedRange, runId, type],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -620,15 +330,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
handleFetch("device-change");
|
||||
} else {
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
}
|
||||
}, [featureInfosKey, handleFetch, hasDevices]);
|
||||
|
||||
// 当设备数量变化时,调整数据源选择
|
||||
useEffect(() => {
|
||||
if (featureInfos.length > 1 && selectedSource === "all") {
|
||||
setSelectedSource("clean");
|
||||
}
|
||||
}, [featureInfos.length, selectedSource]);
|
||||
}, [handleFetch, hasDevices]);
|
||||
|
||||
const columns: GridColDef[] = useMemo(() => {
|
||||
const base: GridColDef[] = [
|
||||
@@ -643,45 +347,33 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const dynamic = (() => {
|
||||
const cols: GridColDef[] = [];
|
||||
|
||||
deviceIds.forEach((id) => {
|
||||
// 为每个设备的每种数据类型创建列
|
||||
const suffixes = [
|
||||
{ key: "clean", name: "清洗值" },
|
||||
{ key: "raw", name: "监测值" },
|
||||
{ key: "sim", name: "实时模拟值" },
|
||||
{ key: "scheme_sim", name: "方案模拟值" },
|
||||
];
|
||||
|
||||
suffixes.forEach(({ key, name }) => {
|
||||
const fieldKey = `${id}_${key}`;
|
||||
// 检查是否有该字段的数据
|
||||
const hasData = dataset.some(
|
||||
(item) => item[fieldKey] !== null && item[fieldKey] !== undefined
|
||||
);
|
||||
|
||||
if (hasData) {
|
||||
cols.push({
|
||||
field: fieldKey,
|
||||
headerName: `${id} (${name})`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
if (value === null || value === undefined) return "--";
|
||||
if (Number.isFinite(Number(value))) {
|
||||
return Number(value).toFixed(fractionDigits);
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
historySeries.forEach((metadata) => {
|
||||
const fieldKey = historySeriesKey(metadata);
|
||||
const hasData = dataset.some(
|
||||
(item) => item[fieldKey] !== null && item[fieldKey] !== undefined,
|
||||
);
|
||||
if (hasData) {
|
||||
cols.push({
|
||||
field: fieldKey,
|
||||
headerName: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`,
|
||||
minWidth: 180,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
if (value === null || value === undefined) return "--";
|
||||
if (Number.isFinite(Number(value))) {
|
||||
return Number(value).toFixed(fractionDigits);
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return cols;
|
||||
})();
|
||||
|
||||
return [...base, ...dynamic];
|
||||
}, [deviceIds, fractionDigits, dataset]);
|
||||
}, [historySeries, fractionDigits, dataset]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
@@ -733,91 +425,57 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
];
|
||||
|
||||
const xData = dataset.map((item) => item.label);
|
||||
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
|
||||
const hasPressureSeries = historySeries.some(
|
||||
(item) => item.metric === "pressure",
|
||||
);
|
||||
|
||||
const getSeries = () => {
|
||||
return deviceIds.flatMap((id, index) => {
|
||||
const series = [];
|
||||
["clean", "raw", "sim", "scheme_sim"].forEach((suffix, sIndex) => {
|
||||
const key = `${id}_${suffix}`;
|
||||
const hasData = dataset.some(
|
||||
(item) => item[key] !== null && item[key] !== undefined
|
||||
);
|
||||
if (hasData) {
|
||||
const displayName =
|
||||
suffix === "clean"
|
||||
? "清洗值"
|
||||
: suffix === "raw"
|
||||
? "监测值"
|
||||
: suffix === "sim"
|
||||
? "实时模拟"
|
||||
: "方案模拟";
|
||||
|
||||
series.push({
|
||||
name: `${id} (${displayName})`,
|
||||
type: "line",
|
||||
symbol:
|
||||
suffix === "clean"
|
||||
? "circle"
|
||||
: suffix === "raw"
|
||||
return historySeries.flatMap((metadata, index) => {
|
||||
const key = historySeriesKey(metadata);
|
||||
const hasSeriesData = dataset.some(
|
||||
(item) => item[key] !== null && item[key] !== undefined,
|
||||
);
|
||||
if (!hasSeriesData) return [];
|
||||
const isObserved = metadata.source.startsWith("scada_");
|
||||
return [
|
||||
{
|
||||
name: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`,
|
||||
type: "line",
|
||||
yAxisIndex:
|
||||
metadata.metric === "pressure" && hasFlowSeries ? 1 : 0,
|
||||
symbol:
|
||||
metadata.source === "scada_cleaned"
|
||||
? "circle"
|
||||
: metadata.source === "scada_raw"
|
||||
? "diamond"
|
||||
: "none",
|
||||
symbolSize: suffix === "clean" || suffix === "raw" ? 7 : 0,
|
||||
showSymbol: suffix === "clean" || suffix === "raw",
|
||||
sampling: "lttb",
|
||||
connectNulls: suffix !== "clean" && suffix !== "raw",
|
||||
itemStyle: {
|
||||
color: colors[(index * 4 + sIndex) % colors.length],
|
||||
},
|
||||
data: dataset.map((item) => item[key]),
|
||||
lineStyle:
|
||||
suffix === "clean" || suffix === "raw"
|
||||
? { width: 0 }
|
||||
: undefined,
|
||||
areaStyle:
|
||||
suffix === "clean" || suffix === "raw"
|
||||
? undefined
|
||||
: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: colors[(index * 4 + sIndex) % colors.length],
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: "rgba(255, 255, 255, 0)",
|
||||
},
|
||||
]),
|
||||
opacity: 0.3,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
// 如果没有任何数据,则使用fallback
|
||||
if (series.length === 0) {
|
||||
series.push({
|
||||
name: id,
|
||||
type: "line",
|
||||
symbol: "none",
|
||||
symbolSize: isObserved ? 7 : 0,
|
||||
showSymbol: isObserved,
|
||||
sampling: "lttb",
|
||||
connectNulls: true,
|
||||
itemStyle: { color: colors[index % colors.length] },
|
||||
data: dataset.map((item) => item[id]),
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: colors[index % colors.length],
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: "rgba(255, 255, 255, 0)",
|
||||
},
|
||||
]),
|
||||
opacity: 0.3,
|
||||
connectNulls: !isObserved,
|
||||
itemStyle: {
|
||||
color: colors[index % colors.length],
|
||||
},
|
||||
});
|
||||
}
|
||||
return series;
|
||||
data: dataset.map((item) => item[key]),
|
||||
lineStyle: isObserved ? { width: 0 } : undefined,
|
||||
areaStyle: isObserved
|
||||
? undefined
|
||||
: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: colors[index % colors.length],
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: "rgba(255, 255, 255, 0)",
|
||||
},
|
||||
]),
|
||||
opacity: 0.3,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
const option = {
|
||||
@@ -853,10 +511,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
boundaryGap: false,
|
||||
data: xData,
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
scale: true,
|
||||
},
|
||||
yAxis: [
|
||||
...(hasFlowSeries
|
||||
? [
|
||||
{
|
||||
type: "value",
|
||||
scale: true,
|
||||
name: `流量 (${FLOW_DISPLAY_UNIT})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasPressureSeries
|
||||
? [
|
||||
{
|
||||
type: "value",
|
||||
scale: true,
|
||||
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: "inside",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type Feature from "ol/Feature";
|
||||
|
||||
import PropertyPanel from "./PropertyPanel";
|
||||
import { buildFeatureProperties } from "./toolbarFeatureHelpers";
|
||||
|
||||
jest.mock("react-draggable", () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
jest.mock("ol/Feature", () => ({
|
||||
__esModule: true,
|
||||
default: class Feature {},
|
||||
}));
|
||||
|
||||
describe("PropertyPanel junction demands", () => {
|
||||
it("shows a readable demand table instead of raw GeoServer JSON", () => {
|
||||
const feature = {
|
||||
getId: () => "junctions.n_001",
|
||||
getProperties: () => ({
|
||||
id: "n_001",
|
||||
elevation: 42,
|
||||
base_demand: 0,
|
||||
demands:
|
||||
'[{"category":null,"pattern_id":"PAT_BASE","base_demand":0,"sequence_no":0}]',
|
||||
}),
|
||||
} as unknown as Feature;
|
||||
const panelData = buildFeatureProperties(feature, {});
|
||||
|
||||
render(<PropertyPanel {...panelData} onClose={jest.fn()} />);
|
||||
|
||||
expect(screen.getByText("PAT_BASE")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("0.000 m³/h")).toHaveLength(2);
|
||||
expect(screen.getByText("未分类")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/\[{"category"/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -835,6 +835,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
onSave: handleValveSettingSave,
|
||||
}
|
||||
: undefined,
|
||||
data?.resultUnits,
|
||||
),
|
||||
[
|
||||
selectedFeature,
|
||||
@@ -848,6 +849,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
isValveStatusLoading,
|
||||
isValveStatusSaving,
|
||||
isValvePropertiesLoading,
|
||||
data?.resultUnits,
|
||||
isValveSettingSaving,
|
||||
handleValveStatusSave,
|
||||
handleValveSettingSave,
|
||||
|
||||
@@ -173,6 +173,52 @@ describe("getSimulationElementType", () => {
|
||||
});
|
||||
|
||||
describe("buildFeatureProperties simulation values by hydraulic type", () => {
|
||||
it("renders GeoServer demand JSON as a readable unit-aware table", () => {
|
||||
const junction = createFeature("junctions", "n_001", {
|
||||
base_demand: 0,
|
||||
demands:
|
||||
'[{"category":null,"pattern_id":"PAT_BASE","base_demand":0,"sequence_no":0}]',
|
||||
});
|
||||
|
||||
const result = buildFeatureProperties(
|
||||
junction,
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
{ flow: "LPS", pressure: "METERS", velocity: "m/s" },
|
||||
);
|
||||
|
||||
expect(result.properties).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
type: "table",
|
||||
label: "需水配置",
|
||||
columns: ["序号", "基础需水量", "模式", "类别"],
|
||||
rows: [[1, "0.000 m³/h", "PAT_BASE", "未分类"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the selected project's MLD model unit for demand values", () => {
|
||||
const junction = createFeature("junctions", "J1", { base_demand: 1 });
|
||||
|
||||
const result = buildFeatureProperties(
|
||||
junction,
|
||||
{ actual_demand: 2, pressure: 18 },
|
||||
undefined,
|
||||
undefined,
|
||||
{ flow: "MLD", pressure: "METERS", velocity: "m/s" },
|
||||
);
|
||||
|
||||
expect(result.properties).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: "基本需水量", value: "41.667" }),
|
||||
expect.objectContaining({ label: "实际需水量", value: "83.333" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows link simulation results for a point-rendered pump", () => {
|
||||
const pump = createFeature("pumps", "P1", {
|
||||
node1: "J1",
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import Feature from "ol/Feature";
|
||||
|
||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||
import {
|
||||
DEFAULT_NETWORK_RESULT_UNITS,
|
||||
FLOW_DISPLAY_UNIT,
|
||||
PRESSURE_DISPLAY_UNIT,
|
||||
type NetworkResultUnits,
|
||||
VELOCITY_DISPLAY_UNIT,
|
||||
toModelDisplayValue,
|
||||
} from "@utils/units";
|
||||
import {
|
||||
getValveSettingHelperText,
|
||||
VALVE_STATUS_OPTIONS,
|
||||
@@ -163,11 +170,70 @@ export const inferHistoryFeatureInfos = (
|
||||
})
|
||||
.filter(Boolean) as [string, string][];
|
||||
|
||||
type DemandEntry = {
|
||||
sequence_no?: unknown;
|
||||
base_demand?: unknown;
|
||||
demand?: unknown;
|
||||
pattern_id?: unknown;
|
||||
pattern?: unknown;
|
||||
category?: unknown;
|
||||
};
|
||||
|
||||
const parseDemandEntries = (value: unknown): DemandEntry[] => {
|
||||
let parsed = value;
|
||||
if (typeof parsed === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(entry): entry is DemandEntry =>
|
||||
typeof entry === "object" && entry !== null,
|
||||
);
|
||||
};
|
||||
|
||||
const buildDemandProperty = (
|
||||
value: unknown,
|
||||
resultUnits: NetworkResultUnits,
|
||||
): ToolbarPropertyItem => {
|
||||
const entries = parseDemandEntries(value);
|
||||
if (entries.length === 0) {
|
||||
return { label: "需水配置", value: "未配置" };
|
||||
}
|
||||
|
||||
return {
|
||||
type: "table",
|
||||
label: "需水配置",
|
||||
columns: ["序号", "基础需水量", "模式", "类别"],
|
||||
rows: entries.map((entry, index) => {
|
||||
const sourceDemand = Number(entry.base_demand ?? entry.demand);
|
||||
const demand = Number.isFinite(sourceDemand)
|
||||
? `${toModelDisplayValue(
|
||||
sourceDemand,
|
||||
"base_demand",
|
||||
resultUnits,
|
||||
).toFixed(3)} ${FLOW_DISPLAY_UNIT}`
|
||||
: "未设置";
|
||||
const sequence = Number(entry.sequence_no);
|
||||
return [
|
||||
Number.isInteger(sequence) ? sequence + 1 : index + 1,
|
||||
demand,
|
||||
String(entry.pattern_id ?? entry.pattern ?? "无"),
|
||||
String(entry.category ?? "未分类"),
|
||||
];
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildFeatureProperties = (
|
||||
highlightFeature: Feature | undefined,
|
||||
computedProperties: Record<string, any>,
|
||||
valveStatus?: ValveStatusPropertyOptions,
|
||||
valveSetting?: ValveSettingPropertyOptions,
|
||||
resultUnits: NetworkResultUnits = DEFAULT_NETWORK_RESULT_UNITS,
|
||||
): ToolbarPropertyPanelData => {
|
||||
if (!highlightFeature) return {};
|
||||
|
||||
@@ -182,12 +248,12 @@ export const buildFeatureProperties = (
|
||||
{ key: "reaction", label: "反应", unit: "1/d" },
|
||||
{ key: "setting", label: "设置", unit: "" },
|
||||
{ key: "status", label: "状态", unit: "" },
|
||||
{ key: "velocity", label: "流速", unit: "m/s" },
|
||||
{ key: "velocity", label: "流速", unit: VELOCITY_DISPLAY_UNIT },
|
||||
];
|
||||
const nodeComputedFields = [
|
||||
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
|
||||
{ key: "total_head", label: "水头", unit: "m" },
|
||||
{ key: "pressure", label: "压力", unit: "m" },
|
||||
{ key: "pressure", label: "压力", unit: PRESSURE_DISPLAY_UNIT },
|
||||
{ key: "quality", label: "水质", unit: "mg/L" },
|
||||
];
|
||||
|
||||
@@ -200,7 +266,10 @@ export const buildFeatureProperties = (
|
||||
|
||||
let value = computedProperties[key];
|
||||
if (key === "flow" && value !== undefined) {
|
||||
value = toM3h(value, "lps");
|
||||
value = toModelDisplayValue(value, key, resultUnits);
|
||||
}
|
||||
if (key === "velocity" && value !== undefined) {
|
||||
value = toModelDisplayValue(value, key, resultUnits);
|
||||
}
|
||||
if (
|
||||
key === "unit_headloss" &&
|
||||
@@ -226,7 +295,10 @@ export const buildFeatureProperties = (
|
||||
|
||||
let value = computedProperties[key];
|
||||
if (key === "actual_demand") {
|
||||
value = toM3h(value, "lps");
|
||||
value = toModelDisplayValue(value, key, resultUnits);
|
||||
}
|
||||
if (key === "pressure") {
|
||||
value = toModelDisplayValue(value, key, resultUnits);
|
||||
}
|
||||
result.properties?.push({
|
||||
label,
|
||||
@@ -273,14 +345,15 @@ export const buildFeatureProperties = (
|
||||
{
|
||||
label: "基本需水量",
|
||||
value: Number.isFinite(Number(properties.base_demand))
|
||||
? toM3h(Number(properties.base_demand), "lps").toFixed(3)
|
||||
? toModelDisplayValue(
|
||||
Number(properties.base_demand),
|
||||
"base_demand",
|
||||
resultUnits,
|
||||
).toFixed(3)
|
||||
: properties.base_demand,
|
||||
unit: "m³/h",
|
||||
},
|
||||
{
|
||||
label: "需水配置",
|
||||
value: properties.demands,
|
||||
unit: FLOW_DISPLAY_UNIT,
|
||||
},
|
||||
buildDemandProperty(properties.demands, resultUnits),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import type { FlatStyleLike } from "ol/style/flat";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { config } from "@/config/config";
|
||||
import { isLpsFlowProperty, toM3h } from "@utils/units";
|
||||
import {
|
||||
type NetworkResultUnits,
|
||||
toModelDisplayValue,
|
||||
} from "@utils/units";
|
||||
|
||||
import { LayerStyleController } from "../layerStyleController";
|
||||
import { useData, useMap } from "../MapComponent";
|
||||
@@ -56,11 +59,15 @@ const configsEqual = (left?: StyleConfig, right?: StyleConfig) =>
|
||||
const hasSameTemplate = (left: StyleConfig, right: StyleConfig) =>
|
||||
left.property === right.property && left.segments === right.segments;
|
||||
|
||||
const normalizeComputedStyleValue = (property: string, value: unknown) => {
|
||||
const normalizeComputedStyleValue = (
|
||||
property: string,
|
||||
value: unknown,
|
||||
resultUnits?: NetworkResultUnits,
|
||||
) => {
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isFinite(numericValue)) return Number.NaN;
|
||||
const displayValue = isLpsFlowProperty(property)
|
||||
? toM3h(numericValue, "lps")
|
||||
const displayValue = resultUnits
|
||||
? toModelDisplayValue(numericValue, property, resultUnits)
|
||||
: numericValue;
|
||||
return property === "flow" ? Math.abs(displayValue) : displayValue;
|
||||
};
|
||||
@@ -140,6 +147,7 @@ export const useStyleEditor = ({
|
||||
const elevationRange = data?.elevationRange;
|
||||
const diameterRange = data?.diameterRange;
|
||||
const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0;
|
||||
const resultUnits = data?.resultUnits;
|
||||
const setJunctionText = data?.setJunctionText;
|
||||
const setPipeText = data?.setPipeText;
|
||||
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
|
||||
@@ -311,10 +319,18 @@ export const useStyleEditor = ({
|
||||
}
|
||||
const records = layerId === "junctions" ? currentJunctionCalData : currentPipeCalData;
|
||||
return (records || [])
|
||||
.map((item: any) => normalizeComputedStyleValue(property, item.value))
|
||||
.map((item: any) =>
|
||||
normalizeComputedStyleValue(property, item.value, resultUnits),
|
||||
)
|
||||
.filter(Number.isFinite);
|
||||
},
|
||||
[currentJunctionCalData, currentPipeCalData, diameterRange, elevationRange],
|
||||
[
|
||||
currentJunctionCalData,
|
||||
currentPipeCalData,
|
||||
diameterRange,
|
||||
elevationRange,
|
||||
resultUnits,
|
||||
],
|
||||
);
|
||||
|
||||
const syncAuxiliaryLayers = useCallback(
|
||||
@@ -425,7 +441,11 @@ export const useStyleEditor = ({
|
||||
records.forEach((record: any) => {
|
||||
const id = record.ID ?? record.id;
|
||||
if (id === undefined || id === null) return;
|
||||
const value = normalizeComputedStyleValue(nextConfig.property, record.value);
|
||||
const value = normalizeComputedStyleValue(
|
||||
nextConfig.property,
|
||||
record.value,
|
||||
resultUnits,
|
||||
);
|
||||
if (Number.isFinite(value)) stateById.set(String(id), value);
|
||||
});
|
||||
const committed = await controller.applyRuntime(options, stateById);
|
||||
@@ -465,6 +485,7 @@ export const useStyleEditor = ({
|
||||
getDataForMap,
|
||||
getMapKey,
|
||||
getRenderLayersById,
|
||||
resultUnits,
|
||||
syncContoursForStyle,
|
||||
upsertLayerStyleState,
|
||||
],
|
||||
|
||||
@@ -23,7 +23,11 @@ import { TextLayer } from "@deck.gl/layers";
|
||||
import { TripsLayer } from "@deck.gl/geo-layers";
|
||||
import { CollisionFilterExtension } from "@deck.gl/extensions";
|
||||
import { ContourLayer } from "deck.gl";
|
||||
import { isLpsFlowProperty, toM3h } from "@utils/units";
|
||||
import {
|
||||
type NetworkResultUnits,
|
||||
toModelDisplayValue,
|
||||
} from "@utils/units";
|
||||
import { useNetworkResultUnits } from "@/hooks/useNetworkResultUnits";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
cleanupTransientMapResources,
|
||||
@@ -125,6 +129,7 @@ interface DataContextType {
|
||||
elevationRange?: [number, number];
|
||||
forceStyleAutoApplyVersion?: number;
|
||||
setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>;
|
||||
resultUnits?: NetworkResultUnits;
|
||||
}
|
||||
|
||||
// 跨组件传递
|
||||
@@ -138,14 +143,13 @@ const mergeJunctionValues = (
|
||||
features: any[],
|
||||
records: any[],
|
||||
property: string,
|
||||
resultUnits: NetworkResultUnits,
|
||||
) => {
|
||||
const recordsById = indexCalculationRecords(records);
|
||||
return features.map((feature) => {
|
||||
const record = recordsById.get(String(feature.id));
|
||||
if (!record) return feature;
|
||||
const value = isLpsFlowProperty(property)
|
||||
? toM3h(record.value, "lps")
|
||||
: record.value;
|
||||
const value = toModelDisplayValue(record.value, property, resultUnits);
|
||||
return { ...feature, [property]: value };
|
||||
});
|
||||
};
|
||||
@@ -154,13 +158,14 @@ const mergePipeValues = (
|
||||
features: any[],
|
||||
records: any[],
|
||||
property: string,
|
||||
resultUnits: NetworkResultUnits,
|
||||
) => {
|
||||
const recordsById = indexCalculationRecords(records);
|
||||
const isFlow = property === "flow";
|
||||
return features.map((feature) => {
|
||||
const record = recordsById.get(String(feature.id));
|
||||
if (!record) return feature;
|
||||
const value = isFlow ? toM3h(record.value, "lps") : record.value;
|
||||
const value = toModelDisplayValue(record.value, property, resultUnits);
|
||||
const reverseFlow = isFlow && record.value < 0;
|
||||
return {
|
||||
...feature,
|
||||
@@ -202,6 +207,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const MAP_URL = config.MAP_URL;
|
||||
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
|
||||
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||
const resultUnits = useNetworkResultUnits(project?.networkName);
|
||||
|
||||
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
@@ -288,37 +294,52 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
// 实时合并计算结果到基础地理数据中
|
||||
const mergedJunctionData = useMemo(
|
||||
() =>
|
||||
mergeJunctionValues(junctionData, currentJunctionCalData, junctionText),
|
||||
[junctionData, currentJunctionCalData, junctionText],
|
||||
mergeJunctionValues(
|
||||
junctionData,
|
||||
currentJunctionCalData,
|
||||
junctionText,
|
||||
resultUnits,
|
||||
),
|
||||
[junctionData, currentJunctionCalData, junctionText, resultUnits],
|
||||
);
|
||||
const mergedPipeData = useMemo(
|
||||
() => mergePipeValues(pipeData, currentPipeCalData, pipeText),
|
||||
[pipeData, currentPipeCalData, pipeText],
|
||||
() => mergePipeValues(pipeData, currentPipeCalData, pipeText, resultUnits),
|
||||
[pipeData, currentPipeCalData, pipeText, resultUnits],
|
||||
);
|
||||
const mergedPipeFragments = useMemo(
|
||||
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText),
|
||||
[pipeFragments, currentPipeCalData, pipeText],
|
||||
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText, resultUnits),
|
||||
[pipeFragments, currentPipeCalData, pipeText, resultUnits],
|
||||
);
|
||||
const mergedCompareJunctionData = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText)
|
||||
? mergeJunctionValues(
|
||||
junctionData,
|
||||
compareJunctionCalData,
|
||||
junctionText,
|
||||
resultUnits,
|
||||
)
|
||||
: [],
|
||||
[isCompareMode, junctionData, compareJunctionCalData, junctionText],
|
||||
[isCompareMode, junctionData, compareJunctionCalData, junctionText, resultUnits],
|
||||
);
|
||||
const mergedComparePipeData = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergePipeValues(pipeData, comparePipeCalData, pipeText)
|
||||
? mergePipeValues(pipeData, comparePipeCalData, pipeText, resultUnits)
|
||||
: [],
|
||||
[isCompareMode, pipeData, comparePipeCalData, pipeText],
|
||||
[isCompareMode, pipeData, comparePipeCalData, pipeText, resultUnits],
|
||||
);
|
||||
const mergedComparePipeFragments = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergePipeValues(pipeFragments, comparePipeCalData, pipeText)
|
||||
? mergePipeValues(
|
||||
pipeFragments,
|
||||
comparePipeCalData,
|
||||
pipeText,
|
||||
resultUnits,
|
||||
)
|
||||
: [],
|
||||
[isCompareMode, pipeFragments, comparePipeCalData, pipeText],
|
||||
[isCompareMode, pipeFragments, comparePipeCalData, pipeText, resultUnits],
|
||||
);
|
||||
|
||||
const [diameterRange, setDiameterRange] = useState<
|
||||
@@ -1163,6 +1184,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
elevationRange,
|
||||
forceStyleAutoApplyVersion,
|
||||
setForceStyleAutoApplyVersion,
|
||||
resultUnits,
|
||||
}}
|
||||
>
|
||||
<MapContext.Provider value={map}>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { ThreeDimensionalControls } from "./ThreeDimensionalControls";
|
||||
import type { SceneRuntimeState } from "./sceneProtocol";
|
||||
|
||||
const state: SceneRuntimeState = {
|
||||
mode: "network",
|
||||
status: "管网实体模型",
|
||||
contextVisible: true,
|
||||
roofVisible: true,
|
||||
displayMode: "global",
|
||||
style: {
|
||||
scale: 6,
|
||||
mode: "uniform",
|
||||
color: "#098ed0",
|
||||
missingColor: "#89949d",
|
||||
lowColor: "#2b83ba",
|
||||
highColor: "#e66c37",
|
||||
opacity: 1,
|
||||
roughness: 0.3,
|
||||
metalness: 0.22,
|
||||
nodes: true,
|
||||
direction: "none",
|
||||
arrowColor: "#f2b447",
|
||||
autoRange: true,
|
||||
min: 0,
|
||||
max: 3,
|
||||
},
|
||||
styleSummary: {},
|
||||
appearance: {
|
||||
preset: "day",
|
||||
exposure: 0.95,
|
||||
contextOpacity: 0.4,
|
||||
shadows: true,
|
||||
effects: true,
|
||||
quality: "standard",
|
||||
},
|
||||
camera: {
|
||||
active: "overview",
|
||||
note: "供水总览",
|
||||
views: [
|
||||
{
|
||||
id: "overview",
|
||||
label: "供水总览",
|
||||
mode: "network",
|
||||
saved: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
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", () => {
|
||||
const onCommand = jest.fn();
|
||||
const onTimelineOpenChange = jest.fn();
|
||||
render(
|
||||
<ThreeDimensionalControls
|
||||
open
|
||||
activeTab="scene"
|
||||
ready
|
||||
state={state}
|
||||
selection={null}
|
||||
timelineOpen
|
||||
onOpenChange={jest.fn()}
|
||||
onTimelineOpenChange={onTimelineOpenChange}
|
||||
onTabChange={jest.fn()}
|
||||
onCommand={onCommand}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<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", () => {
|
||||
const onCommand = jest.fn();
|
||||
render(
|
||||
<ThreeDimensionalControls
|
||||
open
|
||||
activeTab="properties"
|
||||
ready
|
||||
state={state}
|
||||
selection={{
|
||||
assetId: "inp:node:J-1",
|
||||
elementId: "J-1",
|
||||
kind: "节点",
|
||||
title: "节点 · J-1",
|
||||
sections: [
|
||||
{
|
||||
title: "运行结果",
|
||||
fields: [{ label: "压力", value: "31.200 mH₂O" }],
|
||||
},
|
||||
],
|
||||
neighbors: [{ id: "P-1", label: "P-1" }],
|
||||
}}
|
||||
timelineOpen
|
||||
onOpenChange={jest.fn()}
|
||||
onTimelineOpenChange={jest.fn()}
|
||||
onTabChange={jest.fn()}
|
||||
onCommand={onCommand}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("节点 · J-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("31.200 mH₂O")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "P-1" }));
|
||||
expect(onCommand).toHaveBeenCalledWith({ name: "select-asset", assetId: "P-1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,616 @@
|
||||
"use client";
|
||||
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Select from "@mui/material/Select";
|
||||
import clsx from "clsx";
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import {
|
||||
FiBox,
|
||||
FiCamera,
|
||||
FiCheck,
|
||||
FiChevronDown,
|
||||
FiChevronRight,
|
||||
FiClock,
|
||||
FiCrosshair,
|
||||
FiDroplet,
|
||||
FiHome,
|
||||
FiInfo,
|
||||
FiLayers,
|
||||
FiMaximize,
|
||||
FiPlus,
|
||||
FiRotateCcw,
|
||||
FiSliders,
|
||||
FiTrash2,
|
||||
FiX,
|
||||
} from "react-icons/fi";
|
||||
|
||||
import type {
|
||||
SceneAssetSelection,
|
||||
SceneCommand,
|
||||
SceneMode,
|
||||
SceneRuntimeState,
|
||||
} from "./sceneProtocol";
|
||||
|
||||
const sceneModes: Array<{ mode: SceneMode; label: string }> = [
|
||||
{ mode: "network", label: "供水管网" },
|
||||
{ mode: "hydraulic", label: "泵组与管网" },
|
||||
{ mode: "map", label: "站区轻量版" },
|
||||
{ mode: "detail", label: "站房精细版" },
|
||||
{ mode: "pump", label: "CAD 泵房参考" },
|
||||
{ mode: "meters", label: "设备样件" },
|
||||
];
|
||||
|
||||
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";
|
||||
|
||||
export type ThreeDimensionalControlsProps = {
|
||||
open: boolean;
|
||||
activeTab: ControlTab;
|
||||
ready: boolean;
|
||||
state: SceneRuntimeState;
|
||||
selection: SceneAssetSelection | null;
|
||||
timelineOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onTimelineOpenChange: (open: boolean) => void;
|
||||
onTabChange: (tab: ControlTab) => void;
|
||||
onCommand: (command: SceneCommand) => void;
|
||||
};
|
||||
|
||||
export function ThreeDimensionalControls({
|
||||
open,
|
||||
activeTab,
|
||||
ready,
|
||||
state,
|
||||
selection,
|
||||
timelineOpen,
|
||||
onOpenChange,
|
||||
onTimelineOpenChange,
|
||||
onTabChange,
|
||||
onCommand,
|
||||
}: ThreeDimensionalControlsProps) {
|
||||
const [cameraLabel, setCameraLabel] = useState("");
|
||||
|
||||
const selectTab = (tab: ControlTab) => {
|
||||
if (open && activeTab === tab) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
onTabChange(tab);
|
||||
onOpenChange(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
aria-label="三维场景快捷工具"
|
||||
className={clsx(
|
||||
glassSurface,
|
||||
"absolute left-2 top-2 z-20 flex max-w-[calc(100%-1rem)] items-center gap-0.5 rounded-xl p-1 opacity-90 transition-opacity duration-200 hover:opacity-100 md:left-4 md:top-4 md:flex-col",
|
||||
)}
|
||||
>
|
||||
<ToolButton
|
||||
label="场景与视角"
|
||||
active={open && activeTab === "scene"}
|
||||
disabled={!ready}
|
||||
onClick={() => selectTab("scene")}
|
||||
>
|
||||
<FiBox />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="管网样式"
|
||||
active={open && activeTab === "style"}
|
||||
disabled={!ready}
|
||||
onClick={() => selectTab("style")}
|
||||
>
|
||||
<FiDroplet />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="时间轴"
|
||||
active={timelineOpen}
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
onTimelineOpenChange(!timelineOpen);
|
||||
if (!timelineOpen && window.matchMedia("(max-width: 767px)").matches) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FiClock />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="构件属性"
|
||||
active={open && activeTab === "properties"}
|
||||
disabled={!ready}
|
||||
onClick={() => selectTab("properties")}
|
||||
>
|
||||
<FiInfo />
|
||||
</ToolButton>
|
||||
<span aria-hidden="true" className="mx-1 h-6 w-px bg-slate-300/70 md:my-1 md:h-px md:w-6" />
|
||||
<ToolButton
|
||||
label="供水总览"
|
||||
disabled={!ready}
|
||||
onClick={() => onCommand({ name: "visit-camera", viewId: "overview" })}
|
||||
>
|
||||
<FiHome />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="管网俯视"
|
||||
disabled={!ready}
|
||||
onClick={() => onCommand({ name: "visit-camera", viewId: "plan" })}
|
||||
>
|
||||
<FiLayers />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="适应视图"
|
||||
disabled={!ready}
|
||||
onClick={() => onCommand({ name: "fit-view" })}
|
||||
>
|
||||
<FiMaximize />
|
||||
</ToolButton>
|
||||
</nav>
|
||||
|
||||
{open && (
|
||||
<aside
|
||||
aria-label="三维场景控制面板"
|
||||
className={clsx(
|
||||
glassSurface,
|
||||
"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">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-blue-600 text-white shadow-sm shadow-blue-700/20">
|
||||
<FiSliders aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-slate-900">三维场景工具</p>
|
||||
<p className="truncate text-[11px] text-slate-500">{state.status}</p>
|
||||
</div>
|
||||
<IconButton label="收起三维场景工具" onClick={() => onOpenChange(false)}>
|
||||
<FiX />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div role="tablist" aria-label="三维场景工具分类" className="grid grid-cols-3 border-b border-white/45 bg-sky-50/10 px-2 pt-1">
|
||||
<TabButton active={activeTab === "scene"} onClick={() => onTabChange("scene")} icon={<FiLayers />}>场景</TabButton>
|
||||
<TabButton active={activeTab === "style"} onClick={() => onTabChange("style")} icon={<FiDroplet />}>样式</TabButton>
|
||||
<TabButton active={activeTab === "properties"} onClick={() => onTabChange("properties")} icon={<FiInfo />}>属性</TabButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-disabled={!ready}
|
||||
className={clsx(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
{activeTab === "scene" && (
|
||||
<div className="space-y-6">
|
||||
<ControlSection title="显示内容">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sceneModes.map((item) => (
|
||||
<button
|
||||
key={item.mode}
|
||||
type="button"
|
||||
onClick={() => onCommand({ name: "set-mode", mode: item.mode })}
|
||||
className={clsx(
|
||||
"min-h-10 rounded-lg border px-2 text-sm font-medium transition active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40",
|
||||
state.mode === item.mode
|
||||
? "border-blue-600 bg-blue-600 text-white shadow-sm shadow-blue-700/20"
|
||||
: "border-slate-300/70 bg-white/35 text-slate-700 hover:border-blue-400 hover:bg-blue-50/70 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="观察位置" description={state.camera.note}>
|
||||
<div className="space-y-1">
|
||||
{state.camera.views.map((view) => (
|
||||
<div key={view.id} className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCommand({ name: "visit-camera", viewId: view.id })}
|
||||
className={clsx(
|
||||
"flex min-h-10 min-w-0 flex-1 items-center gap-2 rounded-lg px-3 text-left text-sm transition active:scale-[0.99] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40",
|
||||
state.camera.active === view.id
|
||||
? "bg-blue-600 text-white shadow-sm"
|
||||
: "text-slate-700 hover:bg-white/55 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
<FiCamera className="shrink-0" />
|
||||
<span className="truncate">{view.label}</span>
|
||||
<FiChevronRight className="ml-auto shrink-0 opacity-50" />
|
||||
</button>
|
||||
{view.saved && (
|
||||
<IconButton
|
||||
label={`移除视角 ${view.label}`}
|
||||
onClick={() => onCommand({ name: "remove-camera", viewId: view.id })}
|
||||
>
|
||||
<FiTrash2 />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
aria-label="当前视角名称"
|
||||
className={fieldClass}
|
||||
maxLength={24}
|
||||
placeholder="当前视角名称"
|
||||
value={cameraLabel}
|
||||
onChange={(event) => setCameraLabel(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!cameraLabel.trim()}
|
||||
onClick={() => {
|
||||
onCommand({ name: "save-camera", label: cameraLabel.trim() });
|
||||
setCameraLabel("");
|
||||
}}
|
||||
className="inline-flex min-h-10 shrink-0 items-center gap-1.5 rounded-lg bg-blue-600 px-3 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<FiPlus /> 保存
|
||||
</button>
|
||||
</div>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="场景显示">
|
||||
<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" })} />
|
||||
<SelectField
|
||||
label="管网展示比例"
|
||||
value={state.displayMode}
|
||||
onChange={(value) => onCommand({ name: "set-display-mode", mode: value as "global" | "coordinated" })}
|
||||
options={[{ value: "global", label: "全局比例" }, { value: "coordinated", label: "泵房协调比例" }]}
|
||||
/>
|
||||
</ControlSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "style" && (
|
||||
<div className="space-y-6">
|
||||
<ControlSection title="管网表达">
|
||||
<SelectField
|
||||
label="着色方式"
|
||||
value={state.style.mode}
|
||||
onChange={(value) => onCommand({ name: "set-style", patch: { mode: value as SceneRuntimeState["style"]["mode"] } })}
|
||||
options={[
|
||||
{ value: "uniform", label: "统一颜色" },
|
||||
{ value: "pressure", label: "压力" },
|
||||
{ value: "velocity", label: "流速" },
|
||||
{ value: "direction", label: "流向" },
|
||||
]}
|
||||
/>
|
||||
<LabeledSlider label={`管径倍率 ${state.style.scale}×`} value={state.style.scale} min={1} max={12} step={1} onChange={(value) => onCommand({ name: "set-style", patch: { scale: value } })} />
|
||||
<LabeledSlider label={`不透明度 ${Math.round(state.style.opacity * 100)}%`} value={state.style.opacity} min={0.15} max={1} step={0.05} onChange={(value) => onCommand({ name: "set-style", patch: { opacity: value } })} />
|
||||
<LabeledSlider label={`表面粗糙度 ${state.style.roughness.toFixed(2)}`} value={state.style.roughness} min={0.05} max={1} step={0.05} onChange={(value) => onCommand({ name: "set-style", patch: { roughness: value } })} />
|
||||
<SwitchRow label="显示连接节点" checked={state.style.nodes} onChange={(checked) => onCommand({ name: "set-style", patch: { nodes: checked } })} />
|
||||
<SelectField
|
||||
label="方向箭头"
|
||||
value={state.style.direction}
|
||||
onChange={(value) => onCommand({ name: "set-style", patch: { direction: value as SceneRuntimeState["style"]["direction"] } })}
|
||||
options={[{ value: "none", label: "隐藏" }, { value: "results", label: "按后端结果" }, { value: "topology", label: "按编号方向" }]}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="结果色带">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<ColorInput label="低值" value={state.style.lowColor} onChange={(value) => onCommand({ name: "set-style", patch: { lowColor: value } })} />
|
||||
<ColorInput label="高值" value={state.style.highColor} onChange={(value) => onCommand({ name: "set-style", patch: { highColor: value } })} />
|
||||
<ColorInput label="无数据" value={state.style.missingColor} onChange={(value) => onCommand({ name: "set-style", patch: { missingColor: value } })} />
|
||||
</div>
|
||||
<div aria-hidden="true" className="h-2 rounded-full ring-1 ring-white/60" style={{ background: `linear-gradient(90deg, ${state.style.lowColor}, ${state.style.highColor})` }} />
|
||||
<SwitchRow label="按当前结果自动设定范围" checked={state.style.autoRange} onChange={(checked) => onCommand({ name: "set-style", patch: { autoRange: checked } })} />
|
||||
{!state.style.autoRange && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NumberInput label="下限" value={state.style.min} onCommit={(value) => onCommand({ name: "set-style", patch: { min: value } })} />
|
||||
<NumberInput label="上限" value={state.style.max} onCommit={(value) => onCommand({ name: "set-style", patch: { max: value } })} />
|
||||
</div>
|
||||
)}
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="光照与画质">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SelectField label="场景光照" value={state.appearance.preset} onChange={(value) => onCommand({ name: "set-appearance", patch: { preset: value as SceneRuntimeState["appearance"]["preset"] } })} options={[{ value: "day", label: "清晰日光" }, { value: "studio", label: "设备展厅" }, { value: "evening", label: "傍晚暖光" }]} />
|
||||
<SelectField label="画质" value={state.appearance.quality} onChange={(value) => onCommand({ name: "set-appearance", patch: { quality: value as SceneRuntimeState["appearance"]["quality"] } })} options={[{ value: "standard", label: "标准" }, { value: "high", label: "高质量" }]} />
|
||||
</div>
|
||||
<LabeledSlider label={`亮度 ${state.appearance.exposure.toFixed(2)}`} value={state.appearance.exposure} min={0.55} max={1.6} step={0.05} onChange={(value) => onCommand({ name: "set-appearance", patch: { exposure: value } })} />
|
||||
<SwitchRow label="柔和阴影" checked={state.appearance.shadows} onChange={(checked) => onCommand({ name: "set-appearance", patch: { shadows: checked } })} />
|
||||
<SwitchRow label="空间遮蔽与边缘平滑" checked={state.appearance.effects} onChange={(checked) => onCommand({ name: "set-appearance", patch: { effects: checked } })} />
|
||||
</ControlSection>
|
||||
|
||||
<button type="button" onClick={() => onCommand({ name: "reset-style" })} className="flex min-h-10 w-full items-center justify-center gap-2 rounded-lg border border-slate-300/70 bg-white/35 text-sm font-medium text-slate-700 transition hover:border-blue-400 hover:bg-blue-50/70 hover:text-blue-700 active:scale-[0.99]">
|
||||
<FiRotateCcw /> 恢复默认管网样式
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "properties" && <AssetProperties selection={selection} onCommand={onCommand} />}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolButton({ label, active = false, disabled = false, onClick, children }: { label: string; active?: boolean; disabled?: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"grid h-10 w-10 place-items-center rounded-lg text-[18px] transition duration-150 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 disabled:cursor-not-allowed disabled:opacity-35",
|
||||
active
|
||||
? "bg-blue-600 text-white shadow-md shadow-blue-700/20 ring-1 ring-blue-400/30"
|
||||
: "text-slate-600 hover:bg-blue-50/80 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<span className="pointer-events-none absolute left-full top-1/2 z-50 ml-2 hidden -translate-y-1/2 whitespace-nowrap rounded-md bg-slate-900/90 px-2 py-1 text-xs text-white opacity-0 shadow-md transition group-hover:opacity-100 md:block">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({ label, disabled = false, onClick, children }: { label: string; disabled?: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" aria-label={label} title={label} disabled={disabled} onClick={onClick} className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-lg text-slate-500 transition hover:bg-white/65 hover:text-blue-700 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, icon, children }: { active: boolean; onClick: () => void; icon: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" role="tab" aria-selected={active} onClick={onClick} className={clsx("relative flex min-h-11 items-center justify-center gap-1.5 rounded-t-lg text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/40", active ? "text-blue-700 after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:rounded-full after:bg-blue-600" : "text-slate-500 hover:bg-white/35 hover:text-slate-800")}>
|
||||
{icon}{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-2">
|
||||
<h3 className="text-[11px] font-bold uppercase tracking-[0.12em] text-slate-500">{title}</h3>
|
||||
{description && <p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>}
|
||||
</div>
|
||||
<div className="space-y-2.5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="block">
|
||||
<span id={labelId} className="mb-1 block text-xs text-slate-500">{label}</span>
|
||||
<Select
|
||||
fullWidth
|
||||
labelId={labelId}
|
||||
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 }) {
|
||||
return (
|
||||
<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
|
||||
aria-hidden="true"
|
||||
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, disabled = false, onChange }: { label: string; value: number; min: number; max: number; step: number; disabled?: boolean; onChange: (value: number) => void }) {
|
||||
return (
|
||||
<label className={clsx("block", disabled && "opacity-45")}>
|
||||
<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 disabled:cursor-not-allowed" value={value} min={min} max={max} step={step} disabled={disabled} onChange={(event) => onChange(Number(event.target.value))} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorInput({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="text-center">
|
||||
<input type="color" aria-label={`${label}颜色`} value={value} onChange={(event) => onChange(event.target.value)} className="h-10 w-full cursor-pointer rounded-lg border border-slate-300/70 bg-white/45 p-1" />
|
||||
<span className="mt-1 block text-xs text-slate-500">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({ label, value, onCommit }: { label: string; value: number; onCommit: (value: number) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-500">{label}</span>
|
||||
<input key={`${label}-${value}`} className={fieldClass} type="number" defaultValue={value} step={0.1} onBlur={(event) => { const next = Number(event.target.value); if (Number.isFinite(next)) onCommit(next); }} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetProperties({ selection, onCommand }: { selection: SceneAssetSelection | null; onCommand: (command: SceneCommand) => void }) {
|
||||
if (!selection) {
|
||||
return (
|
||||
<div className="flex min-h-72 flex-col items-center justify-center px-6 text-center">
|
||||
<span className="mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-blue-200/70 bg-blue-50/60 text-2xl text-blue-600"><FiInfo /></span>
|
||||
<h3 className="text-sm font-semibold text-slate-800">尚未选择三维构件</h3>
|
||||
<p className="mt-2 max-w-64 text-xs leading-5 text-slate-500">在模型中点击节点、管段、泵或阀门,这里会显示模型属性和当前后端结果。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-slate-900">{selection.title}</p>
|
||||
<p className="mt-0.5 text-xs tabular-nums text-slate-500">模型编号 {selection.elementId}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" onClick={() => onCommand({ name: "locate-selection" })} className="flex min-h-10 items-center justify-center gap-2 rounded-lg bg-blue-600 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-[0.98]"><FiCrosshair />定位构件</button>
|
||||
<button type="button" onClick={() => onCommand({ name: "clear-selection" })} className="min-h-10 rounded-lg border border-slate-300/70 bg-white/35 text-sm font-medium text-slate-700 transition hover:bg-white/65 active:scale-[0.98]">清除选择</button>
|
||||
</div>
|
||||
{selection.sections.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-[0.12em] text-slate-500">{section.title}</h3>
|
||||
<dl>
|
||||
{section.fields.map((field, index) => (
|
||||
<div key={`${section.title}-${field.label}`} className={clsx("grid min-h-9 grid-cols-[minmax(86px,.42fr)_minmax(0,1fr)] items-start gap-4 py-2", index > 0 && "border-t border-slate-200/55")}>
|
||||
<dt className="text-xs text-slate-500">{field.label}</dt>
|
||||
<dd className="text-right text-sm tabular-nums text-slate-800 [overflow-wrap:anywhere]">{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
{selection.neighbors.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-[0.12em] text-slate-500">相邻管段</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selection.neighbors.map((neighbor) => (
|
||||
<button key={neighbor.id} type="button" onClick={() => onCommand({ name: "select-asset", assetId: neighbor.id })} className="min-h-9 rounded-lg border border-slate-300/70 bg-white/35 px-3 text-sm text-slate-700 transition hover:border-blue-400 hover:bg-blue-50/70 hover:text-blue-700">{neighbor.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user