Compare commits
297
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b25b94d8 | ||
|
|
69ad145e75 | ||
|
|
331ea3f094 | ||
|
|
08ddb4453d | ||
|
|
35b6819459 | ||
|
|
3ebe3328aa | ||
|
|
b04378397c | ||
|
|
bad8769000 | ||
|
|
29b8babd68 | ||
|
|
0dad61ff1f | ||
|
|
fee4fc6ce1 | ||
|
|
6623f4f7fb | ||
|
|
d2ce1687f2 | ||
|
|
dca2817320 | ||
|
|
41bf7d71e3 | ||
|
|
5bea86dbf6 | ||
|
|
78a470e89e | ||
|
|
6d505ca461 | ||
|
|
f34d81c933 | ||
|
|
5053ddcd1f | ||
|
|
45def5bba3 | ||
|
|
9e79d52dc8 | ||
|
|
4a978de905 | ||
|
|
1ac7372585 | ||
|
|
5d730ba1b8 | ||
|
|
caf18f706d | ||
|
|
0dd521d8c9 | ||
|
|
0a47534ddb | ||
|
|
9e75e2df8a | ||
|
|
8d7c947897 | ||
|
|
71bde7d9e4 | ||
|
|
5037089057 | ||
|
|
b4c96f8524 | ||
|
|
e496fbe4b7 | ||
|
|
5d9c40e454 | ||
|
|
5592c27386 | ||
|
|
94142d7031 | ||
|
|
0e638046ed | ||
|
|
65ed1fd212 | ||
|
|
81cc5dcae1 | ||
|
|
782363cfb6 | ||
|
|
b57e58ff87 | ||
|
|
38d246eb7b | ||
|
|
723931b6ae | ||
|
|
f355ddd002 | ||
|
|
6114e76735 | ||
|
|
d643f09fcf | ||
|
|
04557b9363 | ||
|
|
8836549697 | ||
|
|
1d7e07174f | ||
|
|
c4246cf25f | ||
|
|
1b2a7f4fb8 | ||
|
|
82e75c03d0 | ||
|
|
5894ee277a | ||
|
|
a3331691de | ||
|
|
7745333a58 | ||
|
|
4596af7c12 | ||
|
|
5332f8f0c5 | ||
|
|
1993cadba8 | ||
|
|
7a3677ee9f | ||
|
|
0d559f6130 | ||
|
|
7af90e495d | ||
|
|
a52c04204d | ||
|
|
08152ff978 | ||
|
|
589cf45aa7 | ||
|
|
59447a100c | ||
|
|
4adbcc1c4c | ||
|
|
202f18332f | ||
|
|
acf13639ef | ||
|
|
d986e563a6 | ||
|
|
d8ee2e1f0c | ||
|
|
fddb0ceb34 | ||
|
|
3d7b594682 | ||
|
|
eb8950c89a | ||
|
|
ef2b045306 | ||
|
|
209da0d295 | ||
|
|
dfaee645ff | ||
|
|
d90ca7c951 | ||
|
|
041b4ef89d | ||
|
|
f5e7312e3b | ||
|
|
c6e6e24aab | ||
|
|
adb53d9a13 | ||
|
|
14c76231d5 | ||
|
|
694f7629ee | ||
|
|
701c5a949d | ||
|
|
435a4172e4 | ||
|
|
600a8703ed | ||
|
|
b13fbe7dca | ||
|
|
e2e296dc06 | ||
|
|
9c2a6a386a | ||
|
|
a4e7ab263a | ||
|
|
67c1a88afe | ||
|
|
76e62a2d3d | ||
|
|
758100b345 | ||
|
|
cf6386d209 | ||
|
|
0dea655f68 | ||
|
|
6ff8886524 | ||
|
|
7cd0c61181 | ||
|
|
f6d2e19397 | ||
|
|
181871e0cf | ||
|
|
8934844bd9 | ||
|
|
24cddc18a6 | ||
|
|
7f07f0449d | ||
|
|
757eea49de | ||
|
|
bb7311589c | ||
|
|
877b79ada8 | ||
|
|
a6ea97142a | ||
|
|
4374c89a63 | ||
|
|
224d53a04d | ||
|
|
7d2ae87e39 | ||
|
|
1e872ca873 | ||
|
|
e2a6bb0e7d | ||
|
|
9c0a7a2864 | ||
|
|
ab9e2a0420 | ||
|
|
eee165c812 | ||
|
|
213a01ff7d | ||
|
|
0501afaced | ||
|
|
d80a071987 | ||
|
|
216c7b1ab9 | ||
|
|
7d966a5e91 | ||
|
|
22afdbf2e8 | ||
|
|
ed9828befe | ||
|
|
968d798a2a | ||
|
|
7da0ed0e39 | ||
|
|
166b45e529 | ||
|
|
e5f13c3d46 | ||
|
|
36cdb1df8d | ||
|
|
865e425748 | ||
|
|
3a36c693cd | ||
|
|
b23cb6acdd | ||
|
|
2691f42581 | ||
|
|
34fd5bfb1a | ||
|
|
40cc355fff | ||
|
|
f7cd5ebfa7 | ||
|
|
d31565d52c | ||
|
|
e32823e4b5 | ||
|
|
5fc1812d53 | ||
|
|
709b029c4e | ||
|
|
57369772c7 | ||
|
|
7764e25398 | ||
|
|
e60e1f6453 | ||
|
|
20ca410e0a | ||
|
|
06a3f32d2d | ||
|
|
fa3e6b6e84 | ||
|
|
888132a60f | ||
|
|
9761ade8d8 | ||
|
|
0e82c080df | ||
|
|
a4f0ffcd32 | ||
|
|
9dc8549f31 | ||
|
|
6b447eb398 | ||
|
|
54fbf15be8 | ||
|
|
4bf99e8069 | ||
|
|
e4d45300b1 | ||
|
|
477350a2a1 | ||
|
|
424555aae2 | ||
|
|
98635e5247 | ||
|
|
adf8ea5ca8 | ||
|
|
91a57123a4 | ||
|
|
4f54da64d0 | ||
|
|
2fbfba118f | ||
|
|
9106b8d4a9 | ||
|
|
3800d73e85 | ||
|
|
e4424b87d1 | ||
|
|
39ee9a02e5 | ||
|
|
45274955c6 | ||
|
|
03ca56d2a7 | ||
|
|
570d2c7de1 | ||
|
|
8058b7b859 | ||
|
|
a4486e3d89 | ||
|
|
536cd6a5d1 | ||
|
|
133f5d417f | ||
|
|
cf43700459 | ||
|
|
5cfb7cc38f | ||
|
|
d4050a841b | ||
|
|
ba66abb4ee | ||
|
|
e0e78cd95a | ||
|
|
c5b0f43a0d | ||
|
|
8f3c288823 | ||
|
|
24d81e04e0 | ||
|
|
85b4f45d4a | ||
|
|
36d1a8d6ea | ||
|
|
e5ca9e24aa | ||
|
|
2c1afdc97c | ||
|
|
30d85173ee | ||
|
|
3b5a493cda | ||
|
|
49fd4f5eb1 | ||
|
|
3db2af0271 | ||
|
|
07861bee03 | ||
|
|
60181dba54 | ||
|
|
a1442fc062 | ||
|
|
260c493fc8 | ||
|
|
46a4d7157d | ||
|
|
3ba252462d | ||
|
|
5ca9a55a7b | ||
|
|
9206c480b2 | ||
|
|
23bd2f47c3 | ||
|
|
c4269f40e3 | ||
|
|
3afe885cc0 | ||
|
|
b99fe66704 | ||
|
|
c2785f0746 | ||
|
|
1ed09c9594 | ||
|
|
baa5d41bec | ||
|
|
05868c6af6 | ||
|
|
e81305d046 | ||
|
|
b963562a5f | ||
|
|
bfd41b58e3 | ||
|
|
333d0d3353 | ||
|
|
f207e2b192 | ||
|
|
4f195b0e06 | ||
|
|
0f110ce0c6 | ||
|
|
a23626614f | ||
|
|
1debaed7ea | ||
|
|
74b4a4157c | ||
|
|
efd04fd651 | ||
|
|
5aa28c8409 | ||
|
|
8b6dda08e6 | ||
|
|
427cbe70b3 | ||
|
|
6410df0cb7 | ||
|
|
ff5cbfde9c | ||
|
|
5cbf1e82f8 | ||
|
|
259202ca8f | ||
|
|
bfa4020239 | ||
|
|
5dab6464c3 | ||
|
|
b752be498a | ||
|
|
781711943a | ||
|
|
7d05ad4920 | ||
|
|
f0fad61bb2 | ||
|
|
d763876f86 | ||
|
|
56b4777dbd | ||
|
|
c484aad1d3 | ||
|
|
d610a09c14 | ||
|
|
a1c8041b11 | ||
|
|
295c959b52 | ||
|
|
adc12c13f9 | ||
|
|
6559d0c062 | ||
|
|
a101e79750 | ||
|
|
8713e5a468 | ||
|
|
03a77f7368 | ||
|
|
825acbf29c | ||
|
|
045391d036 | ||
|
|
accf6ad254 | ||
|
|
55362bef8f | ||
|
|
d232104aa4 | ||
|
|
e1e4664dec | ||
|
|
e0ab4bf60d | ||
|
|
abfc8770a4 | ||
|
|
71be47b956 | ||
|
|
081e4c4c13 | ||
|
|
a7106a7289 | ||
|
|
76aa28c701 | ||
|
|
a7f4867afe | ||
|
|
e2ea1853f1 | ||
|
|
f0f9d3f4f9 | ||
|
|
73201ae44e | ||
|
|
62914f80c3 | ||
|
|
64dcf9cbdb | ||
|
|
520e1cb3f1 | ||
|
|
7f25bd34d5 | ||
|
|
47e47fc605 | ||
|
|
b4ab3e287b | ||
|
|
ddb02cc688 | ||
|
|
6b68b7d081 | ||
|
|
2f24ab5d66 | ||
|
|
133880f7fc | ||
|
|
5ed6740a24 | ||
|
|
9beba1cf6f | ||
|
|
bf6edf2662 | ||
|
|
5430a9d885 | ||
|
|
377fc32f4c | ||
|
|
b73481d604 | ||
|
|
cd34e511ac | ||
|
|
6c5862f7e4 | ||
|
|
2d27e803a3 | ||
|
|
f9dc4b74d0 | ||
|
|
66f2390078 | ||
|
|
9d06226cb4 | ||
|
|
a2e6c1f416 | ||
|
|
2911b87fac | ||
|
|
8b6198a2ac | ||
|
|
03e5f1456c | ||
|
|
25bde02b43 | ||
|
|
1e8af75b88 | ||
|
|
8ea70d04ad | ||
|
|
1d15eeb172 | ||
|
|
ae1f9b284f | ||
|
|
409057cef2 | ||
|
|
2c51785157 | ||
|
|
6be4a0de14 | ||
|
|
9d12b1960c | ||
|
|
cbfce9164e | ||
|
|
62a97459d0 | ||
|
|
4fbe845015 | ||
|
|
f89e43eee2 | ||
|
|
4bd7b48bcf | ||
|
|
5b52afcc53 | ||
|
|
9bb0f8dcd7 | ||
|
|
bc73db66de |
+10
-5
@@ -1,7 +1,12 @@
|
|||||||
**/node_modules/
|
node_modules
|
||||||
**/dist
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
.git
|
.git
|
||||||
npm-debug.log
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
.env*.local
|
||||||
|
README.md
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
KEYCLOAK_CLIENT_ID="tjwater"
|
||||||
|
KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret"
|
||||||
|
KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater"
|
||||||
|
NEXTAUTH_SECRET="replace-with-nextauth-secret"
|
||||||
|
NEXTAUTH_URL="https://frontend.example.com/"
|
||||||
|
|
||||||
|
BACKEND_URL="https://server.example.com"
|
||||||
|
AGENT_URL="https://agent.example.com"
|
||||||
|
MAP_URL="https://geoserver.example.com/geoserver"
|
||||||
|
MAP_WORKSPACE="tjwater_next"
|
||||||
|
MAP_EXTENT="13508801.93,3608163.35,13555650.64,3633685.14"
|
||||||
|
NETWORK_NAME="tjwater_next"
|
||||||
|
MAPBOX_TOKEN="replace-with-public-mapbox-token"
|
||||||
|
TIANDITU_TOKEN="replace-with-public-tianditu-token"
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals"
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
+10
-2
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/playwright-report/
|
||||||
|
/test-results/
|
||||||
|
/e2e/.auth/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
@@ -19,6 +22,7 @@
|
|||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
/public/runtime-config.js
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -26,11 +30,15 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
memery.md
|
||||||
|
|
||||||
|
docs/
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
This repository is the TJWater web frontend built with Refine, Next.js, React, and MUI. Application source lives under the existing Next.js project folders. Reuse established page, component, provider, map, and chat patterns instead of adding parallel structures. Static assets and public files should remain in the existing asset/public locations. Build output (`.next/`), dependency folders, and local caches are generated and must not be edited by hand.
|
||||||
|
|
||||||
|
Deployment files are `Dockerfile`, `docker-compose.yml`, and `.gitea/workflows/package.yml`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
Use npm and Node 20 or newer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run test:coverage
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
|
||||||
|
|
||||||
|
When the Server API changes, sync `contracts/server-v1.openapi.json` from the
|
||||||
|
backend source of truth, update its SHA-256 in `contracts/manifest.json`, then
|
||||||
|
run `npm run api:generate` and `npm run api:check` so the generated types and
|
||||||
|
contract mirror remain aligned.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
Tests use Jest with React Testing Library. Name tests `*.test.ts` or `*.test.tsx` near the related code when possible. Add tests for user-visible behavior, state transitions, route guards, data transforms, and map/chat interactions. Run `npm test` or `npm run test:coverage` before larger PRs.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
History uses Conventional Commit messages such as `feat(map): add coordinate zoom action` and `fix(chat): hide raw permission metadata`, with occasional Chinese summaries. Prefer `feat(scope):`, `fix(scope):`, or `refactor(scope):`.
|
||||||
|
|
||||||
|
PRs should include a UI/behavior summary, verification commands, screenshots for visual changes, and notes for changed environment variables or backend API expectations.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
|
||||||
|
Do not commit `.env`, `.next/`, `node_modules/`, local caches, or private map/API tokens. Public build-time variables should be documented; sensitive values belong in Gitea secrets.
|
||||||
+18
-12
@@ -1,14 +1,17 @@
|
|||||||
FROM refinedev/node:18 AS base
|
FROM node:22.23.1-bookworm-slim AS base
|
||||||
|
|
||||||
|
WORKDIR /app/refine
|
||||||
|
|
||||||
|
ARG NPM_CONFIG_REGISTRY
|
||||||
|
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
|
|
||||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||||
elif [ -f package-lock.json ]; then npm ci; \
|
elif [ -f package-lock.json ]; then npm ci || (echo "===== npm debug logs =====" && find /root/.npm/_logs -maxdepth 1 -type f -name "*-debug-0.log" -print -exec cat {} \; && exit 1); \
|
||||||
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||||
else echo "Lockfile not found." && exit 1; \
|
else echo "Lockfile not found." && exit 1; \
|
||||||
fi
|
fi
|
||||||
@@ -23,21 +26,24 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
COPY --from=builder /app/refine/public ./public
|
COPY --from=builder --chown=node:node /app/refine/public ./public
|
||||||
|
COPY --chown=node:node docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir .next
|
RUN mkdir .next
|
||||||
RUN chown refine:nodejs .next
|
RUN chown node:node .next
|
||||||
|
|
||||||
COPY --from=builder --chown=refine:nodejs /app/refine/.next/standalone ./
|
COPY --from=builder --chown=node:node /app/refine/.next/standalone ./
|
||||||
COPY --from=builder --chown=refine:nodejs /app/refine/.next/static ./.next/static
|
COPY --from=builder --chown=node:node /app/refine/.next/static ./.next/static
|
||||||
|
|
||||||
USER refine
|
USER node
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT 3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Frontend Naming Audit
|
||||||
|
|
||||||
|
DOC-004 audit for the internal `TJWaterFrontend_Refine` application.
|
||||||
|
|
||||||
|
## Local frontend naming
|
||||||
|
|
||||||
|
- Next.js route directories under `src/app` are already `kebab-case`: `audit-logs`, `health-risk-analysis`, `hydraulic-simulation`, `monitoring-place-optimization`, `network-simulation`, `scada-data-cleaning`, and `system-admin`.
|
||||||
|
- Route groups and dynamic segments keep Next.js syntax: `(main)` and `[...nextauth]`.
|
||||||
|
- Local ordinary module filenames now use `camelCase`, including `src/utils/breaksClassification.ts`, `src/components/chat/globalChatboxUtils.ts`, and `src/components/chat/globalChatboxVoice.ts`.
|
||||||
|
- React component files remain `PascalCase.tsx`, including `src/app/RefineContext.tsx`, `src/components/chat/GlobalChatboxParts.tsx`, and domain directories under `src/components/olmap`.
|
||||||
|
|
||||||
|
## API boundary naming
|
||||||
|
|
||||||
|
Frontend request and response boundary fields intentionally keep backend/Agent wire names. Examples include `project_id`, `user_id`, `scheme_name`, `scheme_type`, `start_time`, `end_time`, `session_id`, `request_id`, and `keep_message_count`.
|
||||||
|
|
||||||
|
Current direct API calls mostly use new `kebab-case` URL paths, including:
|
||||||
|
|
||||||
|
- `/api/v1/admin/projects`
|
||||||
|
- `/api/v1/audit/logs`
|
||||||
|
- `/api/v1/projects/open`
|
||||||
|
- `/api/v1/project-info`
|
||||||
|
- `/api/v1/schemes`
|
||||||
|
- `/api/v1/sensor-placement-schemes`
|
||||||
|
- `/api/v1/burst-analysis`
|
||||||
|
- `/api/v1/valve-isolation-analysis`
|
||||||
|
- `/api/v1/flushing-analysis`
|
||||||
|
- `/api/v1/contaminant-simulation`
|
||||||
|
- `/api/v1/simulations/run-by-date`
|
||||||
|
- `/api/v1/burst-detection/detect`
|
||||||
|
- `/api/v1/burst-location/locate`
|
||||||
|
- `/api/v1/scada/by-ids-field-time-range`
|
||||||
|
- `/api/v1/composite/clean-scada`
|
||||||
|
- `/api/v1/agent/chat/render-ref/{render_ref}`
|
||||||
|
|
||||||
|
## Legacy URL inventory
|
||||||
|
|
||||||
|
The frontend no longer calls these active legacy URLs directly. The backend still exposes them as deprecated compatibility aliases:
|
||||||
|
|
||||||
|
| Current frontend URL | Files | Suggested target |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/api/v1/openproject/` | `src/contexts/ProjectContext.tsx` | `/api/v1/projects/open` or `/api/v1/project/open` |
|
||||||
|
| `/api/v1/project_info/` | `src/contexts/ProjectContext.tsx` | `/api/v1/project-info` |
|
||||||
|
| `/api/v1/getallschemes/` | burst, burst simulation, contaminant, flushing scheme query components | `/api/v1/schemes` |
|
||||||
|
| `/api/v1/getallsensorplacements/` | `src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx` | `/api/v1/sensor-placement-schemes` |
|
||||||
|
| `/api/v1/sensorplacementscheme/create` | `src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx` | `/api/v1/sensor-placement-schemes` |
|
||||||
|
| `/api/v1/burst_analysis/` | `src/components/olmap/BurstSimulation/AnalysisParameters.tsx` | `/api/v1/burst-analysis` |
|
||||||
|
| `/api/v1/valve_isolation_analysis/` | `src/components/olmap/BurstSimulation/ValveIsolation.tsx` | `/api/v1/valve-isolation-analysis` |
|
||||||
|
| `/api/v1/flushing_analysis/` | `src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx` | `/api/v1/flushing-analysis` |
|
||||||
|
| `/api/v1/contaminant_simulation/` | `src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx` | `/api/v1/contaminant-simulation` |
|
||||||
|
| `/api/v1/runsimulationmanuallybydate/` | `src/components/olmap/core/Controls/Timeline.tsx` | `/api/v1/simulations/run-by-date` |
|
||||||
|
|
||||||
|
Already migrated frontend code leaves comments showing older pre-`/api/v1` URLs in `src/components/olmap/core/Controls/Toolbar.tsx`; those comments are historical only and are not active calls.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
Continue tracking broad passive legacy backend routes under the shared legacy API compatibility strategy.
|
||||||
@@ -1,48 +1,123 @@
|
|||||||
# my-refine-app
|
# TJWaterFrontend_Refine 内部前端
|
||||||
|
|
||||||
<div align="center" style="margin: 30px;">
|
`TJWaterFrontend_Refine` 是 TJWater 内部 Web 前端,基于 Refine、Next.js、React 和 MUI 构建。它承载管网地图、业务管理、用户认证、智能体聊天、SCADA/历史数据查看和结果可视化等内部功能。
|
||||||
<a href="https://refine.dev">
|
|
||||||
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
This [Refine](https://github.com/refinedev/refine) project was generated with [create refine-app](https://github.com/refinedev/refine/tree/master/packages/create-refine-app).
|
## 技术栈
|
||||||
|
|
||||||
## Getting Started
|
- Next.js 16
|
||||||
|
- React 19
|
||||||
|
- Refine 5
|
||||||
|
- MUI 6 / MUI X
|
||||||
|
- OpenLayers、deck.gl、Turf
|
||||||
|
- Zustand、NextAuth、Jest
|
||||||
|
|
||||||
A React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility ✨
|
## 目录结构
|
||||||
|
|
||||||
Refine's hooks and components simplifies the development process and eliminates the repetitive tasks by providing industry-standard solutions for crucial aspects of a project, including authentication, access control, routing, networking, state management, and i18n.
|
```text
|
||||||
|
src/app/ Next.js App Router 页面
|
||||||
## Available Scripts
|
src/components/ 复用 UI 组件
|
||||||
|
src/providers/ Refine、认证、数据和主题 provider
|
||||||
### Running the development server.
|
src/hooks/ 业务 hooks
|
||||||
|
src/utils/ 通用工具
|
||||||
```bash
|
public/ 静态资源
|
||||||
npm run dev
|
scripts/ 运行时配置和辅助脚本
|
||||||
|
Dockerfile 镜像构建文件
|
||||||
|
docker-compose.yml 本地编排参考
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building for production.
|
新增功能应复用现有页面、组件、provider、地图和聊天结构,避免创建平行体系。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
要求 Node.js 20 或更高版本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the production server.
|
`npm run dev` 会先执行运行时配置生成,再启动 Next.js 开发服务。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run start
|
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 .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Learn More
|
- `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`:启动生产模式服务。
|
||||||
|
|
||||||
To learn more about **Refine**, please check out the [Documentation](https://refine.dev/docs)
|
## 配置说明
|
||||||
|
|
||||||
- **REST Data Provider** [Docs](https://refine.dev/docs/core/providers/data-provider/#overview)
|
运行时配置由 `scripts/generate-runtime-config.mjs` 生成。API 地址、Agent 地址、Keycloak/认证参数、地图服务地址和其他环境差异配置应通过环境变量或部署配置注入。
|
||||||
- **Material UI** [Docs](https://refine.dev/docs/ui-frameworks/mui/tutorial/)
|
|
||||||
- **Custom Auth Provider** [Docs](https://refine.dev/docs/core/providers/auth-provider/)
|
|
||||||
|
|
||||||
## License
|
只有允许暴露给浏览器的配置才应进入 public/runtime 配置;密钥和私有 token 不能进入前端构建产物。
|
||||||
|
|
||||||
MIT
|
## 开发规范
|
||||||
|
|
||||||
|
- React 组件文件使用 `PascalCase.tsx`。
|
||||||
|
- 普通 TypeScript 模块、hooks、store、provider 和工具使用 `camelCase.ts`。
|
||||||
|
- `src/app` 路由目录使用 `kebab-case`,保留 Next.js 路由组和动态段语法。
|
||||||
|
- UI 优先沿用 MUI、Refine 和既有地图/聊天界面模式。
|
||||||
|
- 与后端或 Agent 通信的字段保持接口原始格式,通常为 `snake_case`。
|
||||||
|
|
||||||
|
## 测试与发布
|
||||||
|
|
||||||
|
提交前建议运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
发布镜像前运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 中。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"contract_version": "1.0.0",
|
||||||
|
"contracts": {
|
||||||
|
"agent": {
|
||||||
|
"file": "agent-v1.openapi.json",
|
||||||
|
"sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"file": "server-v1.openapi.json",
|
||||||
|
"sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
image: ${IMAGE_NAME:-refinedev/tjwater-frontend:latest}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
BACKEND_URL: ${BACKEND_URL}
|
||||||
|
AGENT_URL: ${AGENT_URL}
|
||||||
|
MAP_URL: ${MAP_URL}
|
||||||
|
MAP_WORKSPACE: ${MAP_WORKSPACE}
|
||||||
|
MAP_EXTENT: ${MAP_EXTENT}
|
||||||
|
NETWORK_NAME: ${NETWORK_NAME}
|
||||||
|
MAPBOX_TOKEN: ${MAPBOX_TOKEN}
|
||||||
|
TIANDITU_TOKEN: ${TIANDITU_TOKEN}
|
||||||
|
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
|
||||||
|
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
|
||||||
|
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
|
||||||
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||||
|
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
||||||
|
NODE_ENV: production
|
||||||
|
HOSTNAME: 0.0.0.0
|
||||||
|
PORT: 3000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
pull_policy: always
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const parseExtent = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return [13508849, 3608036, 13555781, 3633813];
|
||||||
|
}
|
||||||
|
|
||||||
|
const extent = value.split(",").map(Number);
|
||||||
|
return extent.length === 4 && extent.every(Number.isFinite)
|
||||||
|
? extent
|
||||||
|
: [13508849, 3608036, 13555781, 3633813];
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
|
||||||
|
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
|
||||||
|
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
|
||||||
|
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater",
|
||||||
|
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
|
||||||
|
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater",
|
||||||
|
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
|
||||||
|
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
"/app/refine/public/runtime-config.js",
|
||||||
|
`window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`,
|
||||||
|
);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Chat 流式生成动画改造经验
|
||||||
|
|
||||||
|
本文记录 `src/components/chat` 里本次文字生成、图表生成、滚动稳定性的改造经验。重点不是复盘代码行数,而是总结后续继续调整时应遵守的工程边界和交互原则。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 文本生成要有连续感,避免 token 直接到达导致忽快忽慢。
|
||||||
|
- 已生成内容必须稳定,不能反复淡入、重排或闪烁。
|
||||||
|
- 图表和工具调用插入时不能让“分析结果”边框剧烈抖动。
|
||||||
|
- 底部自动滚动要跟随,但不能每个 token 都强制贴底。
|
||||||
|
- 动画应辅助理解,不能比内容本身更抢眼。
|
||||||
|
|
||||||
|
## 文本流式生成
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
不要把后端 token 到达节奏直接暴露给 UI。后端 token 通常不均匀,前端如果每个 token 都立即渲染,会出现文字跳动、滚动频繁、动画看不出来等问题。
|
||||||
|
|
||||||
|
更稳的做法是类似 Vercel AI SDK `smoothStream` 的思路:
|
||||||
|
|
||||||
|
- token 先进入缓冲区。
|
||||||
|
- 前端按固定节奏释放 chunk。
|
||||||
|
- chunk 尽量按词、短语、标点边界切分。
|
||||||
|
- 当缓冲积压较大时,自适应加快 drain,避免显示落后真实输出太多。
|
||||||
|
|
||||||
|
当前实现采用:
|
||||||
|
|
||||||
|
- `TOKEN_PLAYBACK_INTERVAL_MS = 16`
|
||||||
|
- 小缓冲按较短 chunk 输出。
|
||||||
|
- 大缓冲最多每帧释放 `160` 字符。
|
||||||
|
- 中文优先使用 `Intl.Segmenter("zh", { granularity: "word" })`。
|
||||||
|
- 非 token 事件前强制 flush,保证工具调用、done、error 的顺序正确。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 只做 `setTimeout(120ms)` 批量 flush 不够。它只是减少更新次数,并不能形成稳定播放节奏。
|
||||||
|
- interval 太小,例如 `8ms`,浏览器调度不一定更稳定,反而可能增加 React 更新压力。
|
||||||
|
- 中文按 `Intl.Segmenter` 的单个词输出会显得慢,必须结合缓冲长度动态放大 chunk。
|
||||||
|
- `done`、`error`、`tool_call` 前如果不 flush,会造成文本和结构事件顺序错乱。
|
||||||
|
|
||||||
|
## Markdown 动画
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
Markdown 是流式文本动画里最容易出问题的部分。原因是 `ReactMarkdown` 每次都会重新解析完整内容,原始 Markdown 字符索引和最终 DOM 文本节点索引不一致。
|
||||||
|
|
||||||
|
典型例子:
|
||||||
|
|
||||||
|
- `**加粗**` 的原始长度包含 `**`,但可见文本不包含。
|
||||||
|
- 列表符号、链接语法、代码块围栏都可能影响原始索引。
|
||||||
|
- 新增文本可能落在 `p`、`li`、`strong`、`code` 等不同节点里。
|
||||||
|
|
||||||
|
因此不要简单用原始 `text.length` 或 `fadeFrom` 去对应 Markdown 渲染后的 DOM 文本。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- Markdown 仍完整解析,保证格式正确。
|
||||||
|
- 在 rehype 阶段处理 AST。
|
||||||
|
- 从 AST 尾部反向找最后的可见 text node。
|
||||||
|
- 只给最后一段尾部文本加动画。
|
||||||
|
- 每次最多动画最后 `48` 个字符,避免大 chunk 整段闪烁。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 用 `text.length` 作为 React key 会导致整段 Markdown remount,所有文本都会重新淡入。
|
||||||
|
- CSS animation 和 Web Animations 同时作用在同一个 span 上,会出现闪烁或动画重启。
|
||||||
|
- 在 render 阶段读写 ref 会触发 React hooks lint 规则,也容易产生不可控渲染。
|
||||||
|
- 反向遍历 AST 时拆分 text node 要注意顺序。使用 `unshift` 时应先插入动画尾巴,再插入稳定文本,最终 DOM 才是“稳定文本在前,动画尾巴在后”。
|
||||||
|
|
||||||
|
## 当前文字动画建议
|
||||||
|
|
||||||
|
推荐保留轻量动画:
|
||||||
|
|
||||||
|
- 使用 Web Animations,在 `useLayoutEffect` 中启动,避免先完整显示一帧再裁切。
|
||||||
|
- 使用 `clip-path` 做左到右 reveal。
|
||||||
|
- 叠加轻微 opacity:当前约 `0.46 -> 1`。
|
||||||
|
- 时长控制在 `120ms - 260ms`。
|
||||||
|
|
||||||
|
不要做:
|
||||||
|
|
||||||
|
- 外层整段 `motion.div` 淡入。
|
||||||
|
- 每次流式更新都改变 key。
|
||||||
|
- 对整个 Markdown AST 的新增范围大面积包 span。
|
||||||
|
- 在生成中对已有文本重复动画。
|
||||||
|
|
||||||
|
## 滚动和边框稳定
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
滚动条在最底部时,内容增长会不断改变 `scrollTop`。如果每个 token 都执行 `scrollTop = scrollHeight` 或 `scrollIntoView`,最后一个 assistant turn 的边框会产生明显抖动。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- 生成中不再每个 token 精确贴底。
|
||||||
|
- 底部保留生成缓冲区,当前约 `180px`。
|
||||||
|
- 只有缓冲被消耗到阈值后才恢复滚动。
|
||||||
|
- 用户离开底部附近后,不再强制自动跟随。
|
||||||
|
- 使用 `scrollbar-gutter: stable` 减少滚动条出现/消失造成的宽度变化。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- “锁最大高度”不是正确方向。问题不是高度无限增长,而是底部锚定过于频繁。
|
||||||
|
- 每 token 自动滚动会把视口不断向下推,视觉上就是边框抖动。
|
||||||
|
- 滚动判断阈值要和底部缓冲一致,否则缓冲刚出现就被判断为“离开底部”。
|
||||||
|
|
||||||
|
## 图表生成
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
图表不能等数据到达后突然插入。图表生成应先占位,再 crossfade,再让图表内部动画接管。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- 工具调用 pending 时使用固定尺寸 `ChartGenerationSkeleton`。
|
||||||
|
- 图表真实数据到达后,继续短暂保留 skeleton overlay。
|
||||||
|
- ECharts 在 skeleton 下方淡入。
|
||||||
|
- 容器尺寸保持一致,避免边框高度突变。
|
||||||
|
- ECharts 内部使用 enter/update 动画,而不是外层布局动画。
|
||||||
|
|
||||||
|
图表类型动画建议:
|
||||||
|
|
||||||
|
- 折线图:平滑 enter,面积渐显。
|
||||||
|
- 柱状图:柱子从基线增长,并对数据点轻微 stagger。
|
||||||
|
- 饼图:使用 expansion/sweep 类进入动画。
|
||||||
|
- update 动画要短于 enter 动画。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 只给外层图表卡片 fade in 不够,插入瞬间仍可能造成内容跳变。
|
||||||
|
- skeleton 和最终图表尺寸不一致,会导致边框先长再缩。
|
||||||
|
- 图表更新时不要重建组件,尽量让 ECharts diff 数据并执行内部 transition。
|
||||||
|
|
||||||
|
## 状态提示
|
||||||
|
|
||||||
|
“正在生成”状态是有价值的,应该保留。它承担了部分动感和系统状态反馈,不需要让文本动画本身过于夸张。
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- 状态放在“分析结果”标题行右侧。
|
||||||
|
- 使用小尺寸、低干扰的 pulsing dots。
|
||||||
|
- 不使用末尾光标,避免和业务文本混在一起。
|
||||||
|
|
||||||
|
## 验证建议
|
||||||
|
|
||||||
|
每次调整流式动画后至少跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx eslint src/components/chat/AgentMarkdownBlock.tsx src/components/chat/AgentTurn.tsx src/components/chat/ChatInlineChart.tsx src/components/chat/AgentWorkspace.tsx src/components/chat/GlobalChatbox.tsx src/components/chat/hooks/useAgentChatSession.ts
|
||||||
|
npx tsc --noEmit
|
||||||
|
npm test -- src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx src/components/chat/hooks/useAgentChatSession.actions.test.tsx src/components/chat/AgentWorkspace.test.tsx src/components/chat/ChatInlineChart.test.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
人工验证重点:
|
||||||
|
|
||||||
|
- 长中文回答是否明显落后后端真实速度。
|
||||||
|
- Markdown 加粗、列表、代码块是否乱序。
|
||||||
|
- 底部自动滚动时“分析结果”边框是否抖动。
|
||||||
|
- 工具调用 pending 到图表出现时是否有高度跳变。
|
||||||
|
- 用户手动上滚后是否停止强制跟随。
|
||||||
|
|
||||||
|
## 后续调整原则
|
||||||
|
|
||||||
|
1. 先调节 token playback,再调动画。
|
||||||
|
2. 动画只作用于新增内容,已有内容不能重播。
|
||||||
|
3. Markdown 动画优先保守,宁可弱一点,也不能破坏文本顺序。
|
||||||
|
4. 图表和工具调用先稳定布局,再考虑视觉效果。
|
||||||
|
5. 滚动跟随要有缓冲,不能逐 token 贴底。
|
||||||
@@ -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,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;");
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
|
||||||
|
const config = [
|
||||||
|
{
|
||||||
|
ignores: ["public/three-dimensional/**/vendor/**"],
|
||||||
|
},
|
||||||
|
...nextCoreWebVitals,
|
||||||
|
];
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -9,6 +9,7 @@ const createJestConfig = nextJest({
|
|||||||
const customJestConfig = {
|
const customJestConfig = {
|
||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
testEnvironment: 'jest-environment-jsdom',
|
testEnvironment: 'jest-environment-jsdom',
|
||||||
|
testPathIgnorePatterns: ['<rootDir>/e2e/'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@pages/(.*)$': '<rootDir>/pages/$1',
|
'^@pages/(.*)$': '<rootDir>/pages/$1',
|
||||||
'^@/(.*)$': '<rootDir>/src/$1',
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# CI build notes
|
||||||
|
|
||||||
|
## 2026-04-24
|
||||||
|
|
||||||
|
- **Observed failure while reproducing workflow checkout locally:** the `Checkout code` step ran `git remote add origin ...` unconditionally. In a workspace that already had an `origin` remote, the job failed with `error: remote origin already exists.` and exited before `docker build`.
|
||||||
|
- **Why this matters for act_runner:** self-hosted Gitea runners can reuse working directories or start from repositories that already contain Git metadata, so checkout logic must be idempotent.
|
||||||
|
- **Applied fix:** changed `.gitea/workflows/package.yml` to initialize Git only when needed, use `git remote set-url origin ...` when `origin` already exists, and force-clean the workspace after checking out `FETCH_HEAD`.
|
||||||
|
- **Safety improvement for remote validation:** tags ending with `-test` now run the build verification path only. They skip registry login, image push, `latest` updates, and the deploy webhook so act_runner can be tested without deployment side effects.
|
||||||
|
- **Root cause found on the real act_runner:** although the runner was registered with `ubuntu:docker://gitea/runner-images:ubuntu-22.04`, the workflow used `runs-on: ubuntu`, and the job log showed `Start image=ubuntu:latest`. That default image does not include the expected toolset, which explains the remote `git: not found` failure.
|
||||||
|
- **Applied fix for label selection:** changed both jobs to `runs-on: "ubuntu:docker://gitea/runner-images:ubuntu-22.04"` so Gitea resolves the exact runner image instead of falling back to `ubuntu:latest`.
|
||||||
|
- **Follow-up from server validation:** Gitea then reported `No matching online runner with label: ubuntu:docker://gitea/runner-images:ubuntu-22.04`. The runner advertises the short label `ubuntu-22.04`, so the workflow was updated again to use `runs-on: ubuntu-22.04`, which should map to `docker://gitea/runner-images:ubuntu-22.04` on the runner side.
|
||||||
|
- **Next remote failure on act_runner:** Docker rejected the tag `gitea.waternetwork.cn/OrgTJWater/TJWaterFrontend_Refine:v2026.04.24-test3` with `repository name must be lowercase`. The workflow had normalized the registry host but not the repository path from `github.repository`.
|
||||||
|
- **Applied fix for image naming:** lowercased `REPOSITORY_PATH` during image metadata normalization so image tags remain valid even when the Gitea owner or repository name contains uppercase letters.
|
||||||
|
- **Latest remote failure on act_runner:** a `*-test` run still reached `Notify Deploy Server` and failed with `curl: (3) URL using bad/illegal format or missing URL`. That showed the shell-level `IS_TEST_TAG` guard was not reliable enough for cross-step skip control on this runner.
|
||||||
|
- **Applied fix for test-tag skipping:** moved registry login and deploy webhook skipping to workflow-level `if:` conditions based on `endsWith(github.ref_name, '-test')`, and made the image-push branch check the tag name directly instead of relying on `IS_TEST_TAG` from a previous step.
|
||||||
|
- **Follow-up from server validation:** the runner still executed `Notify Deploy Server` for `v2026.04.24-test5`, so Gitea step-level `if:` with `endsWith(...)` was not sufficient in this environment.
|
||||||
|
- **Applied hardening:** replaced those step-level conditions with direct shell `case "${{ github.ref_name }}" in *-test)` guards inside the login, push, and deploy steps. This avoids relying on Gitea expression behavior for test-tag skipping.
|
||||||
|
- **Workflow mode changed for full CD verification:** per latest request, all `*-test` bypass logic was removed again so the workflow always runs registry login, image push, and deploy webhook. Full deployment validation now depends on using a normal `v*` tag and observing the real CD result instead of synthetic skip branches.
|
||||||
|
- **Next full-CD failure on act_runner:** image build completed, but pushing to the Gitea registry failed on blob upload commit with `failed to do request: Put ... EOF`. This is past the workflow logic stage and points to a transient or infrastructure-side registry upload failure.
|
||||||
|
- **Applied push hardening:** wrapped both `docker push "${IMAGE_NAME}:${IMAGE_TAG}"` and `docker push "${IMAGE_NAME}:latest"` in a 3-attempt retry helper with a short backoff to absorb transient registry EOF failures.
|
||||||
|
- **Current local result:** `npm run lint`, `npm run test -- --runInBand`, `npm run build`, `docker build ...`, and `npm run build` inside `gitea/runner-images:ubuntu-22.04` all completed successfully after the workflow adjustment.
|
||||||
|
- **Non-blocking note:** local Jest run reported a haste-map naming collision between `package.json` and `.next/standalone/package.json`; tests still passed, and this does not affect the current image-build workflow.
|
||||||
@@ -1,6 +1,54 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
distDir: process.env.NEXT_DIST_DIR || ".next",
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "refine.ams3.cdn.digitaloceanspaces.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
turbopack: {
|
||||||
|
rules: {
|
||||||
|
"*.svg": {
|
||||||
|
loaders: ["@svgr/webpack"],
|
||||||
|
as: "*.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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) {
|
webpack(config) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.svg$/,
|
test: /\.svg$/,
|
||||||
|
|||||||
Generated
+3206
-3913
File diff suppressed because it is too large
Load Diff
+37
-19
@@ -6,14 +6,23 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
|
"dev": "npm run runtime:config && cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev",
|
||||||
"build": "refine build",
|
"runtime:config": "node scripts/generate-runtime-config.mjs",
|
||||||
"start": "refine start",
|
"build": "next build",
|
||||||
"lint": "next lint",
|
"start": "next start",
|
||||||
|
"lint": "eslint .",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
"refine": "refine"
|
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.8.2",
|
"@emotion/react": "^11.8.2",
|
||||||
@@ -24,12 +33,10 @@
|
|||||||
"@mui/x-charts": "^7.29.1",
|
"@mui/x-charts": "^7.29.1",
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
"@mui/x-data-grid": "^7.22.2",
|
||||||
"@mui/x-date-pickers": "^8.12.0",
|
"@mui/x-date-pickers": "^8.12.0",
|
||||||
"@refinedev/cli": "^2.16.50",
|
"@refinedev/core": "^5.0.12",
|
||||||
"@refinedev/core": "^5.0.8",
|
|
||||||
"@refinedev/devtools": "^2.0.3",
|
|
||||||
"@refinedev/kbar": "^2.0.1",
|
"@refinedev/kbar": "^2.0.1",
|
||||||
"@refinedev/mui": "^8.0.0",
|
"@refinedev/mui": "^8.0.2",
|
||||||
"@refinedev/nextjs-router": "^7.0.4",
|
"@refinedev/nextjs-router": "^7.0.5",
|
||||||
"@refinedev/react-hook-form": "^5.0.4",
|
"@refinedev/react-hook-form": "^5.0.4",
|
||||||
"@refinedev/simple-rest": "^6.0.1",
|
"@refinedev/simple-rest": "^6.0.1",
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
@@ -39,19 +46,31 @@
|
|||||||
"deck.gl": "^9.1.14",
|
"deck.gl": "^9.1.14",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
|
"edge-tts-ts": "^1.0.0",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^15.5.11",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"ol": "^10.7.0",
|
"ol": "^10.7.0",
|
||||||
"postcss": "^8.5.6",
|
"openapi-fetch": "^0.17.0",
|
||||||
"react": "^19.1.0",
|
"postcss": "8.5.25",
|
||||||
"react-dom": "^19.1.0",
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
"react-draggable": "^4.5.0",
|
"react-draggable": "^4.5.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-window": "^1.8.10",
|
"react-window": "^1.8.10",
|
||||||
"tailwindcss": "^4.1.13"
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tailwindcss": "^4.1.13",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"fast-xml-parser": "5.7.0",
|
||||||
|
"postcss": "8.5.25",
|
||||||
|
"sharp": "0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -62,15 +81,14 @@
|
|||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "^15.0.3",
|
"eslint-config-next": "^16.1.6",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
|
||||||
"refine": {
|
|
||||||
"projectId": "4LwOCL-BBaV29-qUYMAJ"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "tjwater-app",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev",
|
|
||||||
"build": "refine build",
|
|
||||||
"start": "refine start",
|
|
||||||
"lint": "next lint",
|
|
||||||
"refine": "refine"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@emotion/react": "^11.8.2",
|
|
||||||
"@emotion/styled": "^11.8.1",
|
|
||||||
"@mui/icons-material": "^6.1.6",
|
|
||||||
"@mui/lab": "^6.0.0-beta.14",
|
|
||||||
"@mui/material": "^6.1.7",
|
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
|
||||||
"@refinedev/cli": "^2.16.48",
|
|
||||||
"@refinedev/core": "^5.0.0",
|
|
||||||
"@refinedev/devtools": "^2.0.1",
|
|
||||||
"@refinedev/kbar": "^2.0.0",
|
|
||||||
"@refinedev/mui": "^7.0.0",
|
|
||||||
"@refinedev/nextjs-router": "^7.0.0",
|
|
||||||
"@refinedev/react-hook-form": "^5.0.0",
|
|
||||||
"@refinedev/simple-rest": "^6.0.0",
|
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
|
||||||
"@turf/turf": "^7.2.0",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"deck.gl": "^9.1.14",
|
|
||||||
"js-cookie": "^3.0.5",
|
|
||||||
"next": "^15.2.4",
|
|
||||||
"next-auth": "^4.24.5",
|
|
||||||
"ol": "^10.6.1",
|
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-dom": "^19.1.0",
|
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"tailwindcss": "^4.1.13"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@svgr/webpack": "^8.1.0",
|
|
||||||
"@types/js-cookie": "^3.0.6",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/react": "^19.1.0",
|
|
||||||
"@types/react-dom": "^19.1.0",
|
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"eslint": "^8",
|
|
||||||
"eslint-config-next": "^15.0.3",
|
|
||||||
"typescript": "^5.8.3"
|
|
||||||
},
|
|
||||||
"refine": {
|
|
||||||
"projectId": "4LwOCL-BBaV29-qUYMAJ"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777523623582" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11701" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z" fill="#CE75FF" p-id="11702"></path><path d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z" fill="#F3E2FF" p-id="11703"></path><path d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#F62E76" p-id="11704"></path><path d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#CD88FF" p-id="11705"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777457471585" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5556" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M550.4 486.4c0-8.533333 4.266667-12.8 12.8-12.8h4.266667c4.266667 0 4.266667 4.266667 4.266666 4.266667s4.266667 4.266667 4.266667 8.533333v4.266667s0 4.266667-4.266667 4.266666c0 0-4.266667 0-4.266666 4.266667h-4.266667-4.266667s-4.266667 0-4.266666-4.266667c0 0 0-4.266667-4.266667-4.266666v-4.266667z" fill="#4D6BFE" p-id="5557"></path><path d="M994.133333 196.266667c-8.533333-4.266667-12.8 4.266667-21.333333 8.533333l-4.266667 4.266667c-12.8 17.066667-34.133333 25.6-55.466666 25.6-34.133333 0-59.733333 8.533333-85.333334 34.133333-4.266667-29.866667-21.333333-51.2-51.2-64-12.8-4.266667-29.866667-12.8-38.4-25.6-8.533333-8.533333-8.533333-21.333333-12.8-29.866667 0-4.266667 0-12.8-8.533333-12.8s-12.8 4.266667-12.8 12.8c-12.8 21.333333-21.333333 46.933333-17.066667 72.533334 0 59.733333 25.6 106.666667 72.533334 136.533333 4.266667 4.266667 8.533333 8.533333 4.266666 12.8-4.266667 12.8-8.533333 21.333333-8.533333 34.133333-4.266667 8.533333-4.266667 8.533333-12.8 4.266667-25.6-12.8-51.2-29.866667-68.266667-46.933333-34.133333-34.133333-64-72.533333-102.4-102.4-8.533333-8.533333-17.066667-12.8-25.6-21.333334-46.933333-34.133333 0-64 8.533334-68.266666 12.8-4.266667 4.266667-17.066667-29.866667-17.066667-34.133333 0-68.266667 12.8-106.666667 29.866667-8.533333 0-12.8 0-21.333333 4.266666-38.4-8.533333-76.8-8.533333-115.2-4.266666-76.8 8.533333-136.533333 42.666667-179.2 106.666666-51.2 76.8-64 157.866667-51.2 247.466667 17.066667 93.866667 64 170.666667 132.266667 230.4 72.533333 64 157.866667 93.866667 256 85.333333 59.733333-4.266667 123.733333-12.8 200.533333-76.8 17.066667 8.533333 38.4 12.8 72.533333 17.066667 25.6 4.266667 51.2 0 68.266667-4.266667 29.866667-4.266667 25.6-34.133333 17.066667-38.4-85.333333-42.666667-68.266667-25.6-85.333334-38.4 42.666667-51.2 110.933333-106.666667 136.533334-285.866666v-34.133334c0-8.533333 4.266667-8.533333 12.8-8.533333 21.333333-4.266667 42.666667-8.533333 59.733333-21.333333 55.466667-29.866667 76.8-81.066667 85.333333-145.066667 0-8.533333 0-17.066667-12.8-21.333333zM507.733333 746.666667c-85.333333-68.266667-123.733333-89.6-140.8-89.6-17.066667 0-12.8 21.333333-8.533333 29.866666 4.266667 12.8 8.533333 21.333333 12.8 29.866667 4.266667 8.533333 8.533333 17.066667-4.266667 25.6-25.6 17.066667-72.533333-4.266667-76.8-8.533333-55.466667-34.133333-98.133333-76.8-132.266666-136.533334-29.866667-51.2-46.933333-110.933333-46.933334-174.933333 0-17.066667 4.266667-21.333333 17.066667-25.6 21.333333-4.266667 42.666667-4.266667 59.733333 0 85.333333 12.8 157.866667 51.2 217.6 115.2 34.133333 34.133333 59.733333 76.8 89.6 119.466667 29.866667 42.666667 59.733333 85.333333 98.133334 119.466666 12.8 12.8 25.6 21.333333 34.133333 25.6-29.866667 0-81.066667 0-119.466667-29.866666z m166.4-196.266667c-8.533333 4.266667-17.066667 4.266667-25.6 4.266667-12.8 0-25.6-4.266667-29.866666-8.533334-12.8-8.533333-17.066667-12.8-21.333334-29.866666v-25.6c4.266667-12.8 0-21.333333-8.533333-29.866667-8.533333-4.266667-17.066667-8.533333-25.6-8.533333-4.266667 0-8.533333 0-8.533333-4.266667 0 0-4.266667 0-4.266667-4.266667v-4.266666-4.266667-4.266667c0-4.266667 8.533333-8.533333 8.533333-8.533333 12.8-8.533333 29.866667-4.266667 46.933334 0 12.8 4.266667 25.6 17.066667 38.4 29.866667 17.066667 17.066667 17.066667 25.6 25.6 38.4 8.533333 12.8 12.8 21.333333 17.066666 34.133333 0 12.8-4.266667 21.333333-12.8 25.6z" fill="#4D6BFE" p-id="5558"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">TJWater</title>
|
||||||
|
<rect width="72" height="72" rx="18" fill="#1478d4"/>
|
||||||
|
<path d="M36 13c-7.8 11.1-16.1 19.4-16.1 29.3A16.1 16.1 0 0 0 36 58.4a16.1 16.1 0 0 0 16.1-16.1C52.1 32.4 43.8 24.1 36 13Z" fill="#f5fbfc"/>
|
||||||
|
<path d="M26.5 43.5h19M31 36.5l5 7 5-7" fill="none" stroke="#0b8f82" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"/>
|
||||||
|
<circle cx="26.5" cy="43.5" r="2.7" fill="#0b8f82"/>
|
||||||
|
<circle cx="45.5" cy="43.5" r="2.7" fill="#0b8f82"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<rect width="1440" height="900" fill="#edf5f5"/>
|
||||||
|
<path d="M0 0h790L650 900H0Z" fill="#dceced"/>
|
||||||
|
<path d="M0 182c187-78 303-73 451-16 153 59 288 47 409-25M0 552c193-45 327-19 461 74 128 90 266 100 410 47" fill="none" stroke="#c8dddd" stroke-width="2"/>
|
||||||
|
<g fill="none" stroke="#a9ccce" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M-32 725 178 608l142 51 155-190 184 67 176-205" stroke-width="5"/>
|
||||||
|
<path d="m178 608 25-222 151-92 121 175" stroke-width="3"/>
|
||||||
|
<path d="m203 386-92-95 55-161M354 294l92-141 152 58 98-106" stroke-width="3"/>
|
||||||
|
<path d="m320 659-4 132 171 81M659 536l88 116 124-42" stroke-width="3"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#edf5f5" stroke="#1478d4" stroke-width="4">
|
||||||
|
<circle cx="178" cy="608" r="10"/><circle cx="203" cy="386" r="9"/>
|
||||||
|
<circle cx="354" cy="294" r="9"/><circle cx="475" cy="469" r="11"/>
|
||||||
|
<circle cx="659" cy="536" r="10"/><circle cx="747" cy="652" r="9"/>
|
||||||
|
<circle cx="320" cy="659" r="8"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#0b8f82">
|
||||||
|
<circle cx="111" cy="291" r="6"/><circle cx="166" cy="130" r="6"/>
|
||||||
|
<circle cx="446" cy="153" r="7"/><circle cx="598" cy="211" r="6"/>
|
||||||
|
<circle cx="696" cy="105" r="6"/><circle cx="316" cy="791" r="6"/>
|
||||||
|
<circle cx="487" cy="872" r="6"/><circle cx="835" cy="331" r="7"/>
|
||||||
|
</g>
|
||||||
|
<g fill="none" stroke="#86b8bb" stroke-width="2" opacity=".72">
|
||||||
|
<circle cx="615" cy="448" r="222"/><circle cx="615" cy="448" r="276"/>
|
||||||
|
<circle cx="615" cy="448" r="334"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#1478d4" opacity=".08">
|
||||||
|
<rect x="40" y="40" width="118" height="10" rx="5"/>
|
||||||
|
<rect x="40" y="62" width="72" height="6" rx="3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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,74 @@
|
|||||||
|
|
||||||
|
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 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 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,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();owned.forEach(m=>m.dispose());},pipes,nodes,arrows};
|
||||||
|
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=32-context-opacity"></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=32-default-scale';
|
||||||
|
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=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,57 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(new URL("../contracts/manifest.json", import.meta.url), "utf8"),
|
||||||
|
);
|
||||||
|
const run = promisify(execFile);
|
||||||
|
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||||
|
const generator = join(
|
||||||
|
projectRoot,
|
||||||
|
"node_modules",
|
||||||
|
"openapi-typescript",
|
||||||
|
"bin",
|
||||||
|
"cli.js",
|
||||||
|
);
|
||||||
|
const temporaryDirectory = await mkdtemp(
|
||||||
|
join(tmpdir(), "tjwater-api-contracts-"),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [name, contract] of Object.entries(manifest.contracts)) {
|
||||||
|
const contractPath = fileURLToPath(
|
||||||
|
new URL(`../contracts/${contract.file}`, import.meta.url),
|
||||||
|
);
|
||||||
|
const payload = await readFile(contractPath);
|
||||||
|
const actual = createHash("sha256").update(payload).digest("hex");
|
||||||
|
if (actual !== contract.sha256) {
|
||||||
|
throw new Error(
|
||||||
|
`${name} contract hash mismatch: expected ${contract.sha256}, got ${actual}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedName = `${name}Api.ts`;
|
||||||
|
const temporaryOutput = join(temporaryDirectory, generatedName);
|
||||||
|
await run(process.execPath, [generator, contractPath, "-o", temporaryOutput]);
|
||||||
|
const expectedOutput = await readFile(
|
||||||
|
new URL(`../src/generated/${generatedName}`, import.meta.url),
|
||||||
|
);
|
||||||
|
const generatedOutput = await readFile(temporaryOutput);
|
||||||
|
if (!expectedOutput.equals(generatedOutput)) {
|
||||||
|
throw new Error(
|
||||||
|
`${basename(contract.file)} generated type is stale: run \`npm run api:generate\``,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`validated API contract mirrors and generated types for ${manifest.contract_version}`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import nextEnv from "@next/env";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const projectDir = process.cwd();
|
||||||
|
const { loadEnvConfig } = nextEnv;
|
||||||
|
|
||||||
|
loadEnvConfig(projectDir, process.env.NODE_ENV !== "production");
|
||||||
|
|
||||||
|
const parseExtent = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return [13508801.93, 3608163.35, 13555650.64, 3633685.14];
|
||||||
|
}
|
||||||
|
|
||||||
|
const extent = value.split(",").map(Number);
|
||||||
|
return extent.length === 4 && extent.every(Number.isFinite)
|
||||||
|
? extent
|
||||||
|
: [13508801.93, 3608163.35, 13555650.64, 3633685.14];
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
|
||||||
|
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
|
||||||
|
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
|
||||||
|
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater_next",
|
||||||
|
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
|
||||||
|
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater_next",
|
||||||
|
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
|
||||||
|
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const outputPath = path.join(projectDir, "public", "runtime-config.js");
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
outputPath,
|
||||||
|
`window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Generated ${path.relative(projectDir, outputPath)}`);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
echo "Usage: bash scripts/trigger-gitea-pipeline.sh [remote] [tag]"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " bash scripts/trigger-gitea-pipeline.sh"
|
||||||
|
echo " bash scripts/trigger-gitea-pipeline.sh gitea latest"
|
||||||
|
echo " bash scripts/trigger-gitea-pipeline.sh gitea v2026.05.15.1"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
REMOTE="${1:-gitea}"
|
||||||
|
TAG="${2:-latest}"
|
||||||
|
|
||||||
|
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Current directory is not a git repository."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Remote '$REMOTE' does not exist."
|
||||||
|
echo "Available remotes:"
|
||||||
|
git remote -v
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEAD_SHA="$(git rev-parse --short HEAD)"
|
||||||
|
MESSAGE="manual trigger: ${TAG} $(date '+%F %T')"
|
||||||
|
|
||||||
|
echo "[INFO] HEAD: ${HEAD_SHA}"
|
||||||
|
echo "[INFO] Recreate annotated tag '${TAG}'"
|
||||||
|
git tag -fa "$TAG" -m "$MESSAGE"
|
||||||
|
|
||||||
|
echo "[INFO] Push '${TAG}' to remote '${REMOTE}' (force update)"
|
||||||
|
git push "$REMOTE" "refs/tags/${TAG}" --force
|
||||||
|
|
||||||
|
echo "[INFO] Verify remote tag reference"
|
||||||
|
git ls-remote --tags "$REMOTE" "refs/tags/${TAG}"
|
||||||
|
|
||||||
|
echo "[DONE] Pipeline trigger request sent by updating tag '${TAG}'."
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton, MapTimelineSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
||||||
|
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||||
|
import {
|
||||||
|
RAINBOW_COLORS,
|
||||||
|
RISK_BREAKS,
|
||||||
|
} from "@components/olmap/HealthRiskAnalysis/types";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
const Timeline = dynamic(
|
||||||
|
() => import("@components/olmap/HealthRiskAnalysis/Timeline"),
|
||||||
|
{
|
||||||
|
loading: () => <MapTimelineSkeleton />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const HealthRiskStatistics = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@components/olmap/HealthRiskAnalysis/HealthRiskStatistics"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="health-risk-analysis" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const PredictDataPanel = dynamic(
|
||||||
|
() => import("@components/olmap/HealthRiskAnalysis/PredictDataPanel"),
|
||||||
|
{
|
||||||
|
loading: () => null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<HealthRiskProvider>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="realtime"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
HistoryPanel={PredictDataPanel}
|
||||||
|
/>
|
||||||
|
<Timeline />
|
||||||
|
<HealthRiskStatistics />
|
||||||
|
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
|
||||||
|
<StyleLegend
|
||||||
|
layerName="管道"
|
||||||
|
layerId="health-risk"
|
||||||
|
property="健康风险"
|
||||||
|
colors={RAINBOW_COLORS}
|
||||||
|
type="line"
|
||||||
|
dimensions={Array(RAINBOW_COLORS.length).fill(2)}
|
||||||
|
breaks={[0, ...RISK_BREAKS]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</HealthRiskProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const BurstDetectionPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/BurstDetection/BurstDetectionPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="burst-detection" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="burst_detection"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
/>
|
||||||
|
<BurstDetectionPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const BurstLocationPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/BurstLocation/BurstLocationPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="burst-location" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="burst_location"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
/>
|
||||||
|
<BurstLocationPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const BurstPipeAnalysisPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="burst-simulation" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="burst_analysis"
|
||||||
|
enableCompare
|
||||||
|
/>
|
||||||
|
<BurstPipeAnalysisPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const WaterQualityPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/ContaminantSimulation/WaterQualityPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="contaminant-simulation" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="contaminant_analysis"
|
||||||
|
enableCompare
|
||||||
|
/>
|
||||||
|
<WaterQualityPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const DMALeakDetectionPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="dma-leak-detection" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="dma_leak_identification"
|
||||||
|
hiddenButtons={["style"]}
|
||||||
|
/>
|
||||||
|
<DMALeakDetectionPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
|
const FlushingAnalysisPanel = dynamic(
|
||||||
|
() => import("@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"),
|
||||||
|
{
|
||||||
|
loading: () => <MapPanelSkeleton variant="flushing-analysis" />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapToolbar queryType="scheme" schemeType="flushing_analysis" />
|
||||||
|
<FlushingAnalysisPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user