commit 57415a1a0bdb18afb236546ea8218ef105b16573 Author: Codex Date: Thu May 21 11:18:50 2026 +0800 2026-05-21-11-13-49 独立Docker程序包 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..107420e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +node_modules +WebSite/node_modules +WebSite/dist +WebSite/data +WebSite/exports +data +exports +WebSite/.env +.env +.env.* +*.log +*.tmp +*.bak + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7568238 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules/ +WebSite/node_modules/ + +# Build output +dist/ +WebSite/dist/ + +# Runtime backend state and generated exports +data/ +exports/ +WebSite/data/ +WebSite/exports/ + +# Local env +.env +.env.* +WebSite/.env +WebSite/.env.* + +# Logs and OS/editor noise +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store +Thumbs.db +.vscode/ +.idea/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cff47bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim + +WORKDIR /app + +ENV PORT=4000 +ENV HOST=0.0.0.0 + +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY + +COPY WebSite/package*.json ./WebSite/ + +WORKDIR /app/WebSite +RUN npm ci + +WORKDIR /app +COPY WebSite ./WebSite +COPY Head_CT_DICOM ./Head_CT_DICOM +COPY Head_CT_ReConstruct ./Head_CT_ReConstruct + +WORKDIR /app/WebSite +RUN npm run build && mkdir -p data exports + +ENV NODE_ENV=production + +EXPOSE 4000 + +HEALTHCHECK --interval=10s --timeout=5s --retries=12 --start-period=20s \ + CMD node -e "fetch('http://127.0.0.1:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["npm", "run", "serve", "--", "--host", "0.0.0.0", "--port", "4000"] + diff --git a/Head_CT_DICOM/1.dcm b/Head_CT_DICOM/1.dcm new file mode 100644 index 0000000..13dd1cf Binary files /dev/null and b/Head_CT_DICOM/1.dcm differ diff --git a/Head_CT_DICOM/10.dcm b/Head_CT_DICOM/10.dcm new file mode 100644 index 0000000..640fbdf Binary files /dev/null and b/Head_CT_DICOM/10.dcm differ diff --git a/Head_CT_DICOM/100.dcm b/Head_CT_DICOM/100.dcm new file mode 100644 index 0000000..c89ce30 Binary files /dev/null and b/Head_CT_DICOM/100.dcm differ diff --git a/Head_CT_DICOM/101.dcm b/Head_CT_DICOM/101.dcm new file mode 100644 index 0000000..9d3653e Binary files /dev/null and b/Head_CT_DICOM/101.dcm differ diff --git a/Head_CT_DICOM/102.dcm b/Head_CT_DICOM/102.dcm new file mode 100644 index 0000000..a64cd2f Binary files /dev/null and b/Head_CT_DICOM/102.dcm differ diff --git a/Head_CT_DICOM/103.dcm b/Head_CT_DICOM/103.dcm new file mode 100644 index 0000000..ff24e00 Binary files /dev/null and b/Head_CT_DICOM/103.dcm differ diff --git a/Head_CT_DICOM/104.dcm b/Head_CT_DICOM/104.dcm new file mode 100644 index 0000000..ea647fc Binary files /dev/null and b/Head_CT_DICOM/104.dcm differ diff --git a/Head_CT_DICOM/105.dcm b/Head_CT_DICOM/105.dcm new file mode 100644 index 0000000..8ed49bc Binary files /dev/null and b/Head_CT_DICOM/105.dcm differ diff --git a/Head_CT_DICOM/106.dcm b/Head_CT_DICOM/106.dcm new file mode 100644 index 0000000..2a8096e Binary files /dev/null and b/Head_CT_DICOM/106.dcm differ diff --git a/Head_CT_DICOM/107.dcm b/Head_CT_DICOM/107.dcm new file mode 100644 index 0000000..88abac6 Binary files /dev/null and b/Head_CT_DICOM/107.dcm differ diff --git a/Head_CT_DICOM/108.dcm b/Head_CT_DICOM/108.dcm new file mode 100644 index 0000000..511dbfb Binary files /dev/null and b/Head_CT_DICOM/108.dcm differ diff --git a/Head_CT_DICOM/109.dcm b/Head_CT_DICOM/109.dcm new file mode 100644 index 0000000..77f6ee7 Binary files /dev/null and b/Head_CT_DICOM/109.dcm differ diff --git a/Head_CT_DICOM/11.dcm b/Head_CT_DICOM/11.dcm new file mode 100644 index 0000000..5d3e7df Binary files /dev/null and b/Head_CT_DICOM/11.dcm differ diff --git a/Head_CT_DICOM/110.dcm b/Head_CT_DICOM/110.dcm new file mode 100644 index 0000000..58fbead Binary files /dev/null and b/Head_CT_DICOM/110.dcm differ diff --git a/Head_CT_DICOM/111.dcm b/Head_CT_DICOM/111.dcm new file mode 100644 index 0000000..137c475 Binary files /dev/null and b/Head_CT_DICOM/111.dcm differ diff --git a/Head_CT_DICOM/112.dcm b/Head_CT_DICOM/112.dcm new file mode 100644 index 0000000..4699ebe Binary files /dev/null and b/Head_CT_DICOM/112.dcm differ diff --git a/Head_CT_DICOM/113.dcm b/Head_CT_DICOM/113.dcm new file mode 100644 index 0000000..f45d0a1 Binary files /dev/null and b/Head_CT_DICOM/113.dcm differ diff --git a/Head_CT_DICOM/114.dcm b/Head_CT_DICOM/114.dcm new file mode 100644 index 0000000..dca3f2b Binary files /dev/null and b/Head_CT_DICOM/114.dcm differ diff --git a/Head_CT_DICOM/115.dcm b/Head_CT_DICOM/115.dcm new file mode 100644 index 0000000..9a7396a Binary files /dev/null and b/Head_CT_DICOM/115.dcm differ diff --git a/Head_CT_DICOM/116.dcm b/Head_CT_DICOM/116.dcm new file mode 100644 index 0000000..a315120 Binary files /dev/null and b/Head_CT_DICOM/116.dcm differ diff --git a/Head_CT_DICOM/117.dcm b/Head_CT_DICOM/117.dcm new file mode 100644 index 0000000..11cdb80 Binary files /dev/null and b/Head_CT_DICOM/117.dcm differ diff --git a/Head_CT_DICOM/118.dcm b/Head_CT_DICOM/118.dcm new file mode 100644 index 0000000..c299ade Binary files /dev/null and b/Head_CT_DICOM/118.dcm differ diff --git a/Head_CT_DICOM/119.dcm b/Head_CT_DICOM/119.dcm new file mode 100644 index 0000000..0a55d54 Binary files /dev/null and b/Head_CT_DICOM/119.dcm differ diff --git a/Head_CT_DICOM/12.dcm b/Head_CT_DICOM/12.dcm new file mode 100644 index 0000000..bf83ab0 Binary files /dev/null and b/Head_CT_DICOM/12.dcm differ diff --git a/Head_CT_DICOM/120.dcm b/Head_CT_DICOM/120.dcm new file mode 100644 index 0000000..9a287d1 Binary files /dev/null and b/Head_CT_DICOM/120.dcm differ diff --git a/Head_CT_DICOM/121.dcm b/Head_CT_DICOM/121.dcm new file mode 100644 index 0000000..7f0a42d Binary files /dev/null and b/Head_CT_DICOM/121.dcm differ diff --git a/Head_CT_DICOM/122.dcm b/Head_CT_DICOM/122.dcm new file mode 100644 index 0000000..0a91416 Binary files /dev/null and b/Head_CT_DICOM/122.dcm differ diff --git a/Head_CT_DICOM/123.dcm b/Head_CT_DICOM/123.dcm new file mode 100644 index 0000000..0bebc57 Binary files /dev/null and b/Head_CT_DICOM/123.dcm differ diff --git a/Head_CT_DICOM/124.dcm b/Head_CT_DICOM/124.dcm new file mode 100644 index 0000000..0faf36c Binary files /dev/null and b/Head_CT_DICOM/124.dcm differ diff --git a/Head_CT_DICOM/125.dcm b/Head_CT_DICOM/125.dcm new file mode 100644 index 0000000..ab99528 Binary files /dev/null and b/Head_CT_DICOM/125.dcm differ diff --git a/Head_CT_DICOM/126.dcm b/Head_CT_DICOM/126.dcm new file mode 100644 index 0000000..f3c606c Binary files /dev/null and b/Head_CT_DICOM/126.dcm differ diff --git a/Head_CT_DICOM/127.dcm b/Head_CT_DICOM/127.dcm new file mode 100644 index 0000000..9ef177b Binary files /dev/null and b/Head_CT_DICOM/127.dcm differ diff --git a/Head_CT_DICOM/128.dcm b/Head_CT_DICOM/128.dcm new file mode 100644 index 0000000..9121696 Binary files /dev/null and b/Head_CT_DICOM/128.dcm differ diff --git a/Head_CT_DICOM/129.dcm b/Head_CT_DICOM/129.dcm new file mode 100644 index 0000000..d6f9e75 Binary files /dev/null and b/Head_CT_DICOM/129.dcm differ diff --git a/Head_CT_DICOM/13.dcm b/Head_CT_DICOM/13.dcm new file mode 100644 index 0000000..dc8c538 Binary files /dev/null and b/Head_CT_DICOM/13.dcm differ diff --git a/Head_CT_DICOM/130.dcm b/Head_CT_DICOM/130.dcm new file mode 100644 index 0000000..58b09cb Binary files /dev/null and b/Head_CT_DICOM/130.dcm differ diff --git a/Head_CT_DICOM/131.dcm b/Head_CT_DICOM/131.dcm new file mode 100644 index 0000000..faef818 Binary files /dev/null and b/Head_CT_DICOM/131.dcm differ diff --git a/Head_CT_DICOM/132.dcm b/Head_CT_DICOM/132.dcm new file mode 100644 index 0000000..39f932a Binary files /dev/null and b/Head_CT_DICOM/132.dcm differ diff --git a/Head_CT_DICOM/133.dcm b/Head_CT_DICOM/133.dcm new file mode 100644 index 0000000..3da1a7a Binary files /dev/null and b/Head_CT_DICOM/133.dcm differ diff --git a/Head_CT_DICOM/134.dcm b/Head_CT_DICOM/134.dcm new file mode 100644 index 0000000..38258ec Binary files /dev/null and b/Head_CT_DICOM/134.dcm differ diff --git a/Head_CT_DICOM/135.dcm b/Head_CT_DICOM/135.dcm new file mode 100644 index 0000000..bfb4284 Binary files /dev/null and b/Head_CT_DICOM/135.dcm differ diff --git a/Head_CT_DICOM/136.dcm b/Head_CT_DICOM/136.dcm new file mode 100644 index 0000000..76f898c Binary files /dev/null and b/Head_CT_DICOM/136.dcm differ diff --git a/Head_CT_DICOM/137.dcm b/Head_CT_DICOM/137.dcm new file mode 100644 index 0000000..cbe88d8 Binary files /dev/null and b/Head_CT_DICOM/137.dcm differ diff --git a/Head_CT_DICOM/138.dcm b/Head_CT_DICOM/138.dcm new file mode 100644 index 0000000..bb819b8 Binary files /dev/null and b/Head_CT_DICOM/138.dcm differ diff --git a/Head_CT_DICOM/139.dcm b/Head_CT_DICOM/139.dcm new file mode 100644 index 0000000..465b865 Binary files /dev/null and b/Head_CT_DICOM/139.dcm differ diff --git a/Head_CT_DICOM/14.dcm b/Head_CT_DICOM/14.dcm new file mode 100644 index 0000000..1322baa Binary files /dev/null and b/Head_CT_DICOM/14.dcm differ diff --git a/Head_CT_DICOM/140.dcm b/Head_CT_DICOM/140.dcm new file mode 100644 index 0000000..0239572 Binary files /dev/null and b/Head_CT_DICOM/140.dcm differ diff --git a/Head_CT_DICOM/141.dcm b/Head_CT_DICOM/141.dcm new file mode 100644 index 0000000..3619ed8 Binary files /dev/null and b/Head_CT_DICOM/141.dcm differ diff --git a/Head_CT_DICOM/142.dcm b/Head_CT_DICOM/142.dcm new file mode 100644 index 0000000..2709849 Binary files /dev/null and b/Head_CT_DICOM/142.dcm differ diff --git a/Head_CT_DICOM/143.dcm b/Head_CT_DICOM/143.dcm new file mode 100644 index 0000000..f46706d Binary files /dev/null and b/Head_CT_DICOM/143.dcm differ diff --git a/Head_CT_DICOM/144.dcm b/Head_CT_DICOM/144.dcm new file mode 100644 index 0000000..04744f5 Binary files /dev/null and b/Head_CT_DICOM/144.dcm differ diff --git a/Head_CT_DICOM/145.dcm b/Head_CT_DICOM/145.dcm new file mode 100644 index 0000000..c4d351c Binary files /dev/null and b/Head_CT_DICOM/145.dcm differ diff --git a/Head_CT_DICOM/146.dcm b/Head_CT_DICOM/146.dcm new file mode 100644 index 0000000..7f71428 Binary files /dev/null and b/Head_CT_DICOM/146.dcm differ diff --git a/Head_CT_DICOM/147.dcm b/Head_CT_DICOM/147.dcm new file mode 100644 index 0000000..44009ff Binary files /dev/null and b/Head_CT_DICOM/147.dcm differ diff --git a/Head_CT_DICOM/148.dcm b/Head_CT_DICOM/148.dcm new file mode 100644 index 0000000..8aa3d12 Binary files /dev/null and b/Head_CT_DICOM/148.dcm differ diff --git a/Head_CT_DICOM/149.dcm b/Head_CT_DICOM/149.dcm new file mode 100644 index 0000000..ae4c733 Binary files /dev/null and b/Head_CT_DICOM/149.dcm differ diff --git a/Head_CT_DICOM/15.dcm b/Head_CT_DICOM/15.dcm new file mode 100644 index 0000000..c1b235f Binary files /dev/null and b/Head_CT_DICOM/15.dcm differ diff --git a/Head_CT_DICOM/150.dcm b/Head_CT_DICOM/150.dcm new file mode 100644 index 0000000..b4cb604 Binary files /dev/null and b/Head_CT_DICOM/150.dcm differ diff --git a/Head_CT_DICOM/151.dcm b/Head_CT_DICOM/151.dcm new file mode 100644 index 0000000..f241d9a Binary files /dev/null and b/Head_CT_DICOM/151.dcm differ diff --git a/Head_CT_DICOM/152.dcm b/Head_CT_DICOM/152.dcm new file mode 100644 index 0000000..0558a86 Binary files /dev/null and b/Head_CT_DICOM/152.dcm differ diff --git a/Head_CT_DICOM/153.dcm b/Head_CT_DICOM/153.dcm new file mode 100644 index 0000000..11efd2d Binary files /dev/null and b/Head_CT_DICOM/153.dcm differ diff --git a/Head_CT_DICOM/154.dcm b/Head_CT_DICOM/154.dcm new file mode 100644 index 0000000..e19268e Binary files /dev/null and b/Head_CT_DICOM/154.dcm differ diff --git a/Head_CT_DICOM/155.dcm b/Head_CT_DICOM/155.dcm new file mode 100644 index 0000000..ec487c1 Binary files /dev/null and b/Head_CT_DICOM/155.dcm differ diff --git a/Head_CT_DICOM/156.dcm b/Head_CT_DICOM/156.dcm new file mode 100644 index 0000000..990baba Binary files /dev/null and b/Head_CT_DICOM/156.dcm differ diff --git a/Head_CT_DICOM/157.dcm b/Head_CT_DICOM/157.dcm new file mode 100644 index 0000000..c1c6572 Binary files /dev/null and b/Head_CT_DICOM/157.dcm differ diff --git a/Head_CT_DICOM/158.dcm b/Head_CT_DICOM/158.dcm new file mode 100644 index 0000000..d9e047f Binary files /dev/null and b/Head_CT_DICOM/158.dcm differ diff --git a/Head_CT_DICOM/159.dcm b/Head_CT_DICOM/159.dcm new file mode 100644 index 0000000..4b48aa3 Binary files /dev/null and b/Head_CT_DICOM/159.dcm differ diff --git a/Head_CT_DICOM/16.dcm b/Head_CT_DICOM/16.dcm new file mode 100644 index 0000000..1670cbc Binary files /dev/null and b/Head_CT_DICOM/16.dcm differ diff --git a/Head_CT_DICOM/160.dcm b/Head_CT_DICOM/160.dcm new file mode 100644 index 0000000..4cff048 Binary files /dev/null and b/Head_CT_DICOM/160.dcm differ diff --git a/Head_CT_DICOM/161.dcm b/Head_CT_DICOM/161.dcm new file mode 100644 index 0000000..c852e44 Binary files /dev/null and b/Head_CT_DICOM/161.dcm differ diff --git a/Head_CT_DICOM/162.dcm b/Head_CT_DICOM/162.dcm new file mode 100644 index 0000000..e99d335 Binary files /dev/null and b/Head_CT_DICOM/162.dcm differ diff --git a/Head_CT_DICOM/163.dcm b/Head_CT_DICOM/163.dcm new file mode 100644 index 0000000..6fae2a9 Binary files /dev/null and b/Head_CT_DICOM/163.dcm differ diff --git a/Head_CT_DICOM/164.dcm b/Head_CT_DICOM/164.dcm new file mode 100644 index 0000000..cd40e6b Binary files /dev/null and b/Head_CT_DICOM/164.dcm differ diff --git a/Head_CT_DICOM/165.dcm b/Head_CT_DICOM/165.dcm new file mode 100644 index 0000000..146325c Binary files /dev/null and b/Head_CT_DICOM/165.dcm differ diff --git a/Head_CT_DICOM/166.dcm b/Head_CT_DICOM/166.dcm new file mode 100644 index 0000000..bac11a2 Binary files /dev/null and b/Head_CT_DICOM/166.dcm differ diff --git a/Head_CT_DICOM/167.dcm b/Head_CT_DICOM/167.dcm new file mode 100644 index 0000000..fa12c49 Binary files /dev/null and b/Head_CT_DICOM/167.dcm differ diff --git a/Head_CT_DICOM/168.dcm b/Head_CT_DICOM/168.dcm new file mode 100644 index 0000000..a4c01fa Binary files /dev/null and b/Head_CT_DICOM/168.dcm differ diff --git a/Head_CT_DICOM/169.dcm b/Head_CT_DICOM/169.dcm new file mode 100644 index 0000000..5f042f1 Binary files /dev/null and b/Head_CT_DICOM/169.dcm differ diff --git a/Head_CT_DICOM/17.dcm b/Head_CT_DICOM/17.dcm new file mode 100644 index 0000000..c3c1976 Binary files /dev/null and b/Head_CT_DICOM/17.dcm differ diff --git a/Head_CT_DICOM/170.dcm b/Head_CT_DICOM/170.dcm new file mode 100644 index 0000000..24b38e5 Binary files /dev/null and b/Head_CT_DICOM/170.dcm differ diff --git a/Head_CT_DICOM/171.dcm b/Head_CT_DICOM/171.dcm new file mode 100644 index 0000000..026bf41 Binary files /dev/null and b/Head_CT_DICOM/171.dcm differ diff --git a/Head_CT_DICOM/172.dcm b/Head_CT_DICOM/172.dcm new file mode 100644 index 0000000..5b13db7 Binary files /dev/null and b/Head_CT_DICOM/172.dcm differ diff --git a/Head_CT_DICOM/173.dcm b/Head_CT_DICOM/173.dcm new file mode 100644 index 0000000..73a0870 Binary files /dev/null and b/Head_CT_DICOM/173.dcm differ diff --git a/Head_CT_DICOM/174.dcm b/Head_CT_DICOM/174.dcm new file mode 100644 index 0000000..959f587 Binary files /dev/null and b/Head_CT_DICOM/174.dcm differ diff --git a/Head_CT_DICOM/175.dcm b/Head_CT_DICOM/175.dcm new file mode 100644 index 0000000..60efc34 Binary files /dev/null and b/Head_CT_DICOM/175.dcm differ diff --git a/Head_CT_DICOM/176.dcm b/Head_CT_DICOM/176.dcm new file mode 100644 index 0000000..08a7024 Binary files /dev/null and b/Head_CT_DICOM/176.dcm differ diff --git a/Head_CT_DICOM/177.dcm b/Head_CT_DICOM/177.dcm new file mode 100644 index 0000000..319e057 Binary files /dev/null and b/Head_CT_DICOM/177.dcm differ diff --git a/Head_CT_DICOM/178.dcm b/Head_CT_DICOM/178.dcm new file mode 100644 index 0000000..0ee2078 Binary files /dev/null and b/Head_CT_DICOM/178.dcm differ diff --git a/Head_CT_DICOM/179.dcm b/Head_CT_DICOM/179.dcm new file mode 100644 index 0000000..f750f27 Binary files /dev/null and b/Head_CT_DICOM/179.dcm differ diff --git a/Head_CT_DICOM/18.dcm b/Head_CT_DICOM/18.dcm new file mode 100644 index 0000000..87984f7 Binary files /dev/null and b/Head_CT_DICOM/18.dcm differ diff --git a/Head_CT_DICOM/180.dcm b/Head_CT_DICOM/180.dcm new file mode 100644 index 0000000..82eb4b4 Binary files /dev/null and b/Head_CT_DICOM/180.dcm differ diff --git a/Head_CT_DICOM/181.dcm b/Head_CT_DICOM/181.dcm new file mode 100644 index 0000000..3b57869 Binary files /dev/null and b/Head_CT_DICOM/181.dcm differ diff --git a/Head_CT_DICOM/182.dcm b/Head_CT_DICOM/182.dcm new file mode 100644 index 0000000..c6eefa3 Binary files /dev/null and b/Head_CT_DICOM/182.dcm differ diff --git a/Head_CT_DICOM/183.dcm b/Head_CT_DICOM/183.dcm new file mode 100644 index 0000000..0339d6c Binary files /dev/null and b/Head_CT_DICOM/183.dcm differ diff --git a/Head_CT_DICOM/184.dcm b/Head_CT_DICOM/184.dcm new file mode 100644 index 0000000..c5e061c Binary files /dev/null and b/Head_CT_DICOM/184.dcm differ diff --git a/Head_CT_DICOM/185.dcm b/Head_CT_DICOM/185.dcm new file mode 100644 index 0000000..76c9e0b Binary files /dev/null and b/Head_CT_DICOM/185.dcm differ diff --git a/Head_CT_DICOM/186.dcm b/Head_CT_DICOM/186.dcm new file mode 100644 index 0000000..b4e0cda Binary files /dev/null and b/Head_CT_DICOM/186.dcm differ diff --git a/Head_CT_DICOM/187.dcm b/Head_CT_DICOM/187.dcm new file mode 100644 index 0000000..1d78339 Binary files /dev/null and b/Head_CT_DICOM/187.dcm differ diff --git a/Head_CT_DICOM/188.dcm b/Head_CT_DICOM/188.dcm new file mode 100644 index 0000000..df933cc Binary files /dev/null and b/Head_CT_DICOM/188.dcm differ diff --git a/Head_CT_DICOM/189.dcm b/Head_CT_DICOM/189.dcm new file mode 100644 index 0000000..9739302 Binary files /dev/null and b/Head_CT_DICOM/189.dcm differ diff --git a/Head_CT_DICOM/19.dcm b/Head_CT_DICOM/19.dcm new file mode 100644 index 0000000..2931f6e Binary files /dev/null and b/Head_CT_DICOM/19.dcm differ diff --git a/Head_CT_DICOM/190.dcm b/Head_CT_DICOM/190.dcm new file mode 100644 index 0000000..620f47e Binary files /dev/null and b/Head_CT_DICOM/190.dcm differ diff --git a/Head_CT_DICOM/191.dcm b/Head_CT_DICOM/191.dcm new file mode 100644 index 0000000..de5052b Binary files /dev/null and b/Head_CT_DICOM/191.dcm differ diff --git a/Head_CT_DICOM/192.dcm b/Head_CT_DICOM/192.dcm new file mode 100644 index 0000000..5690a2b Binary files /dev/null and b/Head_CT_DICOM/192.dcm differ diff --git a/Head_CT_DICOM/193.dcm b/Head_CT_DICOM/193.dcm new file mode 100644 index 0000000..13d5b07 Binary files /dev/null and b/Head_CT_DICOM/193.dcm differ diff --git a/Head_CT_DICOM/194.dcm b/Head_CT_DICOM/194.dcm new file mode 100644 index 0000000..8347685 Binary files /dev/null and b/Head_CT_DICOM/194.dcm differ diff --git a/Head_CT_DICOM/195.dcm b/Head_CT_DICOM/195.dcm new file mode 100644 index 0000000..c7962a2 Binary files /dev/null and b/Head_CT_DICOM/195.dcm differ diff --git a/Head_CT_DICOM/196.dcm b/Head_CT_DICOM/196.dcm new file mode 100644 index 0000000..d7b73cf Binary files /dev/null and b/Head_CT_DICOM/196.dcm differ diff --git a/Head_CT_DICOM/197.dcm b/Head_CT_DICOM/197.dcm new file mode 100644 index 0000000..96a85df Binary files /dev/null and b/Head_CT_DICOM/197.dcm differ diff --git a/Head_CT_DICOM/198.dcm b/Head_CT_DICOM/198.dcm new file mode 100644 index 0000000..b152bac Binary files /dev/null and b/Head_CT_DICOM/198.dcm differ diff --git a/Head_CT_DICOM/199.dcm b/Head_CT_DICOM/199.dcm new file mode 100644 index 0000000..088716f Binary files /dev/null and b/Head_CT_DICOM/199.dcm differ diff --git a/Head_CT_DICOM/2.dcm b/Head_CT_DICOM/2.dcm new file mode 100644 index 0000000..3637dad Binary files /dev/null and b/Head_CT_DICOM/2.dcm differ diff --git a/Head_CT_DICOM/20.dcm b/Head_CT_DICOM/20.dcm new file mode 100644 index 0000000..85b553c Binary files /dev/null and b/Head_CT_DICOM/20.dcm differ diff --git a/Head_CT_DICOM/200.dcm b/Head_CT_DICOM/200.dcm new file mode 100644 index 0000000..48b8ad5 Binary files /dev/null and b/Head_CT_DICOM/200.dcm differ diff --git a/Head_CT_DICOM/201.dcm b/Head_CT_DICOM/201.dcm new file mode 100644 index 0000000..78d453f Binary files /dev/null and b/Head_CT_DICOM/201.dcm differ diff --git a/Head_CT_DICOM/202.dcm b/Head_CT_DICOM/202.dcm new file mode 100644 index 0000000..16cd5fc Binary files /dev/null and b/Head_CT_DICOM/202.dcm differ diff --git a/Head_CT_DICOM/203.dcm b/Head_CT_DICOM/203.dcm new file mode 100644 index 0000000..2b823ba Binary files /dev/null and b/Head_CT_DICOM/203.dcm differ diff --git a/Head_CT_DICOM/204.dcm b/Head_CT_DICOM/204.dcm new file mode 100644 index 0000000..5c31835 Binary files /dev/null and b/Head_CT_DICOM/204.dcm differ diff --git a/Head_CT_DICOM/205.dcm b/Head_CT_DICOM/205.dcm new file mode 100644 index 0000000..e77ce11 Binary files /dev/null and b/Head_CT_DICOM/205.dcm differ diff --git a/Head_CT_DICOM/206.dcm b/Head_CT_DICOM/206.dcm new file mode 100644 index 0000000..d8cea43 Binary files /dev/null and b/Head_CT_DICOM/206.dcm differ diff --git a/Head_CT_DICOM/207.dcm b/Head_CT_DICOM/207.dcm new file mode 100644 index 0000000..f679f55 Binary files /dev/null and b/Head_CT_DICOM/207.dcm differ diff --git a/Head_CT_DICOM/208.dcm b/Head_CT_DICOM/208.dcm new file mode 100644 index 0000000..a87e028 Binary files /dev/null and b/Head_CT_DICOM/208.dcm differ diff --git a/Head_CT_DICOM/209.dcm b/Head_CT_DICOM/209.dcm new file mode 100644 index 0000000..464a142 Binary files /dev/null and b/Head_CT_DICOM/209.dcm differ diff --git a/Head_CT_DICOM/21.dcm b/Head_CT_DICOM/21.dcm new file mode 100644 index 0000000..af3f470 Binary files /dev/null and b/Head_CT_DICOM/21.dcm differ diff --git a/Head_CT_DICOM/210.dcm b/Head_CT_DICOM/210.dcm new file mode 100644 index 0000000..0e179dd Binary files /dev/null and b/Head_CT_DICOM/210.dcm differ diff --git a/Head_CT_DICOM/211.dcm b/Head_CT_DICOM/211.dcm new file mode 100644 index 0000000..cff043f Binary files /dev/null and b/Head_CT_DICOM/211.dcm differ diff --git a/Head_CT_DICOM/212.dcm b/Head_CT_DICOM/212.dcm new file mode 100644 index 0000000..ab8c11b Binary files /dev/null and b/Head_CT_DICOM/212.dcm differ diff --git a/Head_CT_DICOM/213.dcm b/Head_CT_DICOM/213.dcm new file mode 100644 index 0000000..d87d888 Binary files /dev/null and b/Head_CT_DICOM/213.dcm differ diff --git a/Head_CT_DICOM/214.dcm b/Head_CT_DICOM/214.dcm new file mode 100644 index 0000000..511605b Binary files /dev/null and b/Head_CT_DICOM/214.dcm differ diff --git a/Head_CT_DICOM/215.dcm b/Head_CT_DICOM/215.dcm new file mode 100644 index 0000000..f12da29 Binary files /dev/null and b/Head_CT_DICOM/215.dcm differ diff --git a/Head_CT_DICOM/216.dcm b/Head_CT_DICOM/216.dcm new file mode 100644 index 0000000..e593bf2 Binary files /dev/null and b/Head_CT_DICOM/216.dcm differ diff --git a/Head_CT_DICOM/217.dcm b/Head_CT_DICOM/217.dcm new file mode 100644 index 0000000..bf68c6e Binary files /dev/null and b/Head_CT_DICOM/217.dcm differ diff --git a/Head_CT_DICOM/218.dcm b/Head_CT_DICOM/218.dcm new file mode 100644 index 0000000..64554f0 Binary files /dev/null and b/Head_CT_DICOM/218.dcm differ diff --git a/Head_CT_DICOM/219.dcm b/Head_CT_DICOM/219.dcm new file mode 100644 index 0000000..d3c62ab Binary files /dev/null and b/Head_CT_DICOM/219.dcm differ diff --git a/Head_CT_DICOM/22.dcm b/Head_CT_DICOM/22.dcm new file mode 100644 index 0000000..a01ca1b Binary files /dev/null and b/Head_CT_DICOM/22.dcm differ diff --git a/Head_CT_DICOM/220.dcm b/Head_CT_DICOM/220.dcm new file mode 100644 index 0000000..4f4d496 Binary files /dev/null and b/Head_CT_DICOM/220.dcm differ diff --git a/Head_CT_DICOM/221.dcm b/Head_CT_DICOM/221.dcm new file mode 100644 index 0000000..d3c5938 Binary files /dev/null and b/Head_CT_DICOM/221.dcm differ diff --git a/Head_CT_DICOM/222.dcm b/Head_CT_DICOM/222.dcm new file mode 100644 index 0000000..0e861eb Binary files /dev/null and b/Head_CT_DICOM/222.dcm differ diff --git a/Head_CT_DICOM/223.dcm b/Head_CT_DICOM/223.dcm new file mode 100644 index 0000000..1052fb7 Binary files /dev/null and b/Head_CT_DICOM/223.dcm differ diff --git a/Head_CT_DICOM/224.dcm b/Head_CT_DICOM/224.dcm new file mode 100644 index 0000000..c59c02e Binary files /dev/null and b/Head_CT_DICOM/224.dcm differ diff --git a/Head_CT_DICOM/225.dcm b/Head_CT_DICOM/225.dcm new file mode 100644 index 0000000..c4fb077 Binary files /dev/null and b/Head_CT_DICOM/225.dcm differ diff --git a/Head_CT_DICOM/226.dcm b/Head_CT_DICOM/226.dcm new file mode 100644 index 0000000..25dd0a1 Binary files /dev/null and b/Head_CT_DICOM/226.dcm differ diff --git a/Head_CT_DICOM/227.dcm b/Head_CT_DICOM/227.dcm new file mode 100644 index 0000000..c3e8262 Binary files /dev/null and b/Head_CT_DICOM/227.dcm differ diff --git a/Head_CT_DICOM/228.dcm b/Head_CT_DICOM/228.dcm new file mode 100644 index 0000000..2ac3d1a Binary files /dev/null and b/Head_CT_DICOM/228.dcm differ diff --git a/Head_CT_DICOM/229.dcm b/Head_CT_DICOM/229.dcm new file mode 100644 index 0000000..63c5901 Binary files /dev/null and b/Head_CT_DICOM/229.dcm differ diff --git a/Head_CT_DICOM/23.dcm b/Head_CT_DICOM/23.dcm new file mode 100644 index 0000000..c7abe53 Binary files /dev/null and b/Head_CT_DICOM/23.dcm differ diff --git a/Head_CT_DICOM/230.dcm b/Head_CT_DICOM/230.dcm new file mode 100644 index 0000000..4733500 Binary files /dev/null and b/Head_CT_DICOM/230.dcm differ diff --git a/Head_CT_DICOM/231.dcm b/Head_CT_DICOM/231.dcm new file mode 100644 index 0000000..6c99a07 Binary files /dev/null and b/Head_CT_DICOM/231.dcm differ diff --git a/Head_CT_DICOM/232.dcm b/Head_CT_DICOM/232.dcm new file mode 100644 index 0000000..8b50242 Binary files /dev/null and b/Head_CT_DICOM/232.dcm differ diff --git a/Head_CT_DICOM/233.dcm b/Head_CT_DICOM/233.dcm new file mode 100644 index 0000000..df2dc61 Binary files /dev/null and b/Head_CT_DICOM/233.dcm differ diff --git a/Head_CT_DICOM/234.dcm b/Head_CT_DICOM/234.dcm new file mode 100644 index 0000000..0b53f99 Binary files /dev/null and b/Head_CT_DICOM/234.dcm differ diff --git a/Head_CT_DICOM/235.dcm b/Head_CT_DICOM/235.dcm new file mode 100644 index 0000000..4dc353a Binary files /dev/null and b/Head_CT_DICOM/235.dcm differ diff --git a/Head_CT_DICOM/236.dcm b/Head_CT_DICOM/236.dcm new file mode 100644 index 0000000..6bbfc9f Binary files /dev/null and b/Head_CT_DICOM/236.dcm differ diff --git a/Head_CT_DICOM/237.dcm b/Head_CT_DICOM/237.dcm new file mode 100644 index 0000000..b2f1411 Binary files /dev/null and b/Head_CT_DICOM/237.dcm differ diff --git a/Head_CT_DICOM/238.dcm b/Head_CT_DICOM/238.dcm new file mode 100644 index 0000000..34f1654 Binary files /dev/null and b/Head_CT_DICOM/238.dcm differ diff --git a/Head_CT_DICOM/239.dcm b/Head_CT_DICOM/239.dcm new file mode 100644 index 0000000..bafc393 Binary files /dev/null and b/Head_CT_DICOM/239.dcm differ diff --git a/Head_CT_DICOM/24.dcm b/Head_CT_DICOM/24.dcm new file mode 100644 index 0000000..3c0da92 Binary files /dev/null and b/Head_CT_DICOM/24.dcm differ diff --git a/Head_CT_DICOM/240.dcm b/Head_CT_DICOM/240.dcm new file mode 100644 index 0000000..0ffb58a Binary files /dev/null and b/Head_CT_DICOM/240.dcm differ diff --git a/Head_CT_DICOM/241.dcm b/Head_CT_DICOM/241.dcm new file mode 100644 index 0000000..f0bc9a8 Binary files /dev/null and b/Head_CT_DICOM/241.dcm differ diff --git a/Head_CT_DICOM/242.dcm b/Head_CT_DICOM/242.dcm new file mode 100644 index 0000000..18452d0 Binary files /dev/null and b/Head_CT_DICOM/242.dcm differ diff --git a/Head_CT_DICOM/243.dcm b/Head_CT_DICOM/243.dcm new file mode 100644 index 0000000..df46c6c Binary files /dev/null and b/Head_CT_DICOM/243.dcm differ diff --git a/Head_CT_DICOM/244.dcm b/Head_CT_DICOM/244.dcm new file mode 100644 index 0000000..09c1c1e Binary files /dev/null and b/Head_CT_DICOM/244.dcm differ diff --git a/Head_CT_DICOM/245.dcm b/Head_CT_DICOM/245.dcm new file mode 100644 index 0000000..51b9e76 Binary files /dev/null and b/Head_CT_DICOM/245.dcm differ diff --git a/Head_CT_DICOM/246.dcm b/Head_CT_DICOM/246.dcm new file mode 100644 index 0000000..de1bb99 Binary files /dev/null and b/Head_CT_DICOM/246.dcm differ diff --git a/Head_CT_DICOM/247.dcm b/Head_CT_DICOM/247.dcm new file mode 100644 index 0000000..bcf9693 Binary files /dev/null and b/Head_CT_DICOM/247.dcm differ diff --git a/Head_CT_DICOM/248.dcm b/Head_CT_DICOM/248.dcm new file mode 100644 index 0000000..017535d Binary files /dev/null and b/Head_CT_DICOM/248.dcm differ diff --git a/Head_CT_DICOM/249.dcm b/Head_CT_DICOM/249.dcm new file mode 100644 index 0000000..d4e7da2 Binary files /dev/null and b/Head_CT_DICOM/249.dcm differ diff --git a/Head_CT_DICOM/25.dcm b/Head_CT_DICOM/25.dcm new file mode 100644 index 0000000..9e7198f Binary files /dev/null and b/Head_CT_DICOM/25.dcm differ diff --git a/Head_CT_DICOM/250.dcm b/Head_CT_DICOM/250.dcm new file mode 100644 index 0000000..0022d1f Binary files /dev/null and b/Head_CT_DICOM/250.dcm differ diff --git a/Head_CT_DICOM/251.dcm b/Head_CT_DICOM/251.dcm new file mode 100644 index 0000000..596ac03 Binary files /dev/null and b/Head_CT_DICOM/251.dcm differ diff --git a/Head_CT_DICOM/252.dcm b/Head_CT_DICOM/252.dcm new file mode 100644 index 0000000..03fab4d Binary files /dev/null and b/Head_CT_DICOM/252.dcm differ diff --git a/Head_CT_DICOM/253.dcm b/Head_CT_DICOM/253.dcm new file mode 100644 index 0000000..40dac6f Binary files /dev/null and b/Head_CT_DICOM/253.dcm differ diff --git a/Head_CT_DICOM/254.dcm b/Head_CT_DICOM/254.dcm new file mode 100644 index 0000000..1f71e6c Binary files /dev/null and b/Head_CT_DICOM/254.dcm differ diff --git a/Head_CT_DICOM/255.dcm b/Head_CT_DICOM/255.dcm new file mode 100644 index 0000000..e4d1058 Binary files /dev/null and b/Head_CT_DICOM/255.dcm differ diff --git a/Head_CT_DICOM/256.dcm b/Head_CT_DICOM/256.dcm new file mode 100644 index 0000000..0ce1eec Binary files /dev/null and b/Head_CT_DICOM/256.dcm differ diff --git a/Head_CT_DICOM/257.dcm b/Head_CT_DICOM/257.dcm new file mode 100644 index 0000000..24590e1 Binary files /dev/null and b/Head_CT_DICOM/257.dcm differ diff --git a/Head_CT_DICOM/258.dcm b/Head_CT_DICOM/258.dcm new file mode 100644 index 0000000..65e55fe Binary files /dev/null and b/Head_CT_DICOM/258.dcm differ diff --git a/Head_CT_DICOM/259.dcm b/Head_CT_DICOM/259.dcm new file mode 100644 index 0000000..326c316 Binary files /dev/null and b/Head_CT_DICOM/259.dcm differ diff --git a/Head_CT_DICOM/26.dcm b/Head_CT_DICOM/26.dcm new file mode 100644 index 0000000..a45d55e Binary files /dev/null and b/Head_CT_DICOM/26.dcm differ diff --git a/Head_CT_DICOM/260.dcm b/Head_CT_DICOM/260.dcm new file mode 100644 index 0000000..0f42f8a Binary files /dev/null and b/Head_CT_DICOM/260.dcm differ diff --git a/Head_CT_DICOM/261.dcm b/Head_CT_DICOM/261.dcm new file mode 100644 index 0000000..479b0c9 Binary files /dev/null and b/Head_CT_DICOM/261.dcm differ diff --git a/Head_CT_DICOM/262.dcm b/Head_CT_DICOM/262.dcm new file mode 100644 index 0000000..2df0437 Binary files /dev/null and b/Head_CT_DICOM/262.dcm differ diff --git a/Head_CT_DICOM/263.dcm b/Head_CT_DICOM/263.dcm new file mode 100644 index 0000000..2d08b91 Binary files /dev/null and b/Head_CT_DICOM/263.dcm differ diff --git a/Head_CT_DICOM/264.dcm b/Head_CT_DICOM/264.dcm new file mode 100644 index 0000000..f366087 Binary files /dev/null and b/Head_CT_DICOM/264.dcm differ diff --git a/Head_CT_DICOM/265.dcm b/Head_CT_DICOM/265.dcm new file mode 100644 index 0000000..05a80c6 Binary files /dev/null and b/Head_CT_DICOM/265.dcm differ diff --git a/Head_CT_DICOM/266.dcm b/Head_CT_DICOM/266.dcm new file mode 100644 index 0000000..72cbf69 Binary files /dev/null and b/Head_CT_DICOM/266.dcm differ diff --git a/Head_CT_DICOM/267.dcm b/Head_CT_DICOM/267.dcm new file mode 100644 index 0000000..db78e53 Binary files /dev/null and b/Head_CT_DICOM/267.dcm differ diff --git a/Head_CT_DICOM/268.dcm b/Head_CT_DICOM/268.dcm new file mode 100644 index 0000000..c4e7540 Binary files /dev/null and b/Head_CT_DICOM/268.dcm differ diff --git a/Head_CT_DICOM/269.dcm b/Head_CT_DICOM/269.dcm new file mode 100644 index 0000000..b0b32b7 Binary files /dev/null and b/Head_CT_DICOM/269.dcm differ diff --git a/Head_CT_DICOM/27.dcm b/Head_CT_DICOM/27.dcm new file mode 100644 index 0000000..2ff5aa4 Binary files /dev/null and b/Head_CT_DICOM/27.dcm differ diff --git a/Head_CT_DICOM/270.dcm b/Head_CT_DICOM/270.dcm new file mode 100644 index 0000000..6e2f4d3 Binary files /dev/null and b/Head_CT_DICOM/270.dcm differ diff --git a/Head_CT_DICOM/271.dcm b/Head_CT_DICOM/271.dcm new file mode 100644 index 0000000..9f2f36d Binary files /dev/null and b/Head_CT_DICOM/271.dcm differ diff --git a/Head_CT_DICOM/272.dcm b/Head_CT_DICOM/272.dcm new file mode 100644 index 0000000..0674f58 Binary files /dev/null and b/Head_CT_DICOM/272.dcm differ diff --git a/Head_CT_DICOM/273.dcm b/Head_CT_DICOM/273.dcm new file mode 100644 index 0000000..9d0db95 Binary files /dev/null and b/Head_CT_DICOM/273.dcm differ diff --git a/Head_CT_DICOM/274.dcm b/Head_CT_DICOM/274.dcm new file mode 100644 index 0000000..eac7bbe Binary files /dev/null and b/Head_CT_DICOM/274.dcm differ diff --git a/Head_CT_DICOM/275.dcm b/Head_CT_DICOM/275.dcm new file mode 100644 index 0000000..51990c2 Binary files /dev/null and b/Head_CT_DICOM/275.dcm differ diff --git a/Head_CT_DICOM/276.dcm b/Head_CT_DICOM/276.dcm new file mode 100644 index 0000000..29b3071 Binary files /dev/null and b/Head_CT_DICOM/276.dcm differ diff --git a/Head_CT_DICOM/277.dcm b/Head_CT_DICOM/277.dcm new file mode 100644 index 0000000..668b78e Binary files /dev/null and b/Head_CT_DICOM/277.dcm differ diff --git a/Head_CT_DICOM/278.dcm b/Head_CT_DICOM/278.dcm new file mode 100644 index 0000000..00dd934 Binary files /dev/null and b/Head_CT_DICOM/278.dcm differ diff --git a/Head_CT_DICOM/279.dcm b/Head_CT_DICOM/279.dcm new file mode 100644 index 0000000..c096d12 Binary files /dev/null and b/Head_CT_DICOM/279.dcm differ diff --git a/Head_CT_DICOM/28.dcm b/Head_CT_DICOM/28.dcm new file mode 100644 index 0000000..3520a5c Binary files /dev/null and b/Head_CT_DICOM/28.dcm differ diff --git a/Head_CT_DICOM/280.dcm b/Head_CT_DICOM/280.dcm new file mode 100644 index 0000000..908f792 Binary files /dev/null and b/Head_CT_DICOM/280.dcm differ diff --git a/Head_CT_DICOM/281.dcm b/Head_CT_DICOM/281.dcm new file mode 100644 index 0000000..a9e84b3 Binary files /dev/null and b/Head_CT_DICOM/281.dcm differ diff --git a/Head_CT_DICOM/282.dcm b/Head_CT_DICOM/282.dcm new file mode 100644 index 0000000..b170ffe Binary files /dev/null and b/Head_CT_DICOM/282.dcm differ diff --git a/Head_CT_DICOM/283.dcm b/Head_CT_DICOM/283.dcm new file mode 100644 index 0000000..0f3e277 Binary files /dev/null and b/Head_CT_DICOM/283.dcm differ diff --git a/Head_CT_DICOM/284.dcm b/Head_CT_DICOM/284.dcm new file mode 100644 index 0000000..1792234 Binary files /dev/null and b/Head_CT_DICOM/284.dcm differ diff --git a/Head_CT_DICOM/285.dcm b/Head_CT_DICOM/285.dcm new file mode 100644 index 0000000..8e16ba9 Binary files /dev/null and b/Head_CT_DICOM/285.dcm differ diff --git a/Head_CT_DICOM/286.dcm b/Head_CT_DICOM/286.dcm new file mode 100644 index 0000000..e71de2d Binary files /dev/null and b/Head_CT_DICOM/286.dcm differ diff --git a/Head_CT_DICOM/287.dcm b/Head_CT_DICOM/287.dcm new file mode 100644 index 0000000..9e43041 Binary files /dev/null and b/Head_CT_DICOM/287.dcm differ diff --git a/Head_CT_DICOM/288.dcm b/Head_CT_DICOM/288.dcm new file mode 100644 index 0000000..7320251 Binary files /dev/null and b/Head_CT_DICOM/288.dcm differ diff --git a/Head_CT_DICOM/289.dcm b/Head_CT_DICOM/289.dcm new file mode 100644 index 0000000..ae6bca2 Binary files /dev/null and b/Head_CT_DICOM/289.dcm differ diff --git a/Head_CT_DICOM/29.dcm b/Head_CT_DICOM/29.dcm new file mode 100644 index 0000000..2bf04b5 Binary files /dev/null and b/Head_CT_DICOM/29.dcm differ diff --git a/Head_CT_DICOM/290.dcm b/Head_CT_DICOM/290.dcm new file mode 100644 index 0000000..599c322 Binary files /dev/null and b/Head_CT_DICOM/290.dcm differ diff --git a/Head_CT_DICOM/291.dcm b/Head_CT_DICOM/291.dcm new file mode 100644 index 0000000..674be56 Binary files /dev/null and b/Head_CT_DICOM/291.dcm differ diff --git a/Head_CT_DICOM/292.dcm b/Head_CT_DICOM/292.dcm new file mode 100644 index 0000000..429ec9f Binary files /dev/null and b/Head_CT_DICOM/292.dcm differ diff --git a/Head_CT_DICOM/293.dcm b/Head_CT_DICOM/293.dcm new file mode 100644 index 0000000..779ffb6 Binary files /dev/null and b/Head_CT_DICOM/293.dcm differ diff --git a/Head_CT_DICOM/294.dcm b/Head_CT_DICOM/294.dcm new file mode 100644 index 0000000..61f015b Binary files /dev/null and b/Head_CT_DICOM/294.dcm differ diff --git a/Head_CT_DICOM/295.dcm b/Head_CT_DICOM/295.dcm new file mode 100644 index 0000000..34a58c6 Binary files /dev/null and b/Head_CT_DICOM/295.dcm differ diff --git a/Head_CT_DICOM/296.dcm b/Head_CT_DICOM/296.dcm new file mode 100644 index 0000000..7c641b1 Binary files /dev/null and b/Head_CT_DICOM/296.dcm differ diff --git a/Head_CT_DICOM/297.dcm b/Head_CT_DICOM/297.dcm new file mode 100644 index 0000000..74de6de Binary files /dev/null and b/Head_CT_DICOM/297.dcm differ diff --git a/Head_CT_DICOM/298.dcm b/Head_CT_DICOM/298.dcm new file mode 100644 index 0000000..45600b5 Binary files /dev/null and b/Head_CT_DICOM/298.dcm differ diff --git a/Head_CT_DICOM/299.dcm b/Head_CT_DICOM/299.dcm new file mode 100644 index 0000000..b71622b Binary files /dev/null and b/Head_CT_DICOM/299.dcm differ diff --git a/Head_CT_DICOM/3.dcm b/Head_CT_DICOM/3.dcm new file mode 100644 index 0000000..dc72f8b Binary files /dev/null and b/Head_CT_DICOM/3.dcm differ diff --git a/Head_CT_DICOM/30.dcm b/Head_CT_DICOM/30.dcm new file mode 100644 index 0000000..a7b2fdf Binary files /dev/null and b/Head_CT_DICOM/30.dcm differ diff --git a/Head_CT_DICOM/300.dcm b/Head_CT_DICOM/300.dcm new file mode 100644 index 0000000..44bfce3 Binary files /dev/null and b/Head_CT_DICOM/300.dcm differ diff --git a/Head_CT_DICOM/31.dcm b/Head_CT_DICOM/31.dcm new file mode 100644 index 0000000..c166739 Binary files /dev/null and b/Head_CT_DICOM/31.dcm differ diff --git a/Head_CT_DICOM/32.dcm b/Head_CT_DICOM/32.dcm new file mode 100644 index 0000000..4d37969 Binary files /dev/null and b/Head_CT_DICOM/32.dcm differ diff --git a/Head_CT_DICOM/33.dcm b/Head_CT_DICOM/33.dcm new file mode 100644 index 0000000..675db57 Binary files /dev/null and b/Head_CT_DICOM/33.dcm differ diff --git a/Head_CT_DICOM/34.dcm b/Head_CT_DICOM/34.dcm new file mode 100644 index 0000000..e073f60 Binary files /dev/null and b/Head_CT_DICOM/34.dcm differ diff --git a/Head_CT_DICOM/35.dcm b/Head_CT_DICOM/35.dcm new file mode 100644 index 0000000..1d15aee Binary files /dev/null and b/Head_CT_DICOM/35.dcm differ diff --git a/Head_CT_DICOM/36.dcm b/Head_CT_DICOM/36.dcm new file mode 100644 index 0000000..7e915da Binary files /dev/null and b/Head_CT_DICOM/36.dcm differ diff --git a/Head_CT_DICOM/37.dcm b/Head_CT_DICOM/37.dcm new file mode 100644 index 0000000..8954ed4 Binary files /dev/null and b/Head_CT_DICOM/37.dcm differ diff --git a/Head_CT_DICOM/38.dcm b/Head_CT_DICOM/38.dcm new file mode 100644 index 0000000..3e05328 Binary files /dev/null and b/Head_CT_DICOM/38.dcm differ diff --git a/Head_CT_DICOM/39.dcm b/Head_CT_DICOM/39.dcm new file mode 100644 index 0000000..f213f42 Binary files /dev/null and b/Head_CT_DICOM/39.dcm differ diff --git a/Head_CT_DICOM/4.dcm b/Head_CT_DICOM/4.dcm new file mode 100644 index 0000000..ee226ac Binary files /dev/null and b/Head_CT_DICOM/4.dcm differ diff --git a/Head_CT_DICOM/40.dcm b/Head_CT_DICOM/40.dcm new file mode 100644 index 0000000..b4dcd69 Binary files /dev/null and b/Head_CT_DICOM/40.dcm differ diff --git a/Head_CT_DICOM/41.dcm b/Head_CT_DICOM/41.dcm new file mode 100644 index 0000000..dab712d Binary files /dev/null and b/Head_CT_DICOM/41.dcm differ diff --git a/Head_CT_DICOM/42.dcm b/Head_CT_DICOM/42.dcm new file mode 100644 index 0000000..acbb50d Binary files /dev/null and b/Head_CT_DICOM/42.dcm differ diff --git a/Head_CT_DICOM/43.dcm b/Head_CT_DICOM/43.dcm new file mode 100644 index 0000000..530c0ff Binary files /dev/null and b/Head_CT_DICOM/43.dcm differ diff --git a/Head_CT_DICOM/44.dcm b/Head_CT_DICOM/44.dcm new file mode 100644 index 0000000..eae71cf Binary files /dev/null and b/Head_CT_DICOM/44.dcm differ diff --git a/Head_CT_DICOM/45.dcm b/Head_CT_DICOM/45.dcm new file mode 100644 index 0000000..f9b5a64 Binary files /dev/null and b/Head_CT_DICOM/45.dcm differ diff --git a/Head_CT_DICOM/46.dcm b/Head_CT_DICOM/46.dcm new file mode 100644 index 0000000..f86f493 Binary files /dev/null and b/Head_CT_DICOM/46.dcm differ diff --git a/Head_CT_DICOM/47.dcm b/Head_CT_DICOM/47.dcm new file mode 100644 index 0000000..3ddfec3 Binary files /dev/null and b/Head_CT_DICOM/47.dcm differ diff --git a/Head_CT_DICOM/48.dcm b/Head_CT_DICOM/48.dcm new file mode 100644 index 0000000..8cb792b Binary files /dev/null and b/Head_CT_DICOM/48.dcm differ diff --git a/Head_CT_DICOM/49.dcm b/Head_CT_DICOM/49.dcm new file mode 100644 index 0000000..a0bbc87 Binary files /dev/null and b/Head_CT_DICOM/49.dcm differ diff --git a/Head_CT_DICOM/5.dcm b/Head_CT_DICOM/5.dcm new file mode 100644 index 0000000..3e55147 Binary files /dev/null and b/Head_CT_DICOM/5.dcm differ diff --git a/Head_CT_DICOM/50.dcm b/Head_CT_DICOM/50.dcm new file mode 100644 index 0000000..5974179 Binary files /dev/null and b/Head_CT_DICOM/50.dcm differ diff --git a/Head_CT_DICOM/51.dcm b/Head_CT_DICOM/51.dcm new file mode 100644 index 0000000..0e76a68 Binary files /dev/null and b/Head_CT_DICOM/51.dcm differ diff --git a/Head_CT_DICOM/52.dcm b/Head_CT_DICOM/52.dcm new file mode 100644 index 0000000..e16fc8e Binary files /dev/null and b/Head_CT_DICOM/52.dcm differ diff --git a/Head_CT_DICOM/53.dcm b/Head_CT_DICOM/53.dcm new file mode 100644 index 0000000..5a21f80 Binary files /dev/null and b/Head_CT_DICOM/53.dcm differ diff --git a/Head_CT_DICOM/54.dcm b/Head_CT_DICOM/54.dcm new file mode 100644 index 0000000..c2498b6 Binary files /dev/null and b/Head_CT_DICOM/54.dcm differ diff --git a/Head_CT_DICOM/55.dcm b/Head_CT_DICOM/55.dcm new file mode 100644 index 0000000..a50f539 Binary files /dev/null and b/Head_CT_DICOM/55.dcm differ diff --git a/Head_CT_DICOM/56.dcm b/Head_CT_DICOM/56.dcm new file mode 100644 index 0000000..cb24fc0 Binary files /dev/null and b/Head_CT_DICOM/56.dcm differ diff --git a/Head_CT_DICOM/57.dcm b/Head_CT_DICOM/57.dcm new file mode 100644 index 0000000..c6c2df5 Binary files /dev/null and b/Head_CT_DICOM/57.dcm differ diff --git a/Head_CT_DICOM/58.dcm b/Head_CT_DICOM/58.dcm new file mode 100644 index 0000000..e4e9a0d Binary files /dev/null and b/Head_CT_DICOM/58.dcm differ diff --git a/Head_CT_DICOM/59.dcm b/Head_CT_DICOM/59.dcm new file mode 100644 index 0000000..11b21cc Binary files /dev/null and b/Head_CT_DICOM/59.dcm differ diff --git a/Head_CT_DICOM/6.dcm b/Head_CT_DICOM/6.dcm new file mode 100644 index 0000000..14bea1c Binary files /dev/null and b/Head_CT_DICOM/6.dcm differ diff --git a/Head_CT_DICOM/60.dcm b/Head_CT_DICOM/60.dcm new file mode 100644 index 0000000..88ec3a6 Binary files /dev/null and b/Head_CT_DICOM/60.dcm differ diff --git a/Head_CT_DICOM/61.dcm b/Head_CT_DICOM/61.dcm new file mode 100644 index 0000000..905c3d7 Binary files /dev/null and b/Head_CT_DICOM/61.dcm differ diff --git a/Head_CT_DICOM/62.dcm b/Head_CT_DICOM/62.dcm new file mode 100644 index 0000000..8f0ceaa Binary files /dev/null and b/Head_CT_DICOM/62.dcm differ diff --git a/Head_CT_DICOM/63.dcm b/Head_CT_DICOM/63.dcm new file mode 100644 index 0000000..3c1f716 Binary files /dev/null and b/Head_CT_DICOM/63.dcm differ diff --git a/Head_CT_DICOM/64.dcm b/Head_CT_DICOM/64.dcm new file mode 100644 index 0000000..4944dc6 Binary files /dev/null and b/Head_CT_DICOM/64.dcm differ diff --git a/Head_CT_DICOM/65.dcm b/Head_CT_DICOM/65.dcm new file mode 100644 index 0000000..84521a4 Binary files /dev/null and b/Head_CT_DICOM/65.dcm differ diff --git a/Head_CT_DICOM/66.dcm b/Head_CT_DICOM/66.dcm new file mode 100644 index 0000000..28b06e7 Binary files /dev/null and b/Head_CT_DICOM/66.dcm differ diff --git a/Head_CT_DICOM/67.dcm b/Head_CT_DICOM/67.dcm new file mode 100644 index 0000000..63827da Binary files /dev/null and b/Head_CT_DICOM/67.dcm differ diff --git a/Head_CT_DICOM/68.dcm b/Head_CT_DICOM/68.dcm new file mode 100644 index 0000000..d01b341 Binary files /dev/null and b/Head_CT_DICOM/68.dcm differ diff --git a/Head_CT_DICOM/69.dcm b/Head_CT_DICOM/69.dcm new file mode 100644 index 0000000..38023a1 Binary files /dev/null and b/Head_CT_DICOM/69.dcm differ diff --git a/Head_CT_DICOM/7.dcm b/Head_CT_DICOM/7.dcm new file mode 100644 index 0000000..9e975af Binary files /dev/null and b/Head_CT_DICOM/7.dcm differ diff --git a/Head_CT_DICOM/70.dcm b/Head_CT_DICOM/70.dcm new file mode 100644 index 0000000..02067da Binary files /dev/null and b/Head_CT_DICOM/70.dcm differ diff --git a/Head_CT_DICOM/71.dcm b/Head_CT_DICOM/71.dcm new file mode 100644 index 0000000..2c67398 Binary files /dev/null and b/Head_CT_DICOM/71.dcm differ diff --git a/Head_CT_DICOM/72.dcm b/Head_CT_DICOM/72.dcm new file mode 100644 index 0000000..31f09ba Binary files /dev/null and b/Head_CT_DICOM/72.dcm differ diff --git a/Head_CT_DICOM/73.dcm b/Head_CT_DICOM/73.dcm new file mode 100644 index 0000000..3942ea7 Binary files /dev/null and b/Head_CT_DICOM/73.dcm differ diff --git a/Head_CT_DICOM/74.dcm b/Head_CT_DICOM/74.dcm new file mode 100644 index 0000000..7d1ccad Binary files /dev/null and b/Head_CT_DICOM/74.dcm differ diff --git a/Head_CT_DICOM/75.dcm b/Head_CT_DICOM/75.dcm new file mode 100644 index 0000000..1cb641c Binary files /dev/null and b/Head_CT_DICOM/75.dcm differ diff --git a/Head_CT_DICOM/76.dcm b/Head_CT_DICOM/76.dcm new file mode 100644 index 0000000..bfc00a4 Binary files /dev/null and b/Head_CT_DICOM/76.dcm differ diff --git a/Head_CT_DICOM/77.dcm b/Head_CT_DICOM/77.dcm new file mode 100644 index 0000000..36dacaa Binary files /dev/null and b/Head_CT_DICOM/77.dcm differ diff --git a/Head_CT_DICOM/78.dcm b/Head_CT_DICOM/78.dcm new file mode 100644 index 0000000..215a06c Binary files /dev/null and b/Head_CT_DICOM/78.dcm differ diff --git a/Head_CT_DICOM/79.dcm b/Head_CT_DICOM/79.dcm new file mode 100644 index 0000000..481c09e Binary files /dev/null and b/Head_CT_DICOM/79.dcm differ diff --git a/Head_CT_DICOM/8.dcm b/Head_CT_DICOM/8.dcm new file mode 100644 index 0000000..6e7e66d Binary files /dev/null and b/Head_CT_DICOM/8.dcm differ diff --git a/Head_CT_DICOM/80.dcm b/Head_CT_DICOM/80.dcm new file mode 100644 index 0000000..dfd20b7 Binary files /dev/null and b/Head_CT_DICOM/80.dcm differ diff --git a/Head_CT_DICOM/81.dcm b/Head_CT_DICOM/81.dcm new file mode 100644 index 0000000..138de4b Binary files /dev/null and b/Head_CT_DICOM/81.dcm differ diff --git a/Head_CT_DICOM/82.dcm b/Head_CT_DICOM/82.dcm new file mode 100644 index 0000000..b494d96 Binary files /dev/null and b/Head_CT_DICOM/82.dcm differ diff --git a/Head_CT_DICOM/83.dcm b/Head_CT_DICOM/83.dcm new file mode 100644 index 0000000..5f81261 Binary files /dev/null and b/Head_CT_DICOM/83.dcm differ diff --git a/Head_CT_DICOM/84.dcm b/Head_CT_DICOM/84.dcm new file mode 100644 index 0000000..6f11283 Binary files /dev/null and b/Head_CT_DICOM/84.dcm differ diff --git a/Head_CT_DICOM/85.dcm b/Head_CT_DICOM/85.dcm new file mode 100644 index 0000000..a7ad690 Binary files /dev/null and b/Head_CT_DICOM/85.dcm differ diff --git a/Head_CT_DICOM/86.dcm b/Head_CT_DICOM/86.dcm new file mode 100644 index 0000000..aa17a59 Binary files /dev/null and b/Head_CT_DICOM/86.dcm differ diff --git a/Head_CT_DICOM/87.dcm b/Head_CT_DICOM/87.dcm new file mode 100644 index 0000000..1ac2150 Binary files /dev/null and b/Head_CT_DICOM/87.dcm differ diff --git a/Head_CT_DICOM/88.dcm b/Head_CT_DICOM/88.dcm new file mode 100644 index 0000000..1a6e809 Binary files /dev/null and b/Head_CT_DICOM/88.dcm differ diff --git a/Head_CT_DICOM/89.dcm b/Head_CT_DICOM/89.dcm new file mode 100644 index 0000000..c695561 Binary files /dev/null and b/Head_CT_DICOM/89.dcm differ diff --git a/Head_CT_DICOM/9.dcm b/Head_CT_DICOM/9.dcm new file mode 100644 index 0000000..42af268 Binary files /dev/null and b/Head_CT_DICOM/9.dcm differ diff --git a/Head_CT_DICOM/90.dcm b/Head_CT_DICOM/90.dcm new file mode 100644 index 0000000..a3574f0 Binary files /dev/null and b/Head_CT_DICOM/90.dcm differ diff --git a/Head_CT_DICOM/91.dcm b/Head_CT_DICOM/91.dcm new file mode 100644 index 0000000..7d90faf Binary files /dev/null and b/Head_CT_DICOM/91.dcm differ diff --git a/Head_CT_DICOM/92.dcm b/Head_CT_DICOM/92.dcm new file mode 100644 index 0000000..ffe362b Binary files /dev/null and b/Head_CT_DICOM/92.dcm differ diff --git a/Head_CT_DICOM/93.dcm b/Head_CT_DICOM/93.dcm new file mode 100644 index 0000000..7aceefc Binary files /dev/null and b/Head_CT_DICOM/93.dcm differ diff --git a/Head_CT_DICOM/94.dcm b/Head_CT_DICOM/94.dcm new file mode 100644 index 0000000..0374108 Binary files /dev/null and b/Head_CT_DICOM/94.dcm differ diff --git a/Head_CT_DICOM/95.dcm b/Head_CT_DICOM/95.dcm new file mode 100644 index 0000000..37843f8 Binary files /dev/null and b/Head_CT_DICOM/95.dcm differ diff --git a/Head_CT_DICOM/96.dcm b/Head_CT_DICOM/96.dcm new file mode 100644 index 0000000..f14da4c Binary files /dev/null and b/Head_CT_DICOM/96.dcm differ diff --git a/Head_CT_DICOM/97.dcm b/Head_CT_DICOM/97.dcm new file mode 100644 index 0000000..73b74a6 Binary files /dev/null and b/Head_CT_DICOM/97.dcm differ diff --git a/Head_CT_DICOM/98.dcm b/Head_CT_DICOM/98.dcm new file mode 100644 index 0000000..65330fe Binary files /dev/null and b/Head_CT_DICOM/98.dcm differ diff --git a/Head_CT_DICOM/99.dcm b/Head_CT_DICOM/99.dcm new file mode 100644 index 0000000..0522dbf Binary files /dev/null and b/Head_CT_DICOM/99.dcm differ diff --git a/Head_CT_ReConstruct/会厌.stl b/Head_CT_ReConstruct/会厌.stl new file mode 100644 index 0000000..96650ab Binary files /dev/null and b/Head_CT_ReConstruct/会厌.stl differ diff --git a/Head_CT_ReConstruct/声门.stl b/Head_CT_ReConstruct/声门.stl new file mode 100644 index 0000000..c58fe6d Binary files /dev/null and b/Head_CT_ReConstruct/声门.stl differ diff --git a/Head_CT_ReConstruct/头部.stl b/Head_CT_ReConstruct/头部.stl new file mode 100644 index 0000000..91b31f0 Binary files /dev/null and b/Head_CT_ReConstruct/头部.stl differ diff --git a/Head_CT_ReConstruct/头颅.stl b/Head_CT_ReConstruct/头颅.stl new file mode 100644 index 0000000..24fcab7 Binary files /dev/null and b/Head_CT_ReConstruct/头颅.stl differ diff --git a/Head_CT_ReConstruct/气管上段.stl b/Head_CT_ReConstruct/气管上段.stl new file mode 100644 index 0000000..c65a6e3 Binary files /dev/null and b/Head_CT_ReConstruct/气管上段.stl differ diff --git a/Head_CT_ReConstruct/气管下段.stl b/Head_CT_ReConstruct/气管下段.stl new file mode 100644 index 0000000..67c0576 Binary files /dev/null and b/Head_CT_ReConstruct/气管下段.stl differ diff --git a/Head_CT_ReConstruct/气管整体.stl b/Head_CT_ReConstruct/气管整体.stl new file mode 100644 index 0000000..f1e2583 Binary files /dev/null and b/Head_CT_ReConstruct/气管整体.stl differ diff --git a/Head_CT_ReConstruct/气管狭窄段.stl b/Head_CT_ReConstruct/气管狭窄段.stl new file mode 100644 index 0000000..9781a01 Binary files /dev/null and b/Head_CT_ReConstruct/气管狭窄段.stl differ diff --git a/Head_CT_ReConstruct/肿瘤.stl b/Head_CT_ReConstruct/肿瘤.stl new file mode 100644 index 0000000..276e21a Binary files /dev/null and b/Head_CT_ReConstruct/肿瘤.stl differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..37a7020 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# ReVoxelSeg DICOM Docker 独立运行包 + +这是“基于模型逆向体素化及 DICOM 分割标注系统”的独立 Docker 程序目录。目录内已经包含运行所需源码、默认 DICOM 演示数据和默认 STL 模型数据,因此即使没有原始 `ReVoxelSeg_DICOM` 工程目录,也可以直接在本目录构建和运行。 + +## 目录内容 + +- `WebSite/`:前后端一体服务源码。 +- `Head_CT_DICOM/`:默认 DICOM 影像序列。 +- `Head_CT_ReConstruct/`:默认 STL 模型。 +- `Dockerfile`:Docker 镜像构建文件。 +- `docker_compose.yaml`:本机 Docker Compose 部署文件。 +- `docker_compose.nas.yaml`:威联通 NAS / QTS Container Station 部署文件。 +- `data/`:运行后自动生成,保存项目状态和上传资产。 +- `exports/`:运行后自动生成,保存导出结果。 + +本目录不包含工程分析、软著撰写材料、参考模板、旧运行产物和开发依赖缓存。 + +## 本机部署 + +在本目录执行: + +```bash +docker compose -f docker_compose.yaml up -d --build +``` + +访问: + +```text +http://192.168.3.11:4000/ +http://127.0.0.1:4000/ +``` + +查看状态和日志: + +```bash +docker compose -f docker_compose.yaml ps +docker compose -f docker_compose.yaml logs -f revoxelseg_web +docker compose -f docker_compose.yaml logs -f revoxelseg_frpc +``` + +停止: + +```bash +docker compose -f docker_compose.yaml down +``` + +## 威联通 NAS / QTS 部署 + +建议将完整目录放到: + +```text +/share/Container/revoxelseg_dicom +``` + +然后在 QTS Container Station 中导入或粘贴: + +```text +docker_compose.nas.yaml +``` + +SSH 部署命令: + +```bash +cd /share/Container/revoxelseg_dicom +docker compose -f docker_compose.nas.yaml up -d --build +``` + +如果目录不是 `/share/Container/revoxelseg_dicom`,请修改 `docker_compose.nas.yaml` 中的 `build.context`、`data` 和 `exports` 挂载路径。 + +## FRPC 公网映射 + +两份 Compose 都内置 frpc 配置: + +```toml +serverAddr = "82.157.255.195" +serverPort = 7000 + +auth.method = "token" +auth.token = "en.xjtu.edu.cn" + +transport.poolCount = 5 +transport.heartbeatTimeout = -1 + +[[proxies]] +name = "ReVoxelSeg_DICOM" +type = "tcp" +localIP = "revoxelseg_web" +localPort = 4000 +remotePort = 10008 +``` + +FRPC 在线且 NPM 反向代理配置生效后,可通过: + +```text +https://revoxel.huijutec.cn/ +``` + +## 健康检查 + +```bash +curl http://127.0.0.1:4000/api/health +``` + +正常返回: + +```json +{"ok":true,"service":"revoxelseg-dicom"} +``` + diff --git a/WebSite/.gitignore b/WebSite/.gitignore new file mode 100644 index 0000000..5a86d2a --- /dev/null +++ b/WebSite/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +build/ +dist/ +coverage/ +.DS_Store +*.log +.env* +!.env.example diff --git a/WebSite/README.md b/WebSite/README.md new file mode 100644 index 0000000..ae8ae58 --- /dev/null +++ b/WebSite/README.md @@ -0,0 +1,62 @@ +# 模型逆向系统 WebSite + +本目录是“基于模型逆向体素化及 DICOM 分割标注系统”的前后端一体服务。 + +## 环境要求 + +- Node.js 18 或更高版本 +- npm + +当前版本的 DICOM 预览、STL 预览和 NIfTI 演示导出均由 Node/React/Three.js 完成,不需要 Python 或 conda 环境。 + +后续若接入真实医学级 STL 反向体素化算法,建议单独创建 Python conda 环境,例如: + +```bash +conda create -n revoxelseg python=3.11 +conda activate revoxelseg +``` + +再按真实算法依赖安装 SimpleITK、nibabel、numpy、trimesh、vtk 等包。 + +## 安装依赖 + +```bash +npm ci +``` + +## 开发运行 + +前后端统一由 Express + Vite 中间件托管: + +```bash +npm run serve -- --host 0.0.0.0 --port 4000 +``` + +访问: + +```text +http://192.168.3.11:4000/ +``` + +## 构建检查 + +```bash +npm run lint +npm run build +``` + +## 数据目录 + +默认演示项目读取仓库根目录: + +- `Head_CT_DICOM/`:DICOM 序列。 +- `Head_CT_ReConstruct/`:STL 重建模型。 + +这些医学影像和模型数据默认不提交到 Git。 + +## 运行态目录 + +- `WebSite/data/`:后端共享状态。 +- `WebSite/exports/`:生成的 NIfTI 导出文件。 + +这些目录是运行态产物,默认不提交到 Git。 diff --git a/WebSite/index.html b/WebSite/index.html new file mode 100644 index 0000000..6048cb1 --- /dev/null +++ b/WebSite/index.html @@ -0,0 +1,13 @@ + + + + + + + 模型逆向系统 + + +
+ + + diff --git a/WebSite/metadata.json b/WebSite/metadata.json new file mode 100644 index 0000000..1a6b1b1 --- /dev/null +++ b/WebSite/metadata.json @@ -0,0 +1,6 @@ +{ + "name": "模型逆向系统", + "description": "基于模型逆向体素化及DICOM分割标注系统,支持DICOM与STL模型配准、可视化、微调及分割影像导出。", + "requestFramePermissions": [], + "majorCapabilities": [] +} diff --git a/WebSite/package-lock.json b/WebSite/package-lock.json new file mode 100644 index 0000000..d7aaac1 --- /dev/null +++ b/WebSite/package-lock.json @@ -0,0 +1,5554 @@ +{ + "name": "react-example", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-example", + "version": "0.0.0", + "dependencies": { + "@google/genai": "^1.29.0", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "adm-zip": "^0.5.17", + "clsx": "^2.1.1", + "dotenv": "^17.2.3", + "express": "^4.21.2", + "framer-motion": "^12.38.0", + "lucide-react": "^0.546.0", + "motion": "^12.23.24", + "multer": "^2.1.1", + "react": "^19.0.1", + "react-dom": "^19.0.1", + "recharts": "^3.8.1", + "tailwind-merge": "^3.5.0", + "three": "^0.184.0", + "vite": "^6.2.3" + }, + "devDependencies": { + "@types/adm-zip": "^0.5.8", + "@types/express": "^4.17.21", + "@types/multer": "^2.1.0", + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.51.0.tgz", + "integrity": "sha512-vTZZF3CSimN7cn2zsLpW2p5WF0eZa5Gz69ITMPCNHpPrDlAstOfGifSfi0p/s9Z9400f7xJRkgvkQNrcM7pJ6w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", + "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@types/adm-zip": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", + "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz", + "integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.546.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.546.0.tgz", + "integrity": "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/protobufjs": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", + "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.1", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/WebSite/package.json b/WebSite/package.json new file mode 100644 index 0000000..a4575c2 --- /dev/null +++ b/WebSite/package.json @@ -0,0 +1,46 @@ +{ + "name": "react-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port=3000 --host=0.0.0.0", + "serve": "tsx server.ts", + "build": "vite build", + "preview": "vite preview", + "clean": "rm -rf dist", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@google/genai": "^1.29.0", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "adm-zip": "^0.5.17", + "clsx": "^2.1.1", + "dotenv": "^17.2.3", + "express": "^4.21.2", + "framer-motion": "^12.38.0", + "lucide-react": "^0.546.0", + "motion": "^12.23.24", + "multer": "^2.1.1", + "react": "^19.0.1", + "react-dom": "^19.0.1", + "recharts": "^3.8.1", + "tailwind-merge": "^3.5.0", + "three": "^0.184.0", + "vite": "^6.2.3" + }, + "devDependencies": { + "@types/adm-zip": "^0.5.8", + "@types/express": "^4.17.21", + "@types/multer": "^2.1.0", + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.3" + } +} diff --git a/WebSite/public/logo.png b/WebSite/public/logo.png new file mode 100644 index 0000000..6b9ee7e Binary files /dev/null and b/WebSite/public/logo.png differ diff --git a/WebSite/server.ts b/WebSite/server.ts new file mode 100644 index 0000000..b31dddf --- /dev/null +++ b/WebSite/server.ts @@ -0,0 +1,2965 @@ +import express from 'express'; +import AdmZip from 'adm-zip'; +import multer from 'multer'; +import { createServer as createViteServer } from 'vite'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import zlib from 'node:zlib'; +import { fileURLToPath } from 'node:url'; + +type ProjectStatus = 'pending' | 'completed' | 'processing'; +type DicomPlane = 'axial' | 'sagittal' | 'coronal'; +type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast'; +type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl'; +type SegmentationExportScope = 'all' | 'visible'; +type SegmentationExportMode = 'combined' | 'separate'; +type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; +type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high'; + +interface ModuleStyleRecord { + visible: boolean; + color: string; + opacity: number; + partId: number; +} + +interface ModelPoseValue { + rotateX: number; + rotateY: number; + rotateZ: number; + translateX: number; + translateY: number; + translateZ: number; + scale: number; +} + +interface ModelPoseRecord { + id: string; + name: string; + pose: ModelPoseValue; +} + +interface SegmentationResultRecord { + id: string; + schemaVersion?: number; + name: string; + createdAt: string; + segmentationScope: SegmentationExportScope; + pose: ModelPoseValue; + moduleStyles: Record; + sliceStart: number; + sliceEnd: number; + mappingSlice: number; + displayLevel: SegmentationDisplayLevel; + dicomOpacityLevel: SegmentationDicomOpacityLevel; + showBounds: boolean; + cutEnabled: boolean; +} + +interface UserRecord { + id: number; + name: string; + account: string; + password: string; + department: string; + date: string; +} + +interface ProjectRecord { + id: string; + name: string; + createTime: string; + status: ProjectStatus; + dicomCount: number; + hasModel: boolean; + dicomPath: string; + modelPath: string; + modelCount: number; + stlFiles: string[]; + maskFormats: Array<'nii' | 'nii.gz'>; + exportedMaskCount: number; + isDefault?: boolean; + moduleStyles: Record; + modelPoses: ModelPoseRecord[]; + segmentationResults: SegmentationResultRecord[]; +} + +interface SessionRecord { + authenticated: boolean; + account: string | null; + lastUpdated: string; +} + +interface AppState { + users: UserRecord[]; + projects: ProjectRecord[]; + session: SessionRecord; + updatedAt: string; +} + +interface UploadedAssetPayload { + name: string; + data: string; +} + +interface PreparedAssetFile { + name: string; + data: Buffer; +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); +const dataDir = path.join(__dirname, 'data'); +const exportDir = path.join(__dirname, 'exports'); +const uploadDir = path.join(dataDir, 'uploads'); +const uploadTempDir = path.join(dataDir, 'upload-tmp'); +const statePath = path.join(dataDir, 'state.json'); +const dicomDir = path.join(repoRoot, 'Head_CT_DICOM'); +const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct'); +const dicomPreviewCache = new Map(); +const dicomVolumeCache = new Map(); +const modelPreviewCache = new Map(); +const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; +const defaultModelPose: ModelPoseValue = { + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, +}; + +interface DicomAttributes { + patientName: string; + patientId: string; + studyDate: string; + studyDescription: string; + seriesDescription: string; + modality: string; + manufacturer: string; + rows: number; + columns: number; + bitsAllocated: number; + pixelRepresentation: number; + windowCenter: number; + windowWidth: number; + rescaleIntercept: number; + rescaleSlope: number; + rowSpacing: number; + columnSpacing: number; + sliceThickness: number | null; + spacingBetweenSlices: number | null; + imagePosition: number[] | null; +} + +function today() { + return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date()); +} + +function now() { + return new Date().toISOString(); +} + +function timestampForFilename(date = new Date()) { + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(date); + const value = (type: string) => parts.find((part) => part.type === type)?.value ?? '00'; + return `${value('year')}-${value('month')}-${value('day')}-${value('hour')}-${value('minute')}-${value('second')}`; +} + +function sanitizeFilenamePart(input: string, fallback: string) { + const cleaned = input + .trim() + .replace(/[\\/:*?"<>|]+/g, '_') + .replace(/\s+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + return cleaned || fallback; +} + +function contentDispositionAttachment(filename: string) { + const asciiFallback = filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_'); + return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`; +} + +function ensureDir(dir: string) { + fs.mkdirSync(dir, { recursive: true }); +} + +function listFiles(dir: string, extension: string) { + if (!fs.existsSync(dir)) { + return []; + } + + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(extension)) + .map((entry) => entry.name) + .sort(naturalFileCompare); +} + +function naturalFileCompare(a: string, b: string) { + return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); +} + +function toRepoRelativePath(dir: string) { + return path.relative(repoRoot, dir).split(path.sep).join('/'); +} + +function resolveStoredAssetDir(storedPath: string | undefined, fallbackDir: string) { + if (!storedPath) { + return fallbackDir; + } + return path.isAbsolute(storedPath) ? storedPath : path.resolve(repoRoot, storedPath); +} + +function getProjectDicomDir(project: ProjectRecord) { + return resolveStoredAssetDir(project.dicomPath, project.id === 'head-ct-demo' ? dicomDir : ''); +} + +function getProjectModelDir(project: ProjectRecord) { + return resolveStoredAssetDir(project.modelPath, project.id === 'head-ct-demo' ? modelDir : ''); +} + +function getProjectDicomFilePath(project: ProjectRecord, fileName: string) { + return path.join(getProjectDicomDir(project), fileName); +} + +function getProjectModelFilePath(project: ProjectRecord, fileName: string) { + return path.join(getProjectModelDir(project), fileName); +} + +function getProjectDicomInfoCachePath(project: ProjectRecord) { + const dicomAssetDir = getProjectDicomDir(project); + const resolvedDir = path.resolve(dicomAssetDir); + const resolvedUploadDir = path.resolve(uploadDir); + if (!resolvedDir.startsWith(`${resolvedUploadDir}${path.sep}`)) { + return null; + } + return path.join(resolvedDir, '.revoxelseg-dicom-info.json'); +} + +function readCachedDicomInfo(project: ProjectRecord, files: string[]) { + const cachePath = getProjectDicomInfoCachePath(project); + if (!cachePath || !fs.existsSync(cachePath)) { + return null; + } + try { + const cached = JSON.parse(fs.readFileSync(cachePath, 'utf8')) as { files?: string[]; info?: unknown }; + if (!Array.isArray(cached.files) || cached.files.join('|') !== files.join('|') || !cached.info) { + return null; + } + return cached.info; + } catch { + return null; + } +} + +function writeCachedDicomInfo(project: ProjectRecord, files: string[], info: unknown) { + const cachePath = getProjectDicomInfoCachePath(project); + if (!cachePath) { + return; + } + fs.writeFileSync(cachePath, JSON.stringify({ generatedAt: now(), files, info }, null, 2)); +} + +function clearProjectRuntimeCaches(projectId: string) { + [...dicomPreviewCache.keys()].forEach((key) => { + if (key.startsWith(`${projectId}:`)) { + dicomPreviewCache.delete(key); + } + }); + [...dicomVolumeCache.keys()].forEach((key) => { + if (key.startsWith(`${projectId}:`)) { + dicomVolumeCache.delete(key); + } + }); + modelPreviewCache.clear(); +} + +function publicUser(user: UserRecord) { + const { password: _password, ...rest } = user; + return rest; +} + +function parseUserPayload(body: unknown, existing?: UserRecord) { + const source = body && typeof body === 'object' ? body as Record : {}; + const name = typeof source.name === 'string' ? source.name.trim() : existing?.name ?? ''; + const account = typeof source.account === 'string' ? source.account.trim() : existing?.account ?? ''; + const department = typeof source.department === 'string' ? source.department.trim() : existing?.department ?? ''; + const password = typeof source.password === 'string' ? source.password : existing?.password ?? ''; + + return { name, account, department, password }; +} + +function publicSession(state: AppState) { + const user = state.session.account + ? state.users.find((candidate) => candidate.account === state.session.account) + : null; + + return { + authenticated: state.session.authenticated && Boolean(user), + currentUser: user + ? { + id: user.id, + name: user.name, + account: user.account, + department: user.department, + } + : null, + lastUpdated: state.session.lastUpdated, + }; +} + +function clampNumber(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function normalizeModuleStyle( + style: Partial | undefined, + index: number, +): ModuleStyleRecord { + const opacity = typeof style?.opacity === 'number' && Number.isFinite(style.opacity) ? style.opacity : 0.72; + const partId = typeof style?.partId === 'number' && Number.isFinite(style.partId) ? style.partId : index + 1; + + return { + visible: typeof style?.visible === 'boolean' ? style.visible : true, + color: typeof style?.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(style.color) + ? style.color + : defaultModuleColors[index % defaultModuleColors.length], + opacity: clampNumber(opacity, 0.1, 1), + partId: clampNumber(Math.round(partId), 1, 255), + }; +} + +function buildModuleStyles( + stlFiles: string[], + existing?: Record>, +) { + return stlFiles.reduce>((acc, fileName, index) => { + acc[fileName] = normalizeModuleStyle(existing?.[fileName], index); + return acc; + }, {}); +} + +function defaultModelPoses(): ModelPoseRecord[] { + return [ + { id: 'default', name: '默认', pose: { ...defaultModelPose } }, + { id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } }, + { id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } }, + ]; +} + +function normalizeModelPoseValue(value: Partial | undefined): ModelPoseValue { + const read = (key: keyof ModelPoseValue, fallback: number, min: number, max: number) => { + const nextValue = value?.[key]; + return typeof nextValue === 'number' && Number.isFinite(nextValue) + ? clampNumber(nextValue, min, max) + : fallback; + }; + + return { + rotateX: read('rotateX', defaultModelPose.rotateX, -180, 180), + rotateY: read('rotateY', defaultModelPose.rotateY, -180, 180), + rotateZ: read('rotateZ', defaultModelPose.rotateZ, -180, 180), + translateX: read('translateX', defaultModelPose.translateX, -2, 2), + translateY: read('translateY', defaultModelPose.translateY, -2, 2), + translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2), + scale: read('scale', defaultModelPose.scale, 0.5, 2), + }; +} + +function normalizeModelPoseRecord(record: Partial | undefined, fallback: ModelPoseRecord): ModelPoseRecord { + const id = typeof record?.id === 'string' && record.id.trim() ? record.id.trim().slice(0, 80) : fallback.id; + const name = typeof record?.name === 'string' && record.name.trim() ? record.name.trim().slice(0, 80) : fallback.name; + + return { + id, + name, + pose: normalizeModelPoseValue(record?.pose ?? fallback.pose), + }; +} + +function normalizeModelPoses(existing?: Partial[]) { + const defaults = defaultModelPoses(); + const incoming = Array.isArray(existing) + ? existing + .map((record, index) => normalizeModelPoseRecord(record, defaults[index] ?? { + id: `pose-${index}`, + name: `位姿${index + 1}`, + pose: defaultModelPose, + })) + .filter((record) => record.id !== 'best' && record.name !== '最佳位姿') + .filter((record) => record.id) + : []; + const incomingById = new Map(incoming.map((record) => [record.id, record])); + const normalizedDefaults = defaults.map((pose) => incomingById.get(pose.id) ?? pose); + const custom = incoming.filter((record) => !defaults.some((pose) => pose.id === record.id)); + + return [...normalizedDefaults, ...custom]; +} + +function normalizeSegmentationResults( + existing: Partial[] | undefined, + stlFiles: string[], + currentModuleStyles: Record, + dicomCount = 0, +) { + if (!Array.isArray(existing)) { + return []; + } + + const maxSlice = Math.max(dicomCount - 1, 0); + const normalizeSlice = (value: unknown, fallback: number) => ( + typeof value === 'number' && Number.isFinite(value) + ? clampNumber(Math.round(value), 0, maxSlice) + : clampNumber(fallback, 0, maxSlice) + ); + const normalizeDisplayLevel = (value: unknown): SegmentationDisplayLevel => ( + value === 'fine' || value === 'ultra' || value === 'solid' ? value : 'standard' + ); + const normalizeDicomOpacityLevel = (value: unknown): SegmentationDicomOpacityLevel => ( + value === 'medium' || value === 'high' ? value : 'low' + ); + + return existing + .filter((record) => record?.schemaVersion === 2) + .map((record, index): SegmentationResultRecord => { + const rawStyles = record?.moduleStyles && typeof record.moduleStyles === 'object' && !Array.isArray(record.moduleStyles) + ? record.moduleStyles + : currentModuleStyles; + const sliceStart = normalizeSlice(record?.sliceStart, 0); + const sliceEnd = normalizeSlice(record?.sliceEnd, maxSlice); + return { + id: typeof record?.id === 'string' && record.id.trim() + ? record.id.trim().slice(0, 80) + : `segmentation-${index}`, + schemaVersion: 2, + name: '逆向分割结果', + createdAt: typeof record?.createdAt === 'string' && record.createdAt.trim() ? record.createdAt : now(), + segmentationScope: record?.segmentationScope === 'all' ? 'all' : 'visible', + pose: normalizeModelPoseValue(record?.pose), + moduleStyles: buildModuleStyles(stlFiles, rawStyles), + sliceStart, + sliceEnd, + mappingSlice: normalizeSlice(record?.mappingSlice, sliceEnd), + displayLevel: normalizeDisplayLevel(record?.displayLevel), + dicomOpacityLevel: normalizeDicomOpacityLevel(record?.dicomOpacityLevel), + showBounds: typeof record?.showBounds === 'boolean' ? record.showBounds : true, + cutEnabled: typeof record?.cutEnabled === 'boolean' ? record.cutEnabled : false, + }; + }) + .slice(-1); +} + +function buildDefaultProject(): ProjectRecord { + const stlFiles = listFiles(modelDir, '.stl'); + + return { + id: 'head-ct-demo', + name: '头部 CT 模型逆向体素化演示', + createTime: today(), + status: 'completed', + dicomCount: listFiles(dicomDir, '.dcm').length, + hasModel: stlFiles.length > 0, + dicomPath: 'Head_CT_DICOM', + modelPath: 'Head_CT_ReConstruct', + modelCount: stlFiles.length, + stlFiles, + maskFormats: ['nii', 'nii.gz'], + exportedMaskCount: 0, + isDefault: true, + moduleStyles: buildModuleStyles(stlFiles), + modelPoses: defaultModelPoses(), + segmentationResults: [], + }; +} + +function buildEmptyProject(name: string): ProjectRecord { + return { + id: `project-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, + name, + createTime: today(), + status: 'pending', + dicomCount: 0, + hasModel: false, + dicomPath: '', + modelPath: '', + modelCount: 0, + stlFiles: [], + maskFormats: ['nii', 'nii.gz'], + exportedMaskCount: 0, + moduleStyles: {}, + modelPoses: defaultModelPoses(), + segmentationResults: [], + }; +} + +function defaultState(): AppState { + return { + users: [ + { id: 1, name: 'Admin', account: 'admin', password: '123456', department: 'admin', date: today() }, + { id: 2, name: 'Doctor Li', account: 'doctor_li', password: '123456', department: '肝胆外科', date: today() }, + ], + projects: [buildDefaultProject()], + session: { authenticated: false, account: null, lastUpdated: now() }, + updatedAt: now(), + }; +} + +function normalizeState(state: AppState): AppState { + const defaultProject = buildDefaultProject(); + const savedDefaultProject = state.projects?.find((project) => project.id === defaultProject.id); + const customProjects = Array.isArray(state.projects) + ? state.projects + .filter((project) => project.id !== defaultProject.id) + .map((project) => { + const dicomPath = typeof project.dicomPath === 'string' ? project.dicomPath : ''; + const modelPath = typeof project.modelPath === 'string' ? project.modelPath : ''; + const dicomCount = dicomPath ? listFiles(resolveStoredAssetDir(dicomPath, ''), '.dcm').length : project.dicomCount ?? 0; + const stlFiles = modelPath + ? listFiles(resolveStoredAssetDir(modelPath, ''), '.stl') + : Array.isArray(project.stlFiles) ? project.stlFiles : []; + const moduleStyles = buildModuleStyles(stlFiles, project.moduleStyles); + return { + ...project, + dicomPath, + modelPath, + dicomCount, + stlFiles, + hasModel: stlFiles.length > 0, + modelCount: stlFiles.length, + exportedMaskCount: project.exportedMaskCount ?? 0, + maskFormats: project.maskFormats ?? ['nii', 'nii.gz'], + moduleStyles, + modelPoses: normalizeModelPoses(project.modelPoses), + segmentationResults: normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles, dicomCount), + }; + }) + : []; + const defaultDicomPath = savedDefaultProject?.dicomPath ?? defaultProject.dicomPath; + const defaultModelPath = savedDefaultProject?.modelPath ?? defaultProject.modelPath; + const defaultDicomCount = listFiles(resolveStoredAssetDir(defaultDicomPath, dicomDir), '.dcm').length; + const defaultStlFiles = listFiles(resolveStoredAssetDir(defaultModelPath, modelDir), '.stl'); + const defaultModuleStyles = buildModuleStyles(defaultStlFiles, savedDefaultProject?.moduleStyles); + + return { + ...state, + projects: [ + { + ...defaultProject, + name: savedDefaultProject?.name ?? defaultProject.name, + dicomPath: defaultDicomPath, + modelPath: defaultModelPath, + dicomCount: defaultDicomCount, + hasModel: defaultStlFiles.length > 0, + modelCount: defaultStlFiles.length, + stlFiles: defaultStlFiles, + exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0, + moduleStyles: defaultModuleStyles, + modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses), + segmentationResults: normalizeSegmentationResults( + savedDefaultProject?.segmentationResults, + defaultStlFiles, + defaultModuleStyles, + defaultDicomCount, + ), + }, + ...customProjects, + ], + }; +} + +function readState(): AppState { + ensureDir(dataDir); + + if (!fs.existsSync(statePath)) { + const initialState = defaultState(); + writeState(initialState); + return initialState; + } + + try { + const raw = fs.readFileSync(statePath, 'utf8'); + return normalizeState(JSON.parse(raw) as AppState); + } catch { + const recoveredState = defaultState(); + writeState(recoveredState); + return recoveredState; + } +} + +function writeState(state: AppState) { + ensureDir(dataDir); + fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2)); +} + +interface DicomHuVolume { + width: number; + height: number; + depth: number; + columnSpacing: number; + rowSpacing: number; + sliceSpacing: number; + data: Buffer; + minHu: number; + maxHu: number; +} + +interface Point2DRecord { + x: number; + y: number; +} + +interface Point3DRecord { + x: number; + y: number; + z: number; +} + +interface PlaneSegmentRecord { + a: Point2DRecord; + b: Point2DRecord; +} + +interface ModelBoundsRecord { + min: Point3DRecord; + max: Point3DRecord; +} + +interface ModelPreviewRecord { + fileName: string; + triangleCount: number; + sampledTriangles: number; + vertices: number[]; + bounds: ModelBoundsRecord; +} + +interface ExportSceneMetrics { + center: Point3DRecord; + modelBaseScale: number; + modelPivotOffsetZ: number; + dicomWidth: number; + dicomHeight: number; + dicomDepth: number; +} + +const exportFusionBaseExtent = 4.6; + +function readDicomHuVolume(project: ProjectRecord, files: string[]): DicomHuVolume { + if (!files.length) { + throw new Error('当前项目没有可导出的 DICOM 序列'); + } + + const parsed = files.map((fileName) => { + const buffer = fs.readFileSync(getProjectDicomFilePath(project, fileName)); + const attributes = parseDicomAttributes(buffer, 'default'); + const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010); + if (!attributes.rows || !attributes.columns || !pixelTag) { + throw new Error(`无法解析 DICOM 像素数据:${fileName}`); + } + return { fileName, buffer, attributes, pixelOffset: pixelTag.valueOffset, pixelLength: pixelTag.length }; + }); + const first = parsed[0]; + const width = first.attributes.columns; + const height = first.attributes.rows; + const depth = parsed.length; + const data = Buffer.alloc(width * height * depth * 2); + let minHu = Infinity; + let maxHu = -Infinity; + + parsed.forEach((slice, z) => { + if (slice.attributes.columns !== width || slice.attributes.rows !== height) { + throw new Error(`DICOM 尺寸不一致:${slice.fileName}`); + } + + for (let index = 0; index < width * height; index += 1) { + const position = slice.pixelOffset + index * (slice.attributes.bitsAllocated / 8); + if (position + 1 >= slice.buffer.length || position >= slice.pixelOffset + slice.pixelLength) { + continue; + } + const raw = slice.attributes.bitsAllocated === 16 + ? (slice.attributes.pixelRepresentation ? slice.buffer.readInt16LE(position) : slice.buffer.readUInt16LE(position)) + : slice.buffer.readUInt8(position); + const hu = clampNumber(Math.round(raw * slice.attributes.rescaleSlope + slice.attributes.rescaleIntercept), -32768, 32767); + const outputOffset = (z * width * height + index) * 2; + data.writeInt16LE(hu, outputOffset); + minHu = Math.min(minHu, hu); + maxHu = Math.max(maxHu, hu); + } + }); + + const sliceSpacing = estimateSliceSpacingFromAttributes(parsed.map((item) => item.attributes)).value; + return { + width, + height, + depth, + columnSpacing: first.attributes.columnSpacing, + rowSpacing: first.attributes.rowSpacing, + sliceSpacing, + data, + minHu: Number.isFinite(minHu) ? minHu : 0, + maxHu: Number.isFinite(maxHu) ? maxHu : 0, + }; +} + +function writeNiftiHeader({ + width, + height, + depth, + columnSpacing, + rowSpacing, + sliceSpacing, + datatype, + bitpix, + description, + auxFile, +}: { + width: number; + height: number; + depth: number; + columnSpacing: number; + rowSpacing: number; + sliceSpacing: number; + datatype: number; + bitpix: number; + description: string; + auxFile: string; +}) { + const voxOffset = 352; + const header = Buffer.alloc(voxOffset); + header.writeInt32LE(348, 0); + header.writeInt16LE(3, 40); + header.writeInt16LE(width, 42); + header.writeInt16LE(height, 44); + header.writeInt16LE(depth, 46); + header.writeInt16LE(1, 48); + header.writeInt16LE(1, 50); + header.writeInt16LE(1, 52); + header.writeInt16LE(1, 54); + header.writeInt16LE(datatype, 70); + header.writeInt16LE(bitpix, 72); + header.writeFloatLE(1, 76); + header.writeFloatLE(columnSpacing, 80); + header.writeFloatLE(rowSpacing, 84); + header.writeFloatLE(sliceSpacing, 88); + header.writeFloatLE(voxOffset, 108); + header.writeFloatLE(1, 112); + header.writeUInt8(2, 123); + header.writeInt16LE(1, 252); + header.writeInt16LE(1, 254); + header.write(description.slice(0, 79), 148, 'ascii'); + header.write(auxFile.slice(0, 23), 228, 'ascii'); + header.writeFloatLE(columnSpacing, 280); + header.writeFloatLE(0, 284); + header.writeFloatLE(0, 288); + header.writeFloatLE(0, 292); + header.writeFloatLE(0, 296); + header.writeFloatLE(rowSpacing, 300); + header.writeFloatLE(0, 304); + header.writeFloatLE(0, 308); + header.writeFloatLE(0, 312); + header.writeFloatLE(0, 316); + header.writeFloatLE(sliceSpacing, 320); + header.writeFloatLE(0, 324); + header.write('n+1\0', 344, 'ascii'); + return header; +} + +function createNiftiBuffer(volume: DicomHuVolume, data: Buffer, kind: 'dicom' | 'segmentation', compressed: boolean) { + const isSegmentation = kind === 'segmentation'; + const nifti = Buffer.concat([ + writeNiftiHeader({ + width: volume.width, + height: volume.height, + depth: volume.depth, + columnSpacing: volume.columnSpacing, + rowSpacing: volume.rowSpacing, + sliceSpacing: volume.sliceSpacing, + datatype: isSegmentation ? 2 : 4, + bitpix: isSegmentation ? 8 : 16, + description: isSegmentation ? 'ReVoxelSeg label map' : 'ReVoxelSeg DICOM HU volume', + auxFile: isSegmentation ? 'segmentation' : 'dicom', + }), + data, + ]); + return compressed ? zlib.gzipSync(nifti) : nifti; +} + +function getExportMetrics(project: ProjectRecord, volume: DicomHuVolume, previews: Record): ExportSceneMetrics | null { + const bounds = (project.stlFiles ?? []).reduce((accumulator, fileName) => { + const payload = previews[fileName]; + if (!payload) { + return accumulator; + } + accumulator.min.x = Math.min(accumulator.min.x, payload.bounds.min.x); + accumulator.min.y = Math.min(accumulator.min.y, payload.bounds.min.y); + accumulator.min.z = Math.min(accumulator.min.z, payload.bounds.min.z); + accumulator.max.x = Math.max(accumulator.max.x, payload.bounds.max.x); + accumulator.max.y = Math.max(accumulator.max.y, payload.bounds.max.y); + accumulator.max.z = Math.max(accumulator.max.z, payload.bounds.max.z); + return accumulator; + }, { + min: { x: Infinity, y: Infinity, z: Infinity }, + max: { x: -Infinity, y: -Infinity, z: -Infinity }, + }); + + if (!Number.isFinite(bounds.min.x)) { + return null; + } + + const spanX = Math.max(bounds.max.x - bounds.min.x, 0.001); + const spanY = Math.max(bounds.max.y - bounds.min.y, 0.001); + const spanZ = Math.max(bounds.max.z - bounds.min.z, 0.001); + const maxModelSize = Math.max(spanX, spanY, spanZ, 1); + const physicalWidth = volume.width * volume.columnSpacing; + const physicalHeight = volume.height * volume.rowSpacing; + const physicalDepth = Math.max(volume.depth, 1) * volume.sliceSpacing; + const maxPhysical = Math.max(physicalWidth, physicalHeight, physicalDepth, 1); + const dicomWidth = (physicalWidth / maxPhysical) * exportFusionBaseExtent; + const dicomHeight = (physicalHeight / maxPhysical) * exportFusionBaseExtent; + const dicomDepth = Math.max((physicalDepth / maxPhysical) * exportFusionBaseExtent, 0.18); + const modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92; + + return { + center: { + x: (bounds.min.x + bounds.max.x) / 2, + y: (bounds.min.y + bounds.max.y) / 2, + z: (bounds.min.z + bounds.max.z) / 2, + }, + modelBaseScale, + modelPivotOffsetZ: dicomDepth * 0.08, + dicomWidth, + dicomHeight, + dicomDepth, + }; +} + +function transformPointForExportPose(x: number, y: number, z: number, metrics: ExportSceneMetrics, pose: ModelPoseValue): Point3DRecord { + const scalar = metrics.modelBaseScale * pose.scale; + let px = (x - metrics.center.x) * scalar; + let py = (y - metrics.center.y) * scalar; + let pz = (z - metrics.center.z + metrics.modelPivotOffsetZ) * scalar; + const rotateX = (pose.rotateX * Math.PI) / 180; + const rotateY = (pose.rotateY * Math.PI) / 180; + const rotateZ = (pose.rotateZ * Math.PI) / 180; + const cosX = Math.cos(rotateX); + const sinX = Math.sin(rotateX); + const cosY = Math.cos(rotateY); + const sinY = Math.sin(rotateY); + const cosZ = Math.cos(rotateZ); + const sinZ = Math.sin(rotateZ); + const afterX = { + x: px, + y: py * cosX - pz * sinX, + z: py * sinX + pz * cosX, + }; + const afterY = { + x: afterX.x * cosY + afterX.z * sinY, + y: afterX.y, + z: -afterX.x * sinY + afterX.z * cosY, + }; + px = afterY.x * cosZ - afterY.y * sinZ; + py = afterY.x * sinZ + afterY.y * cosZ; + pz = afterY.z; + + return { + x: px + pose.translateX, + y: py + pose.translateY, + z: pz + pose.translateZ, + }; +} + +function intersectExportEdgeWithPlane(start: Point3DRecord, end: Point3DRecord, targetZ: number): Point2DRecord | null { + const epsilon = 1e-5; + const startDistance = start.z - targetZ; + const endDistance = end.z - targetZ; + + if (Math.abs(startDistance) <= epsilon && Math.abs(endDistance) <= epsilon) { + return null; + } + if (Math.abs(startDistance) <= epsilon) { + return { x: start.x, y: start.y }; + } + if (Math.abs(endDistance) <= epsilon) { + return { x: end.x, y: end.y }; + } + if ((startDistance > 0 && endDistance > 0) || (startDistance < 0 && endDistance < 0)) { + return null; + } + + const t = startDistance / (startDistance - endDistance); + return { + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + }; +} + +function exportPointDistanceSquared(a: Point2DRecord, b: Point2DRecord) { + const dx = a.x - b.x; + const dy = a.y - b.y; + return dx * dx + dy * dy; +} + +function intersectExportTriangleWithPlane(a: Point3DRecord, b: Point3DRecord, c: Point3DRecord, targetZ: number): PlaneSegmentRecord | null { + const intersections = [ + intersectExportEdgeWithPlane(a, b, targetZ), + intersectExportEdgeWithPlane(b, c, targetZ), + intersectExportEdgeWithPlane(c, a, targetZ), + ].filter((point): point is Point2DRecord => Boolean(point)); + const uniquePoints: Point2DRecord[] = []; + + intersections.forEach((point) => { + if (!uniquePoints.some((current) => exportPointDistanceSquared(current, point) < 1e-8)) { + uniquePoints.push(point); + } + }); + + if (uniquePoints.length < 2) { + return null; + } + + let segment: PlaneSegmentRecord = { a: uniquePoints[0], b: uniquePoints[1] }; + let maxDistance = exportPointDistanceSquared(segment.a, segment.b); + for (let first = 0; first < uniquePoints.length; first += 1) { + for (let second = first + 1; second < uniquePoints.length; second += 1) { + const distance = exportPointDistanceSquared(uniquePoints[first], uniquePoints[second]); + if (distance > maxDistance) { + maxDistance = distance; + segment = { a: uniquePoints[first], b: uniquePoints[second] }; + } + } + } + + return maxDistance > 1e-8 ? segment : null; +} + +function readBinaryStlTriangleCount(buffer: Buffer, fileName: string) { + if (buffer.length < 84) { + throw new Error(`STL 文件内容为空或不完整:${fileName}`); + } + + const triangleCount = buffer.readUInt32LE(80); + const expectedLength = 84 + triangleCount * 50; + if (triangleCount <= 0 || expectedLength > buffer.length + 1024) { + throw new Error(`当前仅支持二进制 STL:${fileName}`); + } + + return triangleCount; +} + +function forEachBinaryStlTriangle( + filePath: string, + fileName: string, + callback: ( + ax: number, + ay: number, + az: number, + bx: number, + by: number, + bz: number, + cx: number, + cy: number, + cz: number, + ) => void, +) { + const buffer = fs.readFileSync(filePath); + const triangleCount = readBinaryStlTriangleCount(buffer, fileName); + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { + const offset = 84 + triangleIndex * 50; + if (offset + 50 > buffer.length) { + break; + } + + callback( + buffer.readFloatLE(offset + 12), + buffer.readFloatLE(offset + 16), + buffer.readFloatLE(offset + 20), + buffer.readFloatLE(offset + 24), + buffer.readFloatLE(offset + 28), + buffer.readFloatLE(offset + 32), + buffer.readFloatLE(offset + 36), + buffer.readFloatLE(offset + 40), + buffer.readFloatLE(offset + 44), + ); + } +} + +function addExportSegmentToRows(rows: number[][], width: number, height: number, segment: PlaneSegmentRecord) { + const deltaY = segment.b.y - segment.a.y; + if (Math.abs(deltaY) < 0.01) { + return; + } + + const minY = Math.max(0, Math.floor(Math.min(segment.a.y, segment.b.y))); + const maxY = Math.min(height - 1, Math.ceil(Math.max(segment.a.y, segment.b.y))); + for (let row = minY; row <= maxY; row += 1) { + const sampleY = row + 0.5; + const crosses = (sampleY >= segment.a.y && sampleY < segment.b.y) + || (sampleY >= segment.b.y && sampleY < segment.a.y); + if (!crosses) { + continue; + } + + const t = (sampleY - segment.a.y) / deltaY; + const x = segment.a.x + (segment.b.x - segment.a.x) * t; + if (Number.isFinite(x)) { + rows[row].push(x); + } + } +} + +function fillExportInternalHoles(mask: Uint8Array, width: number, height: number) { + const outside = new Uint8Array(width * height); + const stack: number[] = []; + const pushIfEmpty = (x: number, y: number) => { + if (x < 0 || x >= width || y < 0 || y >= height) { + return; + } + + const index = y * width + x; + if (outside[index] || mask[index]) { + return; + } + + outside[index] = 1; + stack.push(index); + }; + + for (let x = 0; x < width; x += 1) { + pushIfEmpty(x, 0); + pushIfEmpty(x, height - 1); + } + for (let y = 0; y < height; y += 1) { + pushIfEmpty(0, y); + pushIfEmpty(width - 1, y); + } + + while (stack.length) { + const index = stack.pop(); + if (index === undefined) { + continue; + } + + const x = index % width; + const y = Math.floor(index / width); + pushIfEmpty(x + 1, y); + pushIfEmpty(x - 1, y); + pushIfEmpty(x, y + 1); + pushIfEmpty(x, y - 1); + } + + let patchedPixels = 0; + for (let index = 0; index < mask.length; index += 1) { + if (!outside[index] && !mask[index]) { + mask[index] = 1; + patchedPixels += 1; + } + } + + return patchedPixels; +} + +function fillExportRows(data: Buffer, width: number, height: number, slice: number, rows: number[][], label: number) { + const mask = new Uint8Array(width * height); + let filledPixels = 0; + + rows.forEach((intersections, row) => { + if (intersections.length < 2) { + return; + } + + intersections.sort((left, right) => left - right); + const cleaned: number[] = []; + intersections.forEach((x) => { + const previous = cleaned[cleaned.length - 1]; + if (previous === undefined || Math.abs(previous - x) > 0.35) { + cleaned.push(x); + } + }); + + for (let index = 0; index + 1 < cleaned.length; index += 2) { + const rawStartX = cleaned[index]; + const rawEndX = cleaned[index + 1]; + if (rawEndX < 0 || rawStartX > width - 1) { + continue; + } + + const startX = clampNumber(Math.ceil(rawStartX), 0, width - 1); + const endX = clampNumber(Math.floor(rawEndX), 0, width - 1); + for (let x = startX; x <= endX; x += 1) { + const index = row * width + x; + if (!mask[index]) { + mask[index] = 1; + filledPixels += 1; + } + } + } + }); + + if (filledPixels === 0) { + return 0; + } + + filledPixels += fillExportInternalHoles(mask, width, height); + const sliceOffset = slice * width * height; + for (let index = 0; index < mask.length; index += 1) { + if (mask[index]) { + data[sliceOffset + index] = label; + } + } + + return filledPixels; +} + +function getModuleStyle(project: ProjectRecord, fileName: string, index: number): ModuleStyleRecord { + return project.moduleStyles[fileName] ?? { + visible: true, + color: defaultModuleColors[index % defaultModuleColors.length], + opacity: 0.72, + partId: index + 1, + }; +} + +function isModuleIncludedForExport(style: ModuleStyleRecord, scope: SegmentationExportScope) { + return scope === 'all' || style.visible !== false; +} + +function createSegmentationData( + project: ProjectRecord, + volume: DicomHuVolume, + pose: ModelPoseValue, + scope: SegmentationExportScope = 'visible', + onlyFileName?: string, +) { + const data = Buffer.alloc(volume.width * volume.height * volume.depth); + const previews = (project.stlFiles ?? []).reduce>((accumulator, fileName) => { + const filePath = getProjectModelFilePath(project, fileName); + if (fs.existsSync(filePath)) { + accumulator[fileName] = createStlPreview(filePath, fileName, 200000) as ModelPreviewRecord; + } + return accumulator; + }, {}); + const metrics = getExportMetrics(project, volume, previews); + if (!metrics) { + return data; + } + + const sliceToZ = (slice: number) => ( + volume.depth <= 1 + ? 0 + : -metrics.dicomDepth / 2 + (metrics.dicomDepth * slice) / (volume.depth - 1) + ); + const zToSlice = (z: number) => ( + volume.depth <= 1 + ? 0 + : ((z + metrics.dicomDepth / 2) / metrics.dicomDepth) * (volume.depth - 1) + ); + const mapPoint = (point: Point2DRecord): Point2DRecord => ({ + x: ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * volume.width, + y: volume.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * volume.height, + }); + + (project.stlFiles ?? []).forEach((fileName, index) => { + const payload = previews[fileName]; + const style = getModuleStyle(project, fileName, index); + + if (!payload || (onlyFileName && fileName !== onlyFileName) || !isModuleIncludedForExport(style, scope)) { + return; + } + + const label = clampNumber(Math.round(style.partId || index + 1), 1, 255); + const rowsBySlice = new Map(); + const rowsForSlice = (slice: number) => { + const existing = rowsBySlice.get(slice); + if (existing) { + return existing; + } + const rows = Array.from({ length: volume.height }, () => [] as number[]); + rowsBySlice.set(slice, rows); + return rows; + }; + + const filePath = getProjectModelFilePath(project, fileName); + forEachBinaryStlTriangle(filePath, fileName, ( + ax, + ay, + az, + bx, + by, + bz, + cx, + cy, + cz, + ) => { + const a = transformPointForExportPose( + ax, + ay, + az, + metrics, + pose, + ); + const b = transformPointForExportPose( + bx, + by, + bz, + metrics, + pose, + ); + const c = transformPointForExportPose( + cx, + cy, + cz, + metrics, + pose, + ); + const minSlice = clampNumber(Math.floor(zToSlice(Math.min(a.z, b.z, c.z))) - 1, 0, volume.depth - 1); + const maxSlice = clampNumber(Math.ceil(zToSlice(Math.max(a.z, b.z, c.z))) + 1, 0, volume.depth - 1); + + for (let slice = minSlice; slice <= maxSlice; slice += 1) { + const segment = intersectExportTriangleWithPlane(a, b, c, sliceToZ(slice)); + if (!segment) { + continue; + } + + addExportSegmentToRows(rowsForSlice(slice), volume.width, volume.height, { + a: mapPoint(segment.a), + b: mapPoint(segment.b), + }); + } + }); + + rowsBySlice.forEach((rows, slice) => { + fillExportRows(data, volume.width, volume.height, slice, rows, label); + }); + }); + + return data; +} + +function createSegmentationLabelMetadata(project: ProjectRecord, scope: SegmentationExportScope, activePose?: ModelPoseValue) { + const labels = (project.stlFiles ?? []) + .map((fileName, index) => { + const style = getModuleStyle(project, fileName, index); + if (!isModuleIncludedForExport(style, scope)) { + return null; + } + const name = fileName.replace(/\.stl$/i, ''); + const label = clampNumber(Math.round(style.partId || index + 1), 1, 255); + + return { + label, + partId: label, + name, + categoryName: name, + className: name, + fileName, + color: style.color, + opacity: style.opacity, + visible: style.visible !== false, + }; + }) + .filter((item): item is { + label: number; + partId: number; + name: string; + categoryName: string; + className: string; + fileName: string; + color: string; + opacity: number; + visible: boolean; + } => Boolean(item)); + + return Buffer.from(JSON.stringify({ + project: { + id: project.id, + name: project.name, + dicomPath: project.dicomPath, + modelPath: project.modelPath, + }, + generatedAt: now(), + segmentationScope: scope, + activePose: activePose ?? null, + labels, + note: 'Label values correspond to ReVoxelSeg STL component hierarchy partId values.', + }, null, 2), 'utf8'); +} + +function parseModelPoseQuery(raw: unknown) { + if (typeof raw !== 'string' || !raw.trim()) { + return undefined; + } + + try { + return normalizeModelPoseValue(JSON.parse(raw) as Partial); + } catch { + return undefined; + } +} + +function parseSegmentationScope(raw: unknown): SegmentationExportScope { + return raw === 'all' ? 'all' : 'visible'; +} + +function parseSegmentationExportMode(raw: unknown): SegmentationExportMode { + return raw === 'separate' ? 'separate' : 'combined'; +} + +function latestSegmentationResult(project: ProjectRecord) { + return project.segmentationResults?.[project.segmentationResults.length - 1]; +} + +function projectWithSegmentationResultStyles(project: ProjectRecord): ProjectRecord { + const latestResult = latestSegmentationResult(project); + if (!latestResult) { + return project; + } + + return { + ...project, + moduleStyles: latestResult.moduleStyles, + }; +} + +function parseExportTargets(raw: unknown): ProjectExportTarget[] { + const values = typeof raw === 'string' ? raw.split(',') : []; + const targets = values.filter((value): value is ProjectExportTarget => ( + value === 'dicom' || value === 'segmentation' || value === 'pose' || value === 'stl' + )); + return [...new Set(targets)]; +} + +function sanitizeUploadFileName(name: string, fallback: string, extension: string) { + const baseName = path.basename(name || fallback).replace(/[\\/:*?"<>|]+/g, '_').trim(); + const withFallback = baseName || fallback; + return withFallback.toLowerCase().endsWith(extension) ? withFallback : `${withFallback}${extension}`; +} + +function decodeUploadedAssetData(raw: string) { + const base64 = raw.includes(',') ? raw.slice(raw.indexOf(',') + 1) : raw; + if (!base64.trim()) { + throw new Error('上传文件内容为空'); + } + return Buffer.from(base64, 'base64'); +} + +function parseUploadedAssets(raw: unknown): UploadedAssetPayload[] { + if (!Array.isArray(raw)) { + throw new Error('上传文件列表无效'); + } + return raw + .map((item) => item && typeof item === 'object' ? item as Record : null) + .filter((item): item is Record => Boolean(item)) + .map((item, index) => ({ + name: typeof item.name === 'string' && item.name.trim() ? item.name.trim() : `asset-${index + 1}`, + data: typeof item.data === 'string' ? item.data : '', + })) + .filter((item) => item.data); +} + +function trimTarText(value: Buffer) { + return value.toString('utf8').replace(/\0.*$/s, '').trim(); +} + +function looksLikeDicom(data: Buffer) { + return data.length > 132 && data.toString('ascii', 128, 132) === 'DICM'; +} + +function isImportableAsset(kind: 'dicom' | 'stl', name: string, data: Buffer) { + const lowerName = name.toLowerCase(); + if (kind === 'stl') { + return lowerName.endsWith('.stl'); + } + return lowerName.endsWith('.dcm') || lowerName.endsWith('.dicom') || looksLikeDicom(data); +} + +function parseTarEntries(data: Buffer, sourceName: string): PreparedAssetFile[] { + const entries: PreparedAssetFile[] = []; + let offset = 0; + while (offset + 512 <= data.length) { + const header = data.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + break; + } + + const name = trimTarText(header.subarray(0, 100)); + const prefix = trimTarText(header.subarray(345, 500)); + const typeFlag = header.toString('utf8', 156, 157); + const sizeValue = trimTarText(header.subarray(124, 136)).replace(/[^0-7]/g, ''); + const size = Number.parseInt(sizeValue || '0', 8) || 0; + const dataStart = offset + 512; + const dataEnd = Math.min(dataStart + size, data.length); + const archiveName = prefix ? `${prefix}/${name}` : name; + + if ((typeFlag === '0' || typeFlag === '') && archiveName && size > 0) { + entries.push({ + name: archiveName, + data: Buffer.from(data.subarray(dataStart, dataEnd)), + }); + } + + offset = dataStart + Math.ceil(size / 512) * 512; + } + + if (!entries.length) { + throw new Error(`${sourceName} 中没有可读取的 TAR 文件条目`); + } + return entries; +} + +function expandUploadedAsset(file: Express.Multer.File): PreparedAssetFile[] { + const lowerName = file.originalname.toLowerCase(); + if (lowerName.endsWith('.zip')) { + const archive = new AdmZip(file.path); + return archive.getEntries() + .filter((entry) => !entry.isDirectory) + .map((entry) => ({ name: entry.entryName, data: entry.getData() })); + } + + if (lowerName.endsWith('.tar.gz') || lowerName.endsWith('.tgz')) { + return parseTarEntries(zlib.gunzipSync(fs.readFileSync(file.path)), file.originalname); + } + + if (lowerName.endsWith('.tar')) { + return parseTarEntries(fs.readFileSync(file.path), file.originalname); + } + + if (lowerName.endsWith('.gz')) { + const outputName = file.originalname.replace(/\.gz$/i, '') || `${file.originalname}.raw`; + return [{ name: outputName, data: zlib.gunzipSync(fs.readFileSync(file.path)) }]; + } + + return [{ name: file.originalname, data: fs.readFileSync(file.path) }]; +} + +function safeImportedFileName(name: string, fallback: string, extension: string, usedNames: Set) { + const initial = sanitizeUploadFileName(name, fallback, extension); + const parsed = path.parse(initial); + let candidate = initial; + let suffix = 2; + while (usedNames.has(candidate.toLowerCase())) { + candidate = `${parsed.name}-${suffix}${parsed.ext || extension}`; + suffix += 1; + } + usedNames.add(candidate.toLowerCase()); + return candidate; +} + +function collectPreparedAssetFiles(kind: 'dicom' | 'stl', uploadedFiles: Express.Multer.File[], legacyFiles: UploadedAssetPayload[]) { + const expandedFiles: PreparedAssetFile[] = []; + + uploadedFiles.forEach((file) => { + expandedFiles.push(...expandUploadedAsset(file)); + }); + + legacyFiles.forEach((file) => { + expandedFiles.push({ name: file.name, data: decodeUploadedAssetData(file.data) }); + }); + + return expandedFiles.filter((file) => file.data.length > 0 && isImportableAsset(kind, file.name, file.data)); +} + +function cleanupUploadedTempFiles(files: Express.Multer.File[]) { + files.forEach((file) => { + if (file.path) { + fs.rmSync(file.path, { force: true }); + } + }); +} + +function createNiftiExport( + project: ProjectRecord, + files: string[], + target: 'dicom' | 'segmentation', + compressed: boolean, + pose?: ModelPoseValue, + segmentationScope: SegmentationExportScope = 'visible', +) { + const volume = readDicomHuVolume(project, files); + if (target === 'dicom') { + return createNiftiBuffer(volume, volume.data, 'dicom', compressed); + } + + return createNiftiBuffer( + volume, + createSegmentationData(project, volume, pose ?? defaultModelPose, segmentationScope), + 'segmentation', + compressed, + ); +} + +function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) { + return Buffer.from(JSON.stringify({ + project: { + id: project.id, + name: project.name, + dicomPath: project.dicomPath, + modelPath: project.modelPath, + }, + generatedAt: now(), + activePose: activePose ?? null, + modelPoses: project.modelPoses, + moduleStyles: project.moduleStyles, + note: 'Pose values are stored in the ReVoxelSeg fusion view coordinate system.', + }, null, 2), 'utf8'); +} + +function createProjectExportBundle({ + project, + files, + targets, + compressed, + activePose, + segmentationScope, + segmentationExportMode, + exportRoot, +}: { + project: ProjectRecord; + files: string[]; + targets: ProjectExportTarget[]; + compressed: boolean; + activePose?: ModelPoseValue; + segmentationScope: SegmentationExportScope; + segmentationExportMode: SegmentationExportMode; + exportRoot: string; +}) { + const entries: Array<{ name: string; data: Buffer; mtime?: number }> = []; + const needsVolume = targets.includes('dicom') || targets.includes('segmentation'); + const volume = needsVolume ? readDicomHuVolume(project, files) : null; + const format = compressed ? 'nii.gz' : 'nii'; + + if (targets.includes('dicom') && volume) { + entries.push({ + name: `${exportRoot}/${project.id}-dicom-image.${format}`, + data: createNiftiBuffer(volume, volume.data, 'dicom', compressed), + }); + } + + if (targets.includes('segmentation') && volume) { + if (segmentationExportMode === 'separate') { + (project.stlFiles ?? []).forEach((fileName, index) => { + const style = getModuleStyle(project, fileName, index); + if (!isModuleIncludedForExport(style, segmentationScope)) { + return; + } + const moduleName = sanitizeFilenamePart(fileName.replace(/\.stl$/i, ''), `module-${index + 1}`); + entries.push({ + name: `${exportRoot}/segmentation/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`, + data: createNiftiBuffer( + volume, + createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope, fileName), + 'segmentation', + compressed, + ), + }); + }); + } else { + entries.push({ + name: `${exportRoot}/${project.id}-segmentation-label.${format}`, + data: createNiftiBuffer( + volume, + createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope), + 'segmentation', + compressed, + ), + }); + } + entries.push({ + name: `${exportRoot}/${project.id}-segmentation-labels.json`, + data: createSegmentationLabelMetadata(project, segmentationScope, activePose), + }); + } + + if (targets.includes('pose')) { + entries.push({ + name: `${exportRoot}/${project.id}-pose-data.json`, + data: createPoseExport(project, activePose), + }); + } + + if (targets.includes('stl')) { + (project.stlFiles ?? []).forEach((fileName) => { + const filePath = getProjectModelFilePath(project, fileName); + if (!fs.existsSync(filePath)) { + return; + } + + const stat = fs.statSync(filePath); + entries.push({ + name: `${exportRoot}/STL/${fileName}`, + data: fs.readFileSync(filePath), + mtime: stat.mtimeMs / 1000, + }); + }); + } + + if (!entries.length) { + throw new Error('未选择可导出的内容'); + } + + return createTarGz(entries); +} + +function findProject(state: AppState, projectId: string) { + return state.projects.find((candidate) => candidate.id === projectId); +} + +function getProjectDicomFiles(project: ProjectRecord) { + return listFiles(getProjectDicomDir(project), '.dcm'); +} + +function readAsciiValue(buffer: Buffer, start: number, length: number) { + return buffer.subarray(start, start + length).toString('ascii').replace(/\0/g, '').trim(); +} + +function readTagString(buffer: Buffer, group: number, element: number) { + const tag = findExplicitTag(buffer, group, element); + return tag ? readAsciiValue(buffer, tag.valueOffset, tag.length) : ''; +} + +function readTagUInt16(buffer: Buffer, group: number, element: number, fallback = 0) { + const tag = findExplicitTag(buffer, group, element); + return tag && tag.valueOffset + 1 < buffer.length ? buffer.readUInt16LE(tag.valueOffset) : fallback; +} + +function parseNumberList(value: string) { + return value + .split('\\') + .map((item) => Number.parseFloat(item.trim())) + .filter((item) => Number.isFinite(item)); +} + +function median(values: number[]) { + if (!values.length) { + return null; + } + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +function parseDicomAttributes(buffer: Buffer, mode: DicomDisplayMode): DicomAttributes { + const fallbackCenter = Number.parseFloat(readTagString(buffer, 0x0028, 0x1050).split('\\')[0]) || 40; + const fallbackWidth = Number.parseFloat(readTagString(buffer, 0x0028, 0x1051).split('\\')[0]) || 400; + const { windowCenter, windowWidth } = resolveDisplayWindow(mode, fallbackCenter, fallbackWidth); + const pixelSpacing = parseNumberList(readTagString(buffer, 0x0028, 0x0030)); + const imagePosition = parseNumberList(readTagString(buffer, 0x0020, 0x0032)); + const sliceThickness = Number.parseFloat(readTagString(buffer, 0x0018, 0x0050)); + const spacingBetweenSlices = Number.parseFloat(readTagString(buffer, 0x0018, 0x0088)); + + return { + patientName: readTagString(buffer, 0x0010, 0x0010) || '未知', + patientId: readTagString(buffer, 0x0010, 0x0020) || '未知', + studyDate: readTagString(buffer, 0x0008, 0x0020) || '未知', + studyDescription: readTagString(buffer, 0x0008, 0x1030) || '未知', + seriesDescription: readTagString(buffer, 0x0008, 0x103e) || '未知', + modality: readTagString(buffer, 0x0008, 0x0060) || '未知', + manufacturer: readTagString(buffer, 0x0008, 0x0070) || '未知', + rows: readTagUInt16(buffer, 0x0028, 0x0010), + columns: readTagUInt16(buffer, 0x0028, 0x0011), + bitsAllocated: readTagUInt16(buffer, 0x0028, 0x0100, 16), + pixelRepresentation: readTagUInt16(buffer, 0x0028, 0x0103), + windowCenter, + windowWidth, + rescaleIntercept: Number.parseFloat(readTagString(buffer, 0x0028, 0x1052)) || 0, + rescaleSlope: Number.parseFloat(readTagString(buffer, 0x0028, 0x1053)) || 1, + rowSpacing: pixelSpacing[0] || 1, + columnSpacing: pixelSpacing[1] || pixelSpacing[0] || 1, + sliceThickness: Number.isFinite(sliceThickness) ? Math.abs(sliceThickness) : null, + spacingBetweenSlices: Number.isFinite(spacingBetweenSlices) ? Math.abs(spacingBetweenSlices) : null, + imagePosition: imagePosition.length >= 3 ? imagePosition.slice(0, 3) : null, + }; +} + +function findExplicitTag(buffer: Buffer, group: number, element: number) { + const pattern = Buffer.from([ + group & 0xff, + (group >> 8) & 0xff, + element & 0xff, + (element >> 8) & 0xff, + ]); + const longVr = ['OB', 'OD', 'OF', 'OL', 'OW', 'SQ', 'UC', 'UR', 'UT', 'UN']; + let offset = buffer.indexOf(pattern, 132); + + while (offset >= 0 && offset + 8 < buffer.length) { + const vr = buffer.subarray(offset + 4, offset + 6).toString('ascii'); + if (/^[A-Z]{2}$/.test(vr)) { + if (longVr.includes(vr)) { + const length = buffer.readUInt32LE(offset + 8); + return { valueOffset: offset + 12, length, vr }; + } + const length = buffer.readUInt16LE(offset + 6); + return { valueOffset: offset + 8, length, vr }; + } + + offset = buffer.indexOf(pattern, offset + 1); + } + + return null; +} + +function resolveDisplayWindow(mode: DicomDisplayMode, fallbackCenter: number, fallbackWidth: number) { + if (mode === 'bone') { + return { windowCenter: 500, windowWidth: 2000 }; + } + if (mode === 'soft') { + return { windowCenter: 40, windowWidth: 400 }; + } + if (mode === 'contrast') { + return { windowCenter: 80, windowWidth: 180 }; + } + return { windowCenter: fallbackCenter, windowWidth: fallbackWidth }; +} + +function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default') { + const buffer = fs.readFileSync(filePath); + const attrs = parseDicomAttributes(buffer, mode); + const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010); + const pixelOffset = pixelTag?.valueOffset ?? -1; + const pixelLength = pixelTag?.length ?? 0; + + if (!attrs.rows || !attrs.columns || pixelOffset < 0) { + throw new Error('无法解析当前 DICOM 像素数据'); + } + + const count = attrs.rows * attrs.columns; + const pixels = Buffer.alloc(count); + const min = attrs.windowCenter - attrs.windowWidth / 2; + const max = attrs.windowCenter + attrs.windowWidth / 2; + + for (let i = 0; i < count; i += 1) { + const position = pixelOffset + i * (attrs.bitsAllocated / 8); + if (position + 1 >= buffer.length || position >= pixelOffset + pixelLength) { + break; + } + const raw = attrs.bitsAllocated === 16 + ? (attrs.pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position)) + : buffer.readUInt8(position); + const hu = raw * attrs.rescaleSlope + attrs.rescaleIntercept; + let normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255))); + if (mode === 'contrast') { + normalized = Math.max(0, Math.min(255, Math.round((normalized - 128) * 1.35 + 128))); + } + pixels[i] = normalized; + } + + const enhancedPixels = enhanceDicomEdges(pixels, attrs.columns, attrs.rows); + + return { + width: attrs.columns, + height: attrs.rows, + pixels: enhancedPixels.toString('base64'), + windowCenter: attrs.windowCenter, + windowWidth: attrs.windowWidth, + mode, + spacing: { + row: attrs.rowSpacing, + column: attrs.columnSpacing, + slice: attrs.sliceThickness ?? attrs.spacingBetweenSlices ?? 1, + }, + physicalSize: { + width: attrs.columns * attrs.columnSpacing, + height: attrs.rows * attrs.rowSpacing, + }, + attributes: attrs, + }; +} + +function parseDicomPixels(filePath: string, mode: DicomDisplayMode = 'default') { + const preview = parseDicomPreview(filePath, mode); + return { + ...preview, + pixelBuffer: Buffer.from(preview.pixels, 'base64'), + }; +} + +function estimateSliceSpacing(parsed: ReturnType[]) { + const positionDiffs: number[] = []; + for (let index = 1; index < parsed.length; index += 1) { + const previous = parsed[index - 1].attributes.imagePosition; + const current = parsed[index].attributes.imagePosition; + if (previous && current) { + const dx = current[0] - previous[0]; + const dy = current[1] - previous[1]; + const dz = current[2] - previous[2]; + const distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (distance > 0.0001) { + positionDiffs.push(distance); + } + } + } + + return median(positionDiffs) + ?? parsed[0]?.attributes.spacingBetweenSlices + ?? parsed[0]?.attributes.sliceThickness + ?? 1; +} + +function getDicomVolume(project: ProjectRecord, files: string[], mode: DicomDisplayMode) { + const cacheKey = `${project.id}:${project.dicomPath}:${mode}:${files.join('|')}`; + const cached = dicomVolumeCache.get(cacheKey); + if (cached) { + return cached; + } + + const parsed = files.map((fileName) => parseDicomPixels(getProjectDicomFilePath(project, fileName), mode)); + const sliceSpacing = estimateSliceSpacing(parsed); + const volume = { + frames: parsed.map((frame) => frame.pixelBuffer), + width: parsed[0]?.width ?? 0, + height: parsed[0]?.height ?? 0, + windowCenter: parsed[0]?.windowCenter ?? 40, + windowWidth: parsed[0]?.windowWidth ?? 400, + rowSpacing: parsed[0]?.attributes.rowSpacing ?? 1, + columnSpacing: parsed[0]?.attributes.columnSpacing ?? 1, + sliceSpacing, + sliceThickness: parsed[0]?.attributes.sliceThickness ?? null, + spacingBetweenSlices: parsed[0]?.attributes.spacingBetweenSlices ?? null, + }; + dicomVolumeCache.set(cacheKey, volume); + return volume; +} + +function resampleNearest(pixels: Buffer, width: number, height: number, targetWidth: number, targetHeight: number) { + if (width === targetWidth && height === targetHeight) { + return pixels; + } + + const output = Buffer.alloc(targetWidth * targetHeight); + for (let y = 0; y < targetHeight; y += 1) { + const sourceY = Math.min(height - 1, Math.floor((y / targetHeight) * height)); + for (let x = 0; x < targetWidth; x += 1) { + const sourceX = Math.min(width - 1, Math.floor((x / targetWidth) * width)); + output[y * targetWidth + x] = pixels[sourceY * width + sourceX]; + } + } + return output; +} + +function resampleToPhysicalAspect(pixels: Buffer, width: number, height: number, xSpacing: number, ySpacing: number) { + const physicalWidth = width * xSpacing; + const physicalHeight = height * ySpacing; + const unit = Math.max(0.001, Math.min(xSpacing, ySpacing)); + let targetWidth = Math.max(1, Math.round(physicalWidth / unit)); + let targetHeight = Math.max(1, Math.round(physicalHeight / unit)); + const maxDimension = 960; + const scale = Math.min(1, maxDimension / Math.max(targetWidth, targetHeight)); + targetWidth = Math.max(1, Math.round(targetWidth * scale)); + targetHeight = Math.max(1, Math.round(targetHeight * scale)); + + return { + width: targetWidth, + height: targetHeight, + pixels: resampleNearest(pixels, width, height, targetWidth, targetHeight), + physicalWidth, + physicalHeight, + }; +} + +function warmDicomVolumeCache(project: ProjectRecord, files: string[]) { + setTimeout(() => { + try { + getDicomVolume(project, files, 'default'); + getDicomVolume(project, files, 'soft'); + } catch (error) { + console.warn('DICOM volume warmup failed:', error); + } + }, 300); +} + +function createReformattedPreview(project: ProjectRecord, files: string[], plane: Exclude, slice: number, mode: DicomDisplayMode) { + const volume = getDicomVolume(project, files, mode); + const maxSlice = plane === 'sagittal' ? volume.width - 1 : volume.height - 1; + const clampedSlice = Math.max(0, Math.min(maxSlice, slice)); + const outputWidth = files.length; + const outputHeight = plane === 'sagittal' ? volume.height : volume.width; + const pixels = Buffer.alloc(outputWidth * outputHeight); + + volume.frames.forEach((frame, z) => { + for (let row = 0; row < outputHeight; row += 1) { + const sourceIndex = plane === 'sagittal' + ? row * volume.width + clampedSlice + : clampedSlice * volume.width + row; + const targetIndex = row * outputWidth + z; + pixels[targetIndex] = frame[sourceIndex] ?? 0; + } + }); + + const cropped = cropDicomContent(pixels, outputWidth, outputHeight); + const ySpacing = plane === 'sagittal' ? volume.rowSpacing : volume.columnSpacing; + const physical = resampleToPhysicalAspect(cropped.pixels, cropped.width, cropped.height, volume.sliceSpacing, ySpacing); + const enhancedPixels = enhanceDicomEdges(physical.pixels, physical.width, physical.height); + + return { + width: physical.width, + height: physical.height, + pixels: enhancedPixels.toString('base64'), + windowCenter: volume.windowCenter, + windowWidth: volume.windowWidth, + slice: clampedSlice, + total: maxSlice + 1, + fileName: `${plane}-${clampedSlice}`, + mode, + spacing: { + row: volume.rowSpacing, + column: volume.columnSpacing, + slice: volume.sliceSpacing, + displayX: volume.sliceSpacing, + displayY: ySpacing, + }, + physicalSize: { + width: physical.physicalWidth, + height: physical.physicalHeight, + }, + }; +} + +function createDicomFusionVolume(project: ProjectRecord, files: string[], start: number, end: number, mode: DicomDisplayMode) { + const volume = getDicomVolume(project, files, mode); + const total = volume.frames.length; + const safeStart = Math.max(0, Math.min(total - 1, Number.isFinite(start) ? start : 0)); + const safeEnd = Math.max(safeStart, Math.min(total - 1, Number.isFinite(end) ? end : safeStart + 49)); + const maxFrames = 64; + const rangeLength = safeEnd - safeStart + 1; + const step = Math.max(1, Math.ceil(rangeLength / maxFrames)); + const indices: number[] = []; + for (let index = safeStart; index <= safeEnd; index += step) { + indices.push(index); + } + if (indices[indices.length - 1] !== safeEnd) { + indices.push(safeEnd); + } + + const maxTextureDimension = 256; + const textureScale = Math.min(1, maxTextureDimension / Math.max(volume.width, volume.height)); + const targetWidth = Math.max(1, Math.round(volume.width * textureScale)); + const targetHeight = Math.max(1, Math.round(volume.height * textureScale)); + const frames = indices.map((index) => ( + resampleNearest(volume.frames[index], volume.width, volume.height, targetWidth, targetHeight).toString('base64') + )); + + return { + width: targetWidth, + height: targetHeight, + start: safeStart, + end: safeEnd, + total, + indices, + frames, + mode, + spacing: { + row: volume.rowSpacing, + column: volume.columnSpacing, + slice: volume.sliceSpacing, + }, + physicalSize: { + width: volume.width * volume.columnSpacing, + height: volume.height * volume.rowSpacing, + depth: Math.max(1, total) * volume.sliceSpacing, + unit: 'mm', + }, + }; +} + +function enhanceDicomEdges(pixels: Buffer, width: number, height: number) { + if (width < 3 || height < 3) { + return pixels; + } + + const output = Buffer.from(pixels); + for (let y = 1; y < height - 1; y += 1) { + for (let x = 1; x < width - 1; x += 1) { + const index = y * width + x; + const center = pixels[index]; + const neighborAverage = ( + pixels[index - 1] + + pixels[index + 1] + + pixels[index - width] + + pixels[index + width] + ) / 4; + const sharpened = Math.round(center * 1.08 + (center - neighborAverage) * 0.55); + output[index] = Math.max(0, Math.min(255, sharpened)); + } + } + return output; +} + +function cropDicomContent(pixels: Buffer, width: number, height: number) { + const threshold = 12; + const columnHits = Array.from({ length: width }, () => 0); + const rowHits = Array.from({ length: height }, () => 0); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (pixels[y * width + x] > threshold) { + columnHits[x] += 1; + rowHits[y] += 1; + } + } + } + + const minColumnHits = Math.max(4, Math.floor(height * 0.012)); + const minRowHits = Math.max(4, Math.floor(width * 0.012)); + let minX = columnHits.findIndex((hits) => hits >= minColumnHits); + let maxX = width - 1 - [...columnHits].reverse().findIndex((hits) => hits >= minColumnHits); + let minY = rowHits.findIndex((hits) => hits >= minRowHits); + let maxY = height - 1 - [...rowHits].reverse().findIndex((hits) => hits >= minRowHits); + + if (maxX < minX || maxY < minY) { + return { pixels, width, height }; + } + + const padding = 18; + minX = Math.max(0, minX - padding); + minY = Math.max(0, minY - padding); + maxX = Math.min(width - 1, maxX + padding); + maxY = Math.min(height - 1, maxY + padding); + + const croppedWidth = maxX - minX + 1; + const croppedHeight = maxY - minY + 1; + const croppedPixels = Buffer.alloc(croppedWidth * croppedHeight); + for (let row = 0; row < croppedHeight; row += 1) { + const sourceStart = (minY + row) * width + minX; + pixels.copy(croppedPixels, row * croppedWidth, sourceStart, sourceStart + croppedWidth); + } + + return { pixels: croppedPixels, width: croppedWidth, height: croppedHeight }; +} + +function createStlPreview(filePath: string, fileName: string, limit: number): ModelPreviewRecord { + const cacheKey = `${filePath}:${fileName}:${limit}`; + const cached = modelPreviewCache.get(cacheKey); + if (cached) { + return cached as ModelPreviewRecord; + } + + const buffer = fs.readFileSync(filePath); + if (buffer.length < 84) { + throw new Error('STL 文件内容为空或不完整'); + } + + const triangleCount = buffer.readUInt32LE(80); + const expectedLength = 84 + triangleCount * 50; + if (triangleCount <= 0 || expectedLength > buffer.length + 1024) { + throw new Error('当前仅支持二进制 STL 预览'); + } + + const sampleLimit = Math.max(100, Math.min(limit, 200000)); + const step = Math.max(1, Math.ceil(triangleCount / sampleLimit)); + const vertices: number[] = []; + let sampledTriangles = 0; + const bounds = { + min: { x: Infinity, y: Infinity, z: Infinity }, + max: { x: -Infinity, y: -Infinity, z: -Infinity }, + }; + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { + const offset = 84 + triangleIndex * 50; + if (offset + 50 > buffer.length) { + break; + } + + const shouldSample = triangleIndex % step === 0; + for (let vertex = 0; vertex < 3; vertex += 1) { + const vertexOffset = offset + 12 + vertex * 12; + const x = buffer.readFloatLE(vertexOffset); + const y = buffer.readFloatLE(vertexOffset + 4); + const z = buffer.readFloatLE(vertexOffset + 8); + bounds.min.x = Math.min(bounds.min.x, x); + bounds.min.y = Math.min(bounds.min.y, y); + bounds.min.z = Math.min(bounds.min.z, z); + bounds.max.x = Math.max(bounds.max.x, x); + bounds.max.y = Math.max(bounds.max.y, y); + bounds.max.z = Math.max(bounds.max.z, z); + if (shouldSample) { + vertices.push( + Number(x.toFixed(3)), + Number(y.toFixed(3)), + Number(z.toFixed(3)), + ); + } + } + if (shouldSample) { + sampledTriangles += 1; + } + } + + const payload: ModelPreviewRecord = { + fileName, + triangleCount, + sampledTriangles, + vertices, + bounds: { + min: { + x: Number(bounds.min.x.toFixed(3)), + y: Number(bounds.min.y.toFixed(3)), + z: Number(bounds.min.z.toFixed(3)), + }, + max: { + x: Number(bounds.max.x.toFixed(3)), + y: Number(bounds.max.y.toFixed(3)), + z: Number(bounds.max.z.toFixed(3)), + }, + }, + }; + modelPreviewCache.set(cacheKey, payload); + return payload; +} + +function writeOctal(buffer: Buffer, value: number, offset: number, length: number) { + const text = value.toString(8).padStart(length - 1, '0').slice(-(length - 1)); + buffer.write(`${text}\0`, offset, length, 'ascii'); +} + +function createTarEntryHeader(name: string, size: number, mtime: number) { + const header = Buffer.alloc(512); + const safeName = name.slice(0, 100); + header.write(safeName, 0, 100, 'utf8'); + writeOctal(header, 0o644, 100, 8); + writeOctal(header, 0, 108, 8); + writeOctal(header, 0, 116, 8); + writeOctal(header, size, 124, 12); + writeOctal(header, Math.floor(mtime), 136, 12); + header.fill(' ', 148, 156); + header.write('0', 156, 1, 'ascii'); + header.write('ustar', 257, 6, 'ascii'); + header.write('00', 263, 2, 'ascii'); + + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + writeOctal(header, checksum, 148, 8); + return header; +} + +function createTarGz(entries: Array<{ name: string; data: Buffer; mtime?: number }>) { + const chunks: Buffer[] = []; + + entries.forEach((entry) => { + const data = entry.data; + chunks.push(createTarEntryHeader(entry.name, data.length, entry.mtime ?? Date.now() / 1000)); + chunks.push(data); + const remainder = data.length % 512; + if (remainder > 0) { + chunks.push(Buffer.alloc(512 - remainder)); + } + }); + + chunks.push(Buffer.alloc(1024)); + return zlib.gzipSync(Buffer.concat(chunks)); +} + +function createDicomTarGz(project: ProjectRecord, files: string[]) { + const rootName = sanitizeFilenamePart(project.dicomPath || 'DICOM', 'DICOM'); + return createTarGz(files.map((fileName) => { + const filePath = getProjectDicomFilePath(project, fileName); + const stat = fs.statSync(filePath); + return { + name: `${rootName}/${fileName}`, + data: fs.readFileSync(filePath), + mtime: stat.mtimeMs / 1000, + }; + })); +} + +function estimateSliceSpacingFromAttributes(attributes: DicomAttributes[]) { + const diffs: number[] = []; + for (let index = 1; index < attributes.length; index += 1) { + const previous = attributes[index - 1].imagePosition; + const current = attributes[index].imagePosition; + if (previous && current) { + const dx = current[0] - previous[0]; + const dy = current[1] - previous[1]; + const dz = current[2] - previous[2]; + const distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (distance > 0.0001) { + diffs.push(distance); + } + } + } + + return { + value: median(diffs) + ?? attributes[0]?.spacingBetweenSlices + ?? attributes[0]?.sliceThickness + ?? 1, + source: diffs.length ? 'ImagePositionPatient' : attributes[0]?.spacingBetweenSlices ? 'SpacingBetweenSlices' : attributes[0]?.sliceThickness ? 'SliceThickness' : '默认 1mm', + }; +} + +function formatNumber(value: number | null | undefined, digits = 3) { + return typeof value === 'number' && Number.isFinite(value) ? Number(value.toFixed(digits)) : null; +} + +function createDicomInfo(project: ProjectRecord, files: string[]) { + const attributes = files.map((fileName) => { + const buffer = fs.readFileSync(getProjectDicomFilePath(project, fileName)); + return parseDicomAttributes(buffer, 'default'); + }); + const first = attributes[0]; + const last = attributes[attributes.length - 1]; + const sliceSpacing = estimateSliceSpacingFromAttributes(attributes); + const physicalWidth = first.columns * first.columnSpacing; + const physicalHeight = first.rows * first.rowSpacing; + const physicalDepth = Math.max(files.length - 1, 0) * sliceSpacing.value; + + return { + project: { + id: project.id, + name: project.name, + dicomPath: project.dicomPath, + }, + patient: { + name: first.patientName, + id: first.patientId, + }, + study: { + date: first.studyDate, + description: first.studyDescription, + modality: first.modality, + manufacturer: first.manufacturer, + }, + series: { + description: first.seriesDescription, + files: files.length, + firstFile: files[0] ?? '', + lastFile: files[files.length - 1] ?? '', + }, + image: { + rows: first.rows, + columns: first.columns, + bitsAllocated: first.bitsAllocated, + pixelRepresentation: first.pixelRepresentation, + windowCenter: first.windowCenter, + windowWidth: first.windowWidth, + rescaleIntercept: first.rescaleIntercept, + rescaleSlope: first.rescaleSlope, + }, + spacing: { + row: formatNumber(first.rowSpacing), + column: formatNumber(first.columnSpacing), + slice: formatNumber(sliceSpacing.value), + sliceSource: sliceSpacing.source, + sliceThickness: formatNumber(first.sliceThickness), + spacingBetweenSlices: formatNumber(first.spacingBetweenSlices), + }, + physicalSize: { + width: formatNumber(physicalWidth), + height: formatNumber(physicalHeight), + depth: formatNumber(physicalDepth), + unit: 'mm', + }, + position: { + firstImagePosition: first.imagePosition, + lastImagePosition: last?.imagePosition ?? null, + }, + }; +} + +async function startServer() { + const app = express(); + const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0'; + const portArg = process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : process.env.PORT; + const port = Number(portArg ?? 4000); + + ensureDir(exportDir); + ensureDir(uploadTempDir); + const assetUpload = multer({ + storage: multer.diskStorage({ + destination: (_req, _file, callback) => { + ensureDir(uploadTempDir); + callback(null, uploadTempDir); + }, + filename: (_req, file, callback) => { + const safeName = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}-${path.basename(file.originalname).replace(/[\\/:*?"<>|]+/g, '_')}`; + callback(null, safeName); + }, + }), + limits: { + files: 5000, + fileSize: 2 * 1024 * 1024 * 1024, + }, + }); + app.use(express.json({ limit: '512mb' })); + + app.get('/api/health', (_req, res) => { + res.json({ ok: true, service: 'revoxelseg-dicom', time: now() }); + }); + + app.get('/api/session', (_req, res) => { + res.json(publicSession(readState())); + }); + + app.post('/api/login', (req, res) => { + const { account, password } = req.body as { account?: string; password?: string }; + const state = readState(); + const user = state.users.find((candidate) => candidate.account === account && candidate.password === password); + + if (!user) { + res.status(401).json({ message: '账号或密码错误' }); + return; + } + + state.session = { authenticated: true, account: user.account, lastUpdated: now() }; + writeState(state); + res.json(publicSession(state)); + }); + + app.post('/api/logout', (_req, res) => { + const state = readState(); + state.session = { authenticated: false, account: null, lastUpdated: now() }; + writeState(state); + res.json(publicSession(state)); + }); + + app.get('/api/users', (_req, res) => { + res.json(readState().users.map(publicUser)); + }); + + app.post('/api/users', (req, res) => { + const state = readState(); + const payload = parseUserPayload(req.body); + if (!payload.name || !payload.account || !payload.department || !payload.password) { + res.status(400).json({ message: '姓名、账号、科室和密码不能为空' }); + return; + } + if (state.users.some((user) => user.account === payload.account)) { + res.status(409).json({ message: '账号已存在' }); + return; + } + + const nextId = Math.max(0, ...state.users.map((user) => user.id)) + 1; + const user: UserRecord = { + id: nextId, + name: payload.name, + account: payload.account, + password: payload.password, + department: payload.department, + date: today(), + }; + state.users.push(user); + writeState(state); + res.status(201).json(publicUser(user)); + }); + + app.patch('/api/users/:userId', (req, res) => { + const state = readState(); + const userId = Number.parseInt(req.params.userId, 10); + const user = state.users.find((candidate) => candidate.id === userId); + if (!user) { + res.status(404).json({ message: '用户不存在' }); + return; + } + + const payload = parseUserPayload(req.body, user); + if (!payload.name || !payload.account || !payload.department || !payload.password) { + res.status(400).json({ message: '姓名、账号、科室和密码不能为空' }); + return; + } + if (state.users.some((candidate) => candidate.id !== user.id && candidate.account === payload.account)) { + res.status(409).json({ message: '账号已存在' }); + return; + } + + const previousAccount = user.account; + user.name = payload.name; + user.account = payload.account; + user.department = payload.department; + user.password = payload.password; + if (state.session.account === previousAccount) { + state.session = { authenticated: true, account: user.account, lastUpdated: now() }; + } + writeState(state); + res.json(publicUser(user)); + }); + + app.delete('/api/users/:userId', (req, res) => { + const state = readState(); + const userId = Number.parseInt(req.params.userId, 10); + const index = state.users.findIndex((candidate) => candidate.id === userId); + if (index === -1) { + res.status(404).json({ message: '用户不存在' }); + return; + } + const user = state.users[index]; + if (state.session.account === user.account) { + res.status(400).json({ message: '不能删除当前登录用户' }); + return; + } + if (state.users.length <= 1) { + res.status(400).json({ message: '至少保留一个用户' }); + return; + } + + state.users.splice(index, 1); + writeState(state); + res.json({ ok: true, deletedId: user.id }); + }); + + app.get('/api/projects', (_req, res) => { + res.json(readState().projects); + }); + + app.post('/api/projects', (req, res) => { + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + if (!name) { + res.status(400).json({ message: '项目名称不能为空' }); + return; + } + + const state = readState(); + const project = buildEmptyProject(name); + state.projects.push(project); + writeState(state); + res.status(201).json(project); + }); + + app.get('/api/projects/:projectId', (req, res) => { + const project = findProject(readState(), req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + res.json(project); + }); + + app.patch('/api/projects/:projectId', (req, res) => { + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + if (!name) { + res.status(400).json({ message: '项目名称不能为空' }); + return; + } + + const state = readState(); + const project = findProject(state, req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + project.name = name; + writeState(state); + res.json(project); + }); + + app.delete('/api/projects/:projectId', (req, res) => { + const state = readState(); + const index = state.projects.findIndex((project) => project.id === req.params.projectId); + if (index < 0) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const [deleted] = state.projects.splice(index, 1); + writeState(state); + res.json({ ok: true, deletedId: deleted.id }); + }); + + app.patch('/api/projects/:projectId/module-styles', (req, res) => { + const incoming = req.body?.moduleStyles; + if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { + res.status(400).json({ message: '构件样式数据无效' }); + return; + } + + const state = readState(); + const project = findProject(state, req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + project.moduleStyles = buildModuleStyles(project.stlFiles, { + ...(project.moduleStyles ?? {}), + ...(incoming as Record>), + }); + writeState(state); + res.json(project); + }); + + app.patch('/api/projects/:projectId/model-poses', (req, res) => { + const incoming = req.body?.modelPoses; + if (!Array.isArray(incoming)) { + res.status(400).json({ message: '位姿数据无效' }); + return; + } + + const state = readState(); + const project = findProject(state, req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + project.modelPoses = normalizeModelPoses(incoming as Partial[]); + writeState(state); + res.json(project); + }); + + app.post('/api/projects/:projectId/import-assets', (req, res) => { + assetUpload.array('files', 5000)(req, res, (uploadError) => { + if (uploadError) { + res.status(413).json({ message: uploadError instanceof Error ? uploadError.message : '上传文件过大或格式无效' }); + return; + } + + const kind = req.body?.kind === 'stl' ? 'stl' : 'dicom'; + const multerFiles = Array.isArray(req.files) ? req.files as Express.Multer.File[] : []; + let legacyUploadedFiles: UploadedAssetPayload[]; + try { + legacyUploadedFiles = req.body?.files ? parseUploadedAssets(req.body.files) : []; + } catch (error) { + cleanupUploadedTempFiles(multerFiles); + res.status(400).json({ message: error instanceof Error ? error.message : '上传文件列表无效' }); + return; + } + + if (!multerFiles.length && !legacyUploadedFiles.length) { + res.status(400).json({ message: '请选择需要导入的文件' }); + return; + } + + const state = readState(); + const project = findProject(state, req.params.projectId); + if (!project) { + cleanupUploadedTempFiles(multerFiles); + res.status(404).json({ message: '项目不存在' }); + return; + } + + const targetDir = path.join(uploadDir, project.id, kind === 'dicom' ? 'DICOM' : 'STL'); + try { + const preparedFiles = collectPreparedAssetFiles(kind, multerFiles, legacyUploadedFiles); + if (!preparedFiles.length) { + throw new Error(kind === 'dicom' + ? '未找到可导入的 DICOM 文件,请选择 .dcm/.dicom 文件或包含 DICOM 的 ZIP/TAR 压缩包' + : '未找到可导入的 STL 文件,请选择 .stl 文件或包含 STL 的 ZIP/TAR 压缩包'); + } + + fs.rmSync(targetDir, { recursive: true, force: true }); + ensureDir(targetDir); + const usedNames = new Set(); + preparedFiles.forEach((file, index) => { + const fileName = safeImportedFileName( + file.name, + kind === 'dicom' ? `slice-${String(index + 1).padStart(4, '0')}.dcm` : `model-${index + 1}.stl`, + kind === 'dicom' ? '.dcm' : '.stl', + usedNames, + ); + fs.writeFileSync(path.join(targetDir, fileName), file.data); + }); + + if (kind === 'dicom') { + const dicomFiles = listFiles(targetDir, '.dcm'); + project.dicomPath = toRepoRelativePath(targetDir); + project.dicomCount = dicomFiles.length; + project.segmentationResults = []; + const dicomInfo = createDicomInfo(project, dicomFiles); + writeCachedDicomInfo(project, dicomFiles, dicomInfo); + } else { + const stlFiles = listFiles(targetDir, '.stl'); + project.modelPath = toRepoRelativePath(targetDir); + project.stlFiles = stlFiles; + project.modelCount = stlFiles.length; + project.hasModel = stlFiles.length > 0; + project.moduleStyles = buildModuleStyles(stlFiles, project.moduleStyles); + project.segmentationResults = []; + } + + project.status = project.dicomCount > 0 && project.hasModel ? 'completed' : 'pending'; + clearProjectRuntimeCaches(project.id); + writeState(state); + res.json(project); + } catch (error) { + console.error('[import-assets] failed', { + projectId: req.params.projectId, + kind, + fileCount: multerFiles.length + legacyUploadedFiles.length, + message: error instanceof Error ? error.message : error, + }); + res.status(422).json({ message: error instanceof Error ? error.message : '项目资产导入失败' }); + } finally { + cleanupUploadedTempFiles(multerFiles); + } + }); + }); + + app.post('/api/projects/:projectId/segmentation-results', (req, res) => { + const state = readState(); + const project = findProject(state, req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const rawName = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + const rawStyles = req.body?.moduleStyles && typeof req.body.moduleStyles === 'object' && !Array.isArray(req.body.moduleStyles) + ? { + ...project.moduleStyles, + ...(req.body.moduleStyles as Record>), + } + : project.moduleStyles; + const record: SegmentationResultRecord = { + id: `segmentation-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, + schemaVersion: 2, + name: rawName || '逆向分割结果', + createdAt: now(), + segmentationScope: parseSegmentationScope(req.body?.segmentationScope), + pose: normalizeModelPoseValue(req.body?.pose as Partial | undefined), + moduleStyles: buildModuleStyles(project.stlFiles, rawStyles), + sliceStart: Number(req.body?.sliceStart), + sliceEnd: Number(req.body?.sliceEnd), + mappingSlice: Number(req.body?.mappingSlice), + displayLevel: req.body?.displayLevel as SegmentationDisplayLevel, + dicomOpacityLevel: req.body?.dicomOpacityLevel as SegmentationDicomOpacityLevel, + showBounds: typeof req.body?.showBounds === 'boolean' ? req.body.showBounds : true, + cutEnabled: typeof req.body?.cutEnabled === 'boolean' ? req.body.cutEnabled : false, + }; + + project.moduleStyles = record.moduleStyles; + project.segmentationResults = normalizeSegmentationResults( + [record], + project.stlFiles, + record.moduleStyles, + project.dicomCount, + ); + writeState(state); + res.status(201).json(project); + }); + + app.get('/api/projects/:projectId/dicom-preview', (req, res) => { + const project = findProject(readState(), req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const files = getProjectDicomFiles(project); + if (!files.length) { + res.status(404).json({ message: '当前项目没有可预览的 DICOM 文件' }); + return; + } + + const requestedPlane = String(req.query.plane ?? 'axial'); + const plane: DicomPlane = requestedPlane === 'sagittal' || requestedPlane === 'coronal' ? requestedPlane : 'axial'; + const requestedMode = String(req.query.mode ?? 'default'); + const mode: DicomDisplayMode = requestedMode === 'bone' || requestedMode === 'soft' || requestedMode === 'contrast' ? requestedMode : 'default'; + const requestedSlice = Number.parseInt(String(req.query.slice ?? '0'), 10); + const cacheKey = `${project.id}:${plane}:${mode}:${Number.isFinite(requestedSlice) ? requestedSlice : 0}`; + if (dicomPreviewCache.has(cacheKey)) { + res.json(dicomPreviewCache.get(cacheKey)); + return; + } + + try { + let payload: unknown; + if (plane === 'axial') { + const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0)); + const preview = parseDicomPreview(getProjectDicomFilePath(project, files[slice]), mode); + payload = { + ...preview, + plane, + slice, + total: files.length, + fileName: files[slice], + }; + } else { + payload = { + ...createReformattedPreview(project, files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0, mode), + plane, + }; + } + + dicomPreviewCache.set(cacheKey, payload); + res.json(payload); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 预览失败' }); + } + }); + + app.get('/api/projects/:projectId/dicom-fusion-volume', (req, res) => { + const project = findProject(readState(), req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const files = getProjectDicomFiles(project); + if (!files.length) { + res.status(404).json({ message: '当前项目没有可融合的 DICOM 文件' }); + return; + } + + const requestedMode = String(req.query.mode ?? 'soft'); + const mode: DicomDisplayMode = requestedMode === 'bone' || requestedMode === 'soft' || requestedMode === 'contrast' ? requestedMode : 'soft'; + const start = Number.parseInt(String(req.query.start ?? '0'), 10); + const end = Number.parseInt(String(req.query.end ?? '49'), 10); + + try { + res.json(createDicomFusionVolume(project, files, start, end, mode)); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 三维融合体生成失败' }); + } + }); + + app.get('/api/projects/:projectId/dicom-archive', (req, res) => { + const project = findProject(readState(), req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const files = getProjectDicomFiles(project); + if (!files.length) { + res.status(404).json({ message: '当前项目没有可下载的 DICOM 文件' }); + return; + } + + try { + const archive = createDicomTarGz(project, files); + const filename = `${project.id}-${project.dicomPath || 'DICOM'}-${files.length}-files.tar.gz`; + res.setHeader('Content-Type', 'application/gzip'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(archive); + } catch (error) { + res.status(500).json({ message: error instanceof Error ? error.message : 'DICOM 压缩包生成失败' }); + } + }); + + app.get('/api/projects/:projectId/dicom-info', (req, res) => { + const project = findProject(readState(), req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const files = getProjectDicomFiles(project); + if (!files.length) { + res.status(404).json({ message: '当前项目没有可查询的 DICOM 文件' }); + return; + } + + try { + const cachedInfo = readCachedDicomInfo(project, files); + if (cachedInfo) { + res.json(cachedInfo); + return; + } + + const info = createDicomInfo(project, files); + writeCachedDicomInfo(project, files, info); + res.json(info); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 信息解析失败' }); + } + }); + + app.get('/api/projects/:projectId/models/:fileName', (req, res) => { + const project = findProject(readState(), req.params.projectId); + const fileName = path.basename(req.params.fileName); + + if (!project || !project.stlFiles.includes(fileName)) { + res.status(404).json({ message: '模型文件不存在' }); + return; + } + + res.sendFile(getProjectModelFilePath(project, fileName)); + }); + + app.get('/api/projects/:projectId/models/:fileName/preview', (req, res) => { + const project = findProject(readState(), req.params.projectId); + const fileName = path.basename(req.params.fileName); + const limit = Number.parseInt(String(req.query.limit ?? '5000'), 10); + + if (!project || !project.stlFiles.includes(fileName)) { + res.status(404).json({ message: '模型文件不存在' }); + return; + } + + try { + res.json(createStlPreview(getProjectModelFilePath(project, fileName), fileName, Number.isFinite(limit) ? limit : 5000)); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' }); + } + }); + + app.get('/api/overview', (_req, res) => { + const state = readState(); + const dicomCount = state.projects.reduce((sum, project) => sum + project.dicomCount, 0); + const modelCount = state.projects.reduce((sum, project) => sum + project.modelCount, 0); + const exportedMaskProjects = state.projects.filter((project) => project.exportedMaskCount > 0).length; + + res.json({ + totalProjects: state.projects.length, + processedProjects: exportedMaskProjects, + exportedMaskProjects, + dicomCount, + modelCount, + chartData: [ + { name: 'Mon', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Tue', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Wed', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Thu', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Fri', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Sat', projects: state.projects.length, processing: exportedMaskProjects }, + { name: 'Sun', projects: state.projects.length, processing: exportedMaskProjects }, + ], + }); + }); + + app.post('/api/demo/reset', (_req, res) => { + const state = defaultState(); + writeState(state); + res.json({ ok: true, projects: state.projects, users: state.users.map(publicUser) }); + }); + + const handleProjectExport = (req: express.Request, res: express.Response, targetOverride?: 'segmentation') => { + const state = readState(); + const project = state.projects.find((candidate) => candidate.id === req.params.projectId); + + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation'); + const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : 'segmentation'; + const exportProject = projectWithSegmentationResultStyles(project); + const latestResult = latestSegmentationResult(project); + const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose; + const segmentationScope = req.query.segmentationScope === undefined + ? latestResult?.segmentationScope ?? 'visible' + : parseSegmentationScope(req.query.segmentationScope); + + try { + if (target === 'pose') { + const posePayload = createPoseExport(exportProject, activePose); + const filename = `${project.id}-pose-data.json`; + fs.writeFileSync(path.join(exportDir, filename), posePayload); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(posePayload); + return; + } + + const files = getProjectDicomFiles(project); + const format = req.query.format === 'nii' ? 'nii' : 'nii.gz'; + const compressed = format === 'nii.gz'; + const payload = createNiftiExport(exportProject, files, target, compressed, activePose, segmentationScope); + const suffix = target === 'dicom' ? 'dicom-image' : 'segmentation-label'; + const filename = `${project.id}-${suffix}.${format}`; + fs.writeFileSync(path.join(exportDir, filename), payload); + project.exportedMaskCount += target === 'segmentation' ? 1 : 0; + writeState(state); + + res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(payload); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : '导出失败' }); + } + }; + + app.get('/api/projects/:projectId/export-bundle', (req, res) => { + const state = readState(); + const project = state.projects.find((candidate) => candidate.id === req.params.projectId); + + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const targets = parseExportTargets(req.query.targets); + if (!targets.length) { + res.status(400).json({ message: '请至少选择一个导出内容' }); + return; + } + + const exportProject = projectWithSegmentationResultStyles(project); + const latestResult = latestSegmentationResult(project); + const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose; + const segmentationScope = req.query.segmentationScope === undefined + ? latestResult?.segmentationScope ?? 'visible' + : parseSegmentationScope(req.query.segmentationScope); + const segmentationExportMode = parseSegmentationExportMode(req.query.segmentationExportMode); + const format = req.query.format === 'nii' ? 'nii' : 'nii.gz'; + const compressed = format === 'nii.gz'; + + try { + const files = getProjectDicomFiles(project); + const exportBase = `${sanitizeFilenamePart(project.name, project.id)}_${timestampForFilename()}`; + const payload = createProjectExportBundle({ + project: exportProject, + files, + targets, + compressed, + activePose, + segmentationScope, + segmentationExportMode, + exportRoot: exportBase, + }); + const filename = `${exportBase}.tar.gz`; + fs.writeFileSync(path.join(exportDir, filename), payload); + project.exportedMaskCount += targets.includes('segmentation') ? 1 : 0; + writeState(state); + + res.setHeader('Content-Type', 'application/gzip'); + res.setHeader('Content-Disposition', contentDispositionAttachment(filename)); + res.send(payload); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : '导出包生成失败' }); + } + }); + + app.get('/api/projects/:projectId/export-nifti', (req, res) => handleProjectExport(req, res)); + app.get('/api/projects/:projectId/export-mask', (req, res) => handleProjectExport(req, res, 'segmentation')); + app.post('/api/projects/:projectId/export-mask', (req, res) => handleProjectExport(req, res, 'segmentation')); + + if (process.env.NODE_ENV === 'production') { + app.use(express.static(path.join(__dirname, 'dist'))); + app.get('*', (_req, res) => { + res.sendFile(path.join(__dirname, 'dist', 'index.html')); + }); + } else { + const vite = await createViteServer({ + server: { middlewareMode: true, hmr: { port: 24679 } }, + appType: 'spa', + }); + app.use(vite.middlewares); + } + + app.listen(port, host, () => { + console.log(`ReVoxelSeg DICOM server ready at http://${host}:${port}/`); + const defaultProject = buildDefaultProject(); + warmDicomVolumeCache(defaultProject, getProjectDicomFiles(defaultProject)); + }); +} + +startServer().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx new file mode 100644 index 0000000..8074cf8 --- /dev/null +++ b/WebSite/src/App.tsx @@ -0,0 +1,200 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, useRef } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import Login from './components/Login'; +import Sidebar from './components/Sidebar'; +import Overview from './components/Overview'; +import ProjectLibrary from './components/ProjectLibrary'; +import ReverseWorkspace from './components/ReverseWorkspace'; +import UserManagement from './components/UserManagement'; +import { ViewType } from './types'; +import { api } from './lib/api'; + +export default function App() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [sessionLoading, setSessionLoading] = useState(true); + const [activeView, setActiveView] = useState(ViewType.OVERVIEW); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [activeProjectId, setActiveProjectId] = useState('head-ct-demo'); + const [projectLibraryInitialView, setProjectLibraryInitialView] = useState<'dicom' | 'model' | 'mask'>('dicom'); + const workspaceLeaveGuardRef = useRef<(() => Promise) | null>(null); + const bootSessionResetRef = useRef(false); + + // Automatically collapse main sidebar when entering Project Library or Workspace + useEffect(() => { + if (activeView === ViewType.PROJECTS || activeView === ViewType.WORKSPACE) { + setSidebarCollapsed(true); + } else { + setSidebarCollapsed(false); + } + }, [activeView]); + + useEffect(() => { + let mounted = true; + + const syncSession = async () => { + try { + if (!bootSessionResetRef.current) { + bootSessionResetRef.current = true; + const session = await api.logout(); + if (!mounted) { + return; + } + setIsAuthenticated(session.authenticated); + setActiveView(ViewType.OVERVIEW); + return; + } + const session = await api.getSession(); + if (!mounted) { + return; + } + setIsAuthenticated(session.authenticated); + if (!session.authenticated) { + setActiveView(ViewType.OVERVIEW); + } + } catch { + if (mounted) { + setIsAuthenticated(false); + } + } finally { + if (mounted) { + setSessionLoading(false); + } + } + }; + + syncSession(); + const interval = window.setInterval(syncSession, 2500); + return () => { + mounted = false; + window.clearInterval(interval); + }; + }, []); + + const handleLogin = () => { + setIsAuthenticated(true); + }; + + const requestActiveView = (nextView: ViewType) => { + if (nextView === activeView) { + return; + } + + const leaveWorkspace = activeView === ViewType.WORKSPACE && nextView !== ViewType.WORKSPACE; + const switchView = () => { + if (leaveWorkspace && nextView === ViewType.PROJECTS) { + setProjectLibraryInitialView('mask'); + } + setActiveView(nextView); + }; + + if (!leaveWorkspace || !workspaceLeaveGuardRef.current) { + switchView(); + return; + } + + workspaceLeaveGuardRef.current() + .then((canLeave) => { + if (canLeave) { + switchView(); + } + }) + .catch(() => undefined); + }; + + const handleLogout = async () => { + if (activeView === ViewType.WORKSPACE && workspaceLeaveGuardRef.current) { + const canLeave = await workspaceLeaveGuardRef.current(); + if (!canLeave) { + return; + } + } + await api.logout(); + setIsAuthenticated(false); + setActiveView(ViewType.OVERVIEW); + }; + + if (sessionLoading) { + return ( +
+ 正在同步登录状态... +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ( +
+ + +
+ {/* Top Navigation */} +
+
+ + {activeView === ViewType.OVERVIEW && '总体概况'} + {activeView === ViewType.PROJECTS && '项目库'} + {activeView === ViewType.WORKSPACE && '逆向工作区'} + {activeView === ViewType.SYSTEM && '系统管理工作区'} + +
+ +
+
+
+ + {/* Content Area */} +
+ + + {activeView === ViewType.OVERVIEW && } + {activeView === ViewType.PROJECTS && ( + { + setActiveProjectId(projectId); + setActiveView(ViewType.WORKSPACE); + }} + /> + )} + {activeView === ViewType.WORKSPACE && ( + { + workspaceLeaveGuardRef.current = handler; + }} + /> + )} + {activeView === ViewType.SYSTEM && } + + +
+
+
+ ); +} diff --git a/WebSite/src/components/Login.tsx b/WebSite/src/components/Login.tsx new file mode 100644 index 0000000..8456628 --- /dev/null +++ b/WebSite/src/components/Login.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { motion } from 'motion/react'; +import { Lock, User } from 'lucide-react'; +import { api } from '../lib/api'; + +interface LoginProps { + onLogin: () => void; +} + +export default function Login({ onLogin }: LoginProps) { + const [username, setUsername] = useState('admin'); + const [password, setPassword] = useState('123456'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + await api.login(username, password); + onLogin(); + } catch (err) { + setError(err instanceof Error ? err.message : '登录失败'); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+
+ 模型逆向系统 +
+

基于模型逆向体素化及DICOM分割标注系统

+

模型逆向系统

+
+ +
+
+
+ +
+ + + + setUsername(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-neutral-50 border border-neutral-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all outline-none" + placeholder="请输入账号" + /> +
+
+
+ +
+ + + + setPassword(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-neutral-50 border border-neutral-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all outline-none" + placeholder="请输入密码" + /> +
+
+
+ + + {error && ( +

{error}

+ )} +
+
+ +
+
+ ); +} diff --git a/WebSite/src/components/Overview.tsx b/WebSite/src/components/Overview.tsx new file mode 100644 index 0000000..0c2199f --- /dev/null +++ b/WebSite/src/components/Overview.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react'; +import { motion } from 'motion/react'; +import { + FolderRoot, + CheckCircle2, + Activity, + Database, + Box +} from 'lucide-react'; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { api } from '../lib/api'; +import { OverviewSummary } from '../types'; + +export default function Overview() { + const [summary, setSummary] = useState(null); + + useEffect(() => { + api.getOverview().then(setSummary).catch(() => setSummary(null)); + }, []); + + const stats = [ + { label: '项目总数', value: String(summary?.totalProjects ?? '-'), icon: FolderRoot, color: 'bg-blue-500', trend: '同步' }, + { label: '已导出 Mask 项目', value: String(summary?.exportedMaskProjects ?? summary?.processedProjects ?? '-'), icon: CheckCircle2, color: 'bg-emerald-500', trend: '结果' }, + { label: 'DICOM 切片数', value: String(summary?.dicomCount ?? '-'), icon: Database, color: 'bg-indigo-500', trend: '默认' }, + { label: 'STL 模型数', value: String(summary?.modelCount ?? '-'), icon: Box, color: 'bg-cyan-500', trend: '默认' }, + ]; + const chartData = summary?.chartData ?? []; + + return ( +
+
+
+

总体概况

+

欢迎回来,这是您的数据概览

+
+
+ +
+ {stats.map((stat, i) => ( + +
+
+ +
+ + {stat.trend} + +
+
+

{stat.label}

+

{stat.value}

+
+
+ ))} +
+ +
+ +
+

+ + 项目趋势 +

+ +
+
+ {chartData.length > 0 && ( + + + + + + + + + + + + + + + + )} +
+
+ + +
+

+ + 已处理项目趋势 +

+ +
+
+ {chartData.length > 0 && ( + + + + + + + + + + + + + + + + )} +
+
+
+ +
+ ); +} diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx new file mode 100644 index 0000000..b970e1f --- /dev/null +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -0,0 +1,2239 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Plus, + Search, + Eye, + FileArchive, + RotateCw, + RotateCcw, + Box, + Image as ImageIcon, + Info, + ChevronRight, + ChevronUp, + ChevronDown, + Edit2, + FolderRoot, + Download, + Layers, + X, + Trash2, + Upload +} from 'lucide-react'; +import * as THREE from 'three'; +import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types'; +import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectAssetImportKind, ProjectAssetImportProgress, ProjectExportTarget, SegmentationExportMode } from '../lib/api'; +import { + FusionThreeView, + OverlayStats, + VoxelizationMappingView, + clearCachedProjectAssets, + getCachedDicomFusionVolume, + getCachedDicomPreview, + getCachedModelPreview, + dicomOpacityOptions as reverseDicomOpacityOptions, + displayOptions as reverseDisplayOptions, +} from './ReverseWorkspace'; + +type Plane = 'axial' | 'sagittal' | 'coronal'; +type DisplayMode = DicomPreview['mode']; +type SolidityLevel = 'standard' | 'fine' | 'ultra' | 'solid'; + +interface ModelPose { + rotateX: number; + rotateY: number; + rotateZ: number; + translateX: number; + translateY: number; + translateZ: number; + scale: number; +} + +interface ModelPreviewPayload { + fileName: string; + triangleCount: number; + sampledTriangles: number; + vertices: number[]; + bounds?: { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; + }; +} + +type ModelPoseKey = keyof ModelPose; + +const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; +const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [ + { id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' }, + { id: 'stl', label: 'STL 原始模型', description: '原始三维构件' }, + { id: 'pose', label: '位姿数据', description: 'JSON 侧车' }, + { id: 'segmentation', label: '分割影像', description: '同维度 Label Map' }, +]; +const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [ + { id: 'visible', label: '可见类别', description: '仅导出当前显示构件' }, + { id: 'all', label: '所有类别', description: '包含隐藏构件' }, +]; +const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [ + { id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' }, + { id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' }, +]; +const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [ + { id: 'standard', label: '标准', limit: 16000 }, + { id: 'fine', label: '精细', limit: 36000 }, + { id: 'ultra', label: '超精细', limit: 72000 }, + { id: 'solid', label: '实体', limit: 200000 }, +]; +const defaultModelPose: ModelPose = { + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, +}; +const emptyOverlayStats: OverlayStats = { + activeModules: 0, + filledPixels: 0, + segmentCount: 0, + modules: [], +}; +const modelPoseLimits: Record = { + rotateX: { min: -180, max: 180 }, + rotateY: { min: -180, max: 180 }, + rotateZ: { min: -180, max: 180 }, + translateX: { min: -2, max: 2 }, + translateY: { min: -2, max: 2 }, + translateZ: { min: -2, max: 2 }, + scale: { min: 0.5, max: 2.5 }, +}; +const modelPoseStepPrecision: Partial> = { + scale: 3, +}; + +function clampModelPoseValue(key: ModelPoseKey, value: number) { + const limit = modelPoseLimits[key]; + const clampedValue = Math.max(limit.min, Math.min(limit.max, value)); + const precision = modelPoseStepPrecision[key]; + return typeof precision === 'number' ? Number(clampedValue.toFixed(precision)) : clampedValue; +} + +function getControlStepPrecision(step: number) { + if (step >= 1) { + return 0; + } + + const text = step.toString(); + if (text.includes('e-')) { + return Number(text.split('e-')[1] ?? 2); + } + + return text.split('.')[1]?.length ?? 0; +} + +function clampModelPose(next: ModelPose): ModelPose { + return { + rotateX: clampModelPoseValue('rotateX', next.rotateX), + rotateY: clampModelPoseValue('rotateY', next.rotateY), + rotateZ: clampModelPoseValue('rotateZ', next.rotateZ), + translateX: clampModelPoseValue('translateX', next.translateX), + translateY: clampModelPoseValue('translateY', next.translateY), + translateZ: clampModelPoseValue('translateZ', next.translateZ), + scale: clampModelPoseValue('scale', next.scale), + }; +} + +function formatPoseCompactValue(value: number, digits = 2) { + return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0'; +} + +interface AssetImportProgressState { + kind: ProjectAssetImportKind; + fileCount: number; + totalBytes: number; + loadedBytes: number; + percent: number; + phase: 'uploading' | 'processing' | 'done' | 'failed'; + message?: string; +} + +function formatFileSize(value: number) { + if (!Number.isFinite(value) || value <= 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024))); + return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +} + +function describeImportKind(kind: ProjectAssetImportKind) { + return kind === 'dicom' ? 'DICOM 影像' : '3D 模型'; +} + +function drawFallbackModelPreview( + canvas: HTMLCanvasElement, + previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>, +) { + const rect = canvas.getBoundingClientRect(); + const parentRect = canvas.parentElement?.getBoundingClientRect(); + const width = Math.max(Math.floor(rect.width || parentRect?.width || 720), 1); + const height = Math.max(Math.floor(rect.height || parentRect?.height || 460), 1); + canvas.width = width * window.devicePixelRatio; + canvas.height = height * window.devicePixelRatio; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + const context = canvas.getContext('2d'); + if (!context) return; + context.scale(window.devicePixelRatio, window.devicePixelRatio); + context.fillStyle = '#f8fafc'; + context.fillRect(0, 0, width, height); + + const allPoints = previews.flatMap(({ payload }) => { + const points: Array<[number, number]> = []; + for (let index = 0; index < payload.vertices.length; index += 3) { + points.push([payload.vertices[index], payload.vertices[index + 1]]); + } + return points; + }); + + if (!allPoints.length) return; + + const xs = allPoints.map((point) => point[0]); + const ys = allPoints.map((point) => point[1]); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const spanX = Math.max(maxX - minX, 1); + const spanY = Math.max(maxY - minY, 1); + const scale = Math.min((width * 0.78) / spanX, (height * 0.78) / spanY); + const offsetX = width / 2 - ((minX + maxX) / 2) * scale; + const offsetY = height / 2 + ((minY + maxY) / 2) * scale; + + previews.forEach(({ payload, style }) => { + context.globalAlpha = Math.max(0.12, Math.min(style.opacity, 1)); + context.fillStyle = style.color; + context.strokeStyle = style.color; + for (let index = 0; index < payload.vertices.length; index += 9) { + const x1 = payload.vertices[index] * scale + offsetX; + const y1 = -payload.vertices[index + 1] * scale + offsetY; + const x2 = payload.vertices[index + 3] * scale + offsetX; + const y2 = -payload.vertices[index + 4] * scale + offsetY; + const x3 = payload.vertices[index + 6] * scale + offsetX; + const y3 = -payload.vertices[index + 7] * scale + offsetY; + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.lineTo(x3, y3); + context.closePath(); + context.fill(); + context.stroke(); + } + }); + context.globalAlpha = 1; +} + +function drawDicomPreviewToCanvas(canvas: HTMLCanvasElement, preview: DicomPreview, rotation: number) { + const normalizedRotation = ((rotation % 360) + 360) % 360; + const sourceCanvas = document.createElement('canvas'); + sourceCanvas.width = preview.width; + sourceCanvas.height = preview.height; + const sourceContext = sourceCanvas.getContext('2d'); + const targetContext = canvas.getContext('2d'); + if (!sourceContext || !targetContext) { + return; + } + + const binary = atob(preview.pixels); + const imageData = sourceContext.createImageData(preview.width, preview.height); + for (let i = 0; i < binary.length; i += 1) { + const value = binary.charCodeAt(i); + const offset = i * 4; + imageData.data[offset] = value; + imageData.data[offset + 1] = value; + imageData.data[offset + 2] = value; + imageData.data[offset + 3] = 255; + } + sourceContext.putImageData(imageData, 0, 0); + + const isQuarterTurn = normalizedRotation === 90 || normalizedRotation === 270; + canvas.width = isQuarterTurn ? preview.height : preview.width; + canvas.height = isQuarterTurn ? preview.width : preview.height; + targetContext.clearRect(0, 0, canvas.width, canvas.height); + targetContext.save(); + targetContext.imageSmoothingEnabled = true; + + if (normalizedRotation === 90) { + targetContext.translate(canvas.width, 0); + targetContext.rotate(Math.PI / 2); + } else if (normalizedRotation === 180) { + targetContext.translate(canvas.width, canvas.height); + targetContext.rotate(Math.PI); + } else if (normalizedRotation === 270) { + targetContext.translate(0, canvas.height); + targetContext.rotate(-Math.PI / 2); + } + + targetContext.drawImage(sourceCanvas, 0, 0); + targetContext.restore(); +} + +function safeFilePart(value: string) { + return value.trim().replace(/[^\u4e00-\u9fa5a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'dicom'; +} + +function displayDicomValue(value: string | number | null | undefined) { + if (value === null || value === undefined || value === '') { + return '未知'; + } + return String(value); +} + +function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + drawDicomPreviewToCanvas(canvas, preview, rotation); + }, [preview, rotation]); + + return ( + + ); +} + +function OrientationGizmo({ pose }: { pose: ModelPose }) { + const axes = useMemo(() => { + const rotation = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler( + THREE.MathUtils.degToRad(pose.rotateX), + THREE.MathUtils.degToRad(pose.rotateY), + THREE.MathUtils.degToRad(pose.rotateZ), + 'XYZ', + )); + return [ + { id: 'X', color: '#ef4444', vector: new THREE.Vector3(1, 0, 0).applyMatrix4(rotation) }, + { id: 'Y', color: '#10b981', vector: new THREE.Vector3(0, 1, 0).applyMatrix4(rotation) }, + { id: 'Z', color: '#3b82f6', vector: new THREE.Vector3(0, 0, 1).applyMatrix4(rotation) }, + ] + .map((axis) => ({ + ...axis, + endX: 38 + axis.vector.x * 24 + axis.vector.z * 10, + endY: 38 - axis.vector.y * 24 + axis.vector.z * 8, + labelX: 38 + axis.vector.x * 30 + axis.vector.z * 12, + labelY: 38 - axis.vector.y * 30 + axis.vector.z * 10, + opacity: 0.55 + Math.max(-axis.vector.z, 0) * 0.45, + })) + .sort((a, b) => b.vector.z - a.vector.z); + }, [pose.rotateX, pose.rotateY, pose.rotateZ]); + + return ( +
+ + + {axes.map((axis) => ( + + + + + {axis.id} + + + ))} + +
+
X {Math.round(pose.rotateX)}°
+
Y {Math.round(pose.rotateY)}°
+
Z {Math.round(pose.rotateZ)}°
+
+
+ ); +} + +function NativeStlViewer({ + projectId, + files, + styles, + detailLimit, + solidMode, + pose, + onPoseChange, +}: { + projectId: string; + files: string[]; + styles: Record; + detailLimit: number; + solidMode: boolean; + pose: ModelPose; + onPoseChange: React.Dispatch>; +}) { + const containerRef = useRef(null); + const poseRef = useRef(pose); + const onPoseChangeRef = useRef(onPoseChange); + const [progress, setProgress] = useState(0); + const [status, setStatus] = useState('准备加载模型'); + + useEffect(() => { + poseRef.current = pose; + }, [pose]); + + useEffect(() => { + onPoseChangeRef.current = onPoseChange; + }, [onPoseChange]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const dragState = { + active: false, + mode: 'rotate' as 'rotate' | 'pan', + pointerId: 0, + startX: 0, + startY: 0, + startPose: poseRef.current, + }; + + const handlePointerDown = (event: PointerEvent) => { + dragState.active = true; + dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate'; + dragState.pointerId = event.pointerId; + dragState.startX = event.clientX; + dragState.startY = event.clientY; + dragState.startPose = poseRef.current; + container.setPointerCapture(event.pointerId); + }; + const handlePointerMove = (event: PointerEvent) => { + if (!dragState.active || event.pointerId !== dragState.pointerId) return; + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + if (dragState.mode === 'pan') { + onPoseChangeRef.current(clampModelPose({ + ...dragState.startPose, + translateX: dragState.startPose.translateX + deltaX * 0.006, + translateY: dragState.startPose.translateY - deltaY * 0.006, + })); + return; + } + onPoseChangeRef.current(clampModelPose({ + ...dragState.startPose, + rotateY: dragState.startPose.rotateY + deltaX * 0.35, + rotateX: dragState.startPose.rotateX + deltaY * 0.35, + })); + }; + const stopPointerDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) return; + dragState.active = false; + if (container.hasPointerCapture(event.pointerId)) { + container.releasePointerCapture(event.pointerId); + } + }; + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + onPoseChangeRef.current(clampModelPose({ + ...poseRef.current, + scale: poseRef.current.scale - event.deltaY * 0.001, + })); + }; + const preventContextMenu = (event: MouseEvent) => event.preventDefault(); + + container.addEventListener('pointerdown', handlePointerDown); + container.addEventListener('pointermove', handlePointerMove); + container.addEventListener('pointerup', stopPointerDrag); + container.addEventListener('pointercancel', stopPointerDrag); + container.addEventListener('wheel', handleWheel, { passive: false }); + container.addEventListener('contextmenu', preventContextMenu); + + return () => { + container.removeEventListener('pointerdown', handlePointerDown); + container.removeEventListener('pointermove', handlePointerMove); + container.removeEventListener('pointerup', stopPointerDrag); + container.removeEventListener('pointercancel', stopPointerDrag); + container.removeEventListener('wheel', handleWheel); + container.removeEventListener('contextmenu', preventContextMenu); + }; + }, []); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const visibleFiles = files.filter((file) => styles[file]?.visible !== false); + container.innerHTML = ''; + setProgress(visibleFiles.length ? 5 : 0); + setStatus(visibleFiles.length ? '正在加载 STL 模型...' : '没有可显示的模型'); + + if (!visibleFiles.length) { + return; + } + + let disposed = false; + let animationId = 0; + const scene = new THREE.Scene(); + scene.background = new THREE.Color('#f8fafc'); + const camera = new THREE.PerspectiveCamera(45, Math.max(container.clientWidth, 1) / Math.max(container.clientHeight, 1), 0.1, 1000); + camera.up.set(0, 1, 0); + camera.position.set(0, 0, 6); + camera.lookAt(0, 0, 0); + let renderer: THREE.WebGLRenderer | null = null; + try { + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + } catch { + const fallbackCanvas = document.createElement('canvas'); + fallbackCanvas.className = 'absolute inset-0 h-full w-full'; + container.appendChild(fallbackCanvas); + setStatus('WebGL 不可用,正在生成二维模型预览...'); + let fallbackPreviews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }> = []; + + Promise.allSettled( + visibleFiles.map((fileName) => + getCachedModelPreview(projectId, fileName, 3500) + .then((payload) => ({ + payload, + style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 }, + })), + ), + ).then((results) => { + if (disposed) return; + const previews = results + .filter((result): result is PromiseFulfilledResult<{ payload: ModelPreviewPayload; style: ModuleStyle }> => result.status === 'fulfilled') + .map((result) => result.value); + fallbackPreviews = previews; + drawFallbackModelPreview(fallbackCanvas, previews); + setProgress(100); + setStatus(previews.length ? '二维模型预览已生成' : '模型预览加载失败'); + }); + + const handleFallbackResize = () => { + if (fallbackPreviews.length) { + drawFallbackModelPreview(fallbackCanvas, fallbackPreviews); + } + }; + window.addEventListener('resize', handleFallbackResize); + + return () => { + disposed = true; + window.removeEventListener('resize', handleFallbackResize); + container.innerHTML = ''; + }; + } + + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(container.clientWidth, container.clientHeight); + container.appendChild(renderer.domElement); + + scene.add(new THREE.AmbientLight(0xffffff, 0.72)); + const keyLight = new THREE.DirectionalLight(0xffffff, 1.1); + keyLight.position.set(4, 5, 6); + scene.add(keyLight); + const fillLight = new THREE.DirectionalLight(0x9cc4ff, 0.55); + fillLight.position.set(-4, 2, -3); + scene.add(fillLight); + + const poseGroup = new THREE.Group(); + const pivotGroup = new THREE.Group(); + poseGroup.add(pivotGroup); + let baseScale = 1; + scene.add(poseGroup); + let loaded = 0; + let failed = 0; + const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = []; + + visibleFiles.forEach((fileName) => { + getCachedModelPreview(projectId, fileName, detailLimit) + .then((payload) => { + if (disposed) return; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3)); + geometry.computeVertexNormals(); + const style = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 }; + const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity; + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshStandardMaterial({ + color: style.color, + opacity: materialOpacity, + transparent: materialOpacity < 1, + roughness: solidMode ? 0.56 : 0.42, + metalness: 0.04, + side: THREE.DoubleSide, + }), + ); + pivotGroup.add(mesh); + if (payload.bounds) { + loadedBounds.push({ + min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z), + max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z), + }); + } else { + geometry.computeBoundingBox(); + const geometryBox = geometry.boundingBox; + if (geometryBox) { + loadedBounds.push({ + min: geometryBox.min.clone(), + max: geometryBox.max.clone(), + }); + } + } + loaded += 1; + setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100)); + setStatus(`已加载 ${loaded} / ${visibleFiles.length} 个 STL 预览`); + + if (loaded + failed === visibleFiles.length) { + const box = new THREE.Box3(); + if (loadedBounds.length) { + loadedBounds.forEach((bounds) => { + box.expandByPoint(bounds.min); + box.expandByPoint(bounds.max); + }); + } else { + box.setFromObject(pivotGroup); + } + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const maxSize = Math.max(size.x, size.y, size.z) || 1; + pivotGroup.traverse((object) => { + if (object instanceof THREE.Mesh) { + object.geometry.translate(-center.x, -center.y, -center.z); + object.geometry.computeBoundingBox(); + object.geometry.computeBoundingSphere(); + object.geometry.computeVertexNormals(); + } + }); + poseGroup.position.set(0, 0, 0); + pivotGroup.position.set(0, 0, 0); + baseScale = 4.2 / maxSize; + pivotGroup.scale.setScalar(baseScale * poseRef.current.scale); + camera.lookAt(0, 0, 0); + setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成'); + } + }) + .catch(() => { + if (disposed) return; + failed += 1; + setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100)); + setStatus(`有 ${failed} 个模型加载失败`); + }); + }); + + const handleResize = () => { + if (!container.clientWidth || !container.clientHeight) return; + camera.aspect = container.clientWidth / container.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(container.clientWidth, container.clientHeight); + }; + window.addEventListener('resize', handleResize); + + const animate = () => { + if (disposed) return; + const currentPose = poseRef.current; + poseGroup.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ); + pivotGroup.rotation.set( + THREE.MathUtils.degToRad(currentPose.rotateX), + THREE.MathUtils.degToRad(currentPose.rotateY), + THREE.MathUtils.degToRad(currentPose.rotateZ), + ); + pivotGroup.scale.setScalar(baseScale * currentPose.scale); + renderer.render(scene, camera); + animationId = window.requestAnimationFrame(animate); + }; + animate(); + + return () => { + disposed = true; + window.cancelAnimationFrame(animationId); + window.removeEventListener('resize', handleResize); + renderer.dispose(); + poseGroup.traverse((object) => { + if (object instanceof THREE.Mesh) { + object.geometry.dispose(); + const material = object.material; + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + } else { + material.dispose(); + } + } + }); + container.innerHTML = ''; + }; + }, [projectId, files.join('|'), JSON.stringify(styles), detailLimit, solidMode]); + + return ( +
+
+ {progress < 100 && ( +
+
+ {status} + {progress}% +
+
+
+
+
+ )} + {progress >= 100 && ( +
+ {status} +
+ )} + +
+ ); +} + +export default function ProjectLibrary({ + onReverse, + initialViewMode = 'dicom', +}: { + onReverse: (projId: string) => void; + initialViewMode?: 'dicom' | 'model' | 'mask'; +}) { + const [search, setSearch] = useState(''); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedProject, setSelectedProject] = useState(null); + const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>(initialViewMode); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [sliceIndex, setSliceIndex] = useState(0); + const [plane, setPlane] = useState('axial'); + const [displayMode, setDisplayMode] = useState('default'); + const [rotation, setRotation] = useState(0); + const [isSliceChanging, setIsSliceChanging] = useState(false); + const [solidityLevel, setSolidityLevel] = useState('standard'); + const [modelPose, setModelPose] = useState(defaultModelPose); + const [resultPose, setResultPose] = useState(defaultModelPose); + const [resultPreviewSlice, setResultPreviewSlice] = useState(0); + const [resultDisplayMode, setResultDisplayMode] = useState('soft'); + const [resultRotation, setResultRotation] = useState(0); + const [moduleStyles, setModuleStyles] = useState>({}); + const [dicomPreview, setDicomPreview] = useState(null); + const [resultFusionVolume, setResultFusionVolume] = useState(null); + const [resultFusionError, setResultFusionError] = useState(''); + const [resultOverlayStats, setResultOverlayStats] = useState(emptyOverlayStats); + const [resultVisibleModuleCount, setResultVisibleModuleCount] = useState(0); + const [dicomInfo, setDicomInfo] = useState(null); + const [dicomInfoError, setDicomInfoError] = useState(''); + const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false); + const [dicomError, setDicomError] = useState(''); + const [newProjectName, setNewProjectName] = useState(''); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [projectToDelete, setProjectToDelete] = useState(null); + const [editingProjectId, setEditingProjectId] = useState(''); + const [editingName, setEditingName] = useState(''); + const [actionMessage, setActionMessage] = useState(''); + const [showMaskExportMenu, setShowMaskExportMenu] = useState(false); + const [maskExportSelection, setMaskExportSelection] = useState>({ + dicom: false, + segmentation: true, + pose: true, + stl: false, + }); + const [maskSegmentationScope, setMaskSegmentationScope] = useState('visible'); + const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState('combined'); + const [maskExporting, setMaskExporting] = useState(false); + const [assetImporting, setAssetImporting] = useState(false); + const [assetImportProgress, setAssetImportProgress] = useState(null); + const importInputRef = useRef(null); + const importKindRef = useRef('dicom'); + const sliceRepeatRef = useRef(null); + const dicomRequestRef = useRef(0); + const preloadedProjectIdsRef = useRef(new Set()); + + const preloadProjectAssets = (project: Project) => { + if (preloadedProjectIdsRef.current.has(project.id)) { + return; + } + preloadedProjectIdsRef.current.add(project.id); + const maxSlice = Math.max((project.dicomCount || 1) - 1, 0); + if (project.dicomCount > 0) { + void getCachedDicomPreview(project.id, maxSlice, 'axial', 'default').catch(() => undefined); + void getCachedDicomFusionVolume(project.id, maxSlice, maxSlice, 'soft').catch(() => undefined); + } + (project.stlFiles ?? []).slice(0, 3).forEach((fileName) => { + void getCachedModelPreview(project.id, fileName, 3500).catch(() => undefined); + }); + }; + + const refreshProjects = () => { + setLoading(true); + return api.getProjects() + .then((items) => { + setProjects(items); + items.slice(0, 2).forEach(preloadProjectAssets); + setSelectedProject((current) => { + if (!current) { + return items[0] ?? null; + } + return items.find((item) => item.id === current.id) ?? items[0] ?? null; + }); + }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + refreshProjects(); + }, []); + + useEffect(() => { + if (selectedProject) { + preloadProjectAssets(selectedProject); + const latestResult = selectedProject.segmentationResults?.[selectedProject.segmentationResults.length - 1]; + setMaskSegmentationScope(latestResult?.segmentationScope ?? 'visible'); + } + }, [selectedProject?.id]); + + const filteredProjects = useMemo(() => { + const keyword = search.trim().toLowerCase(); + if (!keyword) { + return projects; + } + return projects.filter((project) => project.name.toLowerCase().includes(keyword)); + }, [projects, search]); + + const stlFiles = selectedProject?.stlFiles ?? []; + const planeOptions: Array<{ id: Plane; label: string }> = [ + { id: 'axial', label: '横断面' }, + { id: 'sagittal', label: '矢状面' }, + { id: 'coronal', label: '冠状面' }, + ]; + const displayModes: Array<{ id: DisplayMode; label: string }> = [ + { id: 'default', label: '默认' }, + { id: 'bone', label: '骨窗' }, + { id: 'soft', label: '软组织' }, + { id: 'contrast', label: '高对比' }, + ]; + const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false); + const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0; + const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0]; + const savedSegmentationResults = selectedProject?.segmentationResults ?? []; + const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1]; + const latestResultPose = latestSegmentationResult ? resultPose : modelPose; + const latestResultStyles = latestSegmentationResult?.moduleStyles ?? moduleStyles; + const resultMaxSlice = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0); + const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, resultPreviewSlice)); + const resultDisplayOption = reverseDisplayOptions.find((option) => option.id === 'fine') ?? reverseDisplayOptions[0]; + const resultDicomOpacity = reverseDicomOpacityOptions.find((option) => option.id === 'high') ?? reverseDicomOpacityOptions[reverseDicomOpacityOptions.length - 1]; + const resultCutStart = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.sliceStart ?? 0)); + const resultCutEnd = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.sliceEnd ?? resultMaxSlice)); + const makeDefaultModuleStyle = (index: number, fallback?: Partial): ModuleStyle => ({ + visible: fallback?.visible ?? true, + color: fallback?.color ?? defaultModuleColors[index % defaultModuleColors.length], + opacity: fallback?.opacity ?? 0.72, + partId: Math.max(1, Math.min(255, Math.round(fallback?.partId ?? index + 1))), + }); + + const commitModuleStyles = (next: Record) => { + setModuleStyles(next); + if (!selectedProject) { + return; + } + api.updateProjectModuleStyles(selectedProject.id, next) + .then((updated) => { + setSelectedProject(updated); + setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item))); + }) + .catch((error) => { + setActionMessage(error instanceof Error ? error.message : '构件样式保存失败'); + }); + }; + + const handleMaskBundleExport = async () => { + if (!selectedProject) { + return; + } + + const selectedTargets = exportOptions + .filter((option) => maskExportSelection[option.id]) + .map((option) => option.id); + if (!selectedTargets.length) { + setActionMessage('请至少选择一个导出内容'); + return; + } + + setMaskExporting(true); + setActionMessage(''); + try { + await downloadProjectExportBundle(selectedProject.id, selectedTargets, 'nii.gz', { + pose: latestSegmentationResult?.pose ?? modelPose, + segmentationScope: maskSegmentationScope, + segmentationExportMode: maskSegmentationExportMode, + }); + window.setTimeout(() => setMaskExporting(false), 900); + setShowMaskExportMenu(false); + } catch (error) { + setActionMessage(error instanceof Error ? error.message : '导出失败'); + setMaskExporting(false); + } + }; + + const triggerProjectAssetImport = () => { + if (!selectedProject || viewMode === 'mask' || assetImporting) { + return; + } + const kind: ProjectAssetImportKind = viewMode === 'model' ? 'stl' : 'dicom'; + const hasExistingAssets = kind === 'dicom' + ? (selectedProject.dicomCount ?? 0) > 0 + : (selectedProject.stlFiles?.length ?? selectedProject.modelCount ?? 0) > 0; + if (hasExistingAssets) { + const confirmed = window.confirm( + kind === 'dicom' + ? '当前项目已有 DICOM 影像。继续导入会覆盖项目库中的现有 DICOM 影像,并清空当前逆向分割结果,是否继续?' + : '当前项目已有 3D 模型。继续导入会覆盖项目库中的现有 STL 模型,并清空当前逆向分割结果,是否继续?', + ); + if (!confirmed) { + return; + } + } + const input = importInputRef.current; + if (!input) { + setActionMessage('导入控件尚未就绪,请稍后重试'); + return; + } + importKindRef.current = kind; + input.value = ''; + const archiveAccept = '.zip,.tar,.tar.gz,.tgz,.gz,application/zip,application/gzip,application/x-tar'; + input.accept = kind === 'dicom' + ? `.dcm,.dicom,application/dicom,${archiveAccept}` + : `.stl,model/stl,${archiveAccept}`; + input.multiple = true; + input.click(); + }; + + const handleProjectAssetImport = async (event: React.ChangeEvent) => { + if (!selectedProject) { + return; + } + const files = Array.from(event.target.files ?? []); + event.target.value = ''; + if (!files.length) { + return; + } + + const kind = importKindRef.current; + const totalBytes = files.reduce((sum, file) => sum + file.size, 0); + setAssetImporting(true); + setAssetImportProgress({ + kind, + fileCount: files.length, + totalBytes, + loadedBytes: 0, + percent: 0, + phase: 'uploading', + }); + setActionMessage(`正在导入 ${describeImportKind(kind)}...`); + try { + const updated = await api.importProjectAssets( + selectedProject.id, + kind, + files, + (progress: ProjectAssetImportProgress) => { + setAssetImportProgress({ + kind, + fileCount: files.length, + totalBytes: progress.total || totalBytes, + loadedBytes: progress.loaded, + percent: progress.percent, + phase: progress.percent >= 100 ? 'processing' : 'uploading', + }); + }, + ); + clearCachedProjectAssets(updated.id); + preloadedProjectIdsRef.current.delete(updated.id); + setSelectedProject(updated); + setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item))); + const latestResult = updated.segmentationResults?.[updated.segmentationResults.length - 1]; + const nextStyles: Record = {}; + (updated.stlFiles ?? []).forEach((fileName, index) => { + nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? updated.moduleStyles?.[fileName]); + }); + setModuleStyles(nextStyles); + setModelPose(latestResult?.pose ?? defaultModelPose); + setResultPose(latestResult?.pose ?? defaultModelPose); + setSliceIndex(0); + setDicomPreview(null); + setDicomError(''); + setResultFusionVolume(null); + setAssetImportProgress({ + kind, + fileCount: files.length, + totalBytes, + loadedBytes: totalBytes, + percent: 100, + phase: 'done', + }); + setActionMessage(kind === 'dicom' ? `已导入 ${updated.dicomCount} 张 DICOM 影像` : `已导入 ${updated.modelCount ?? 0} 个 STL 模型`); + window.setTimeout(() => setAssetImportProgress(null), 1800); + } catch (error) { + const message = error instanceof Error ? error.message : '项目资产导入失败'; + setAssetImportProgress((current) => ({ + kind, + fileCount: files.length, + totalBytes: current?.totalBytes ?? totalBytes, + loadedBytes: current?.loadedBytes ?? 0, + percent: current?.percent ?? 0, + phase: 'failed', + message, + })); + setActionMessage(message); + } finally { + setAssetImporting(false); + } + }; + + useEffect(() => { + setViewMode(initialViewMode); + }, [initialViewMode]); + + useEffect(() => { + const latestResult = selectedProject?.segmentationResults?.[selectedProject.segmentationResults.length - 1]; + const next: Record = {}; + stlFiles.forEach((fileName, index) => { + next[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]); + }); + setModuleStyles(next); + setSliceIndex(0); + setModelPose(latestResult?.pose ?? defaultModelPose); + setResultPose(latestResult?.pose ?? defaultModelPose); + setResultPreviewSlice(Math.max(0, Math.min(Math.max((selectedProject?.dicomCount ?? 1) - 1, 0), latestResult?.mappingSlice ?? 0))); + setResultDisplayMode('soft'); + setResultRotation(0); + }, [selectedProject?.id]); + + useEffect(() => { + if (!selectedProject || viewMode !== 'dicom' || !selectedProject.dicomCount) { + setDicomPreview(null); + setIsSliceChanging(false); + return; + } + + let cancelled = false; + const requestId = dicomRequestRef.current + 1; + dicomRequestRef.current = requestId; + setDicomError(''); + setIsSliceChanging(true); + getCachedDicomPreview(selectedProject.id, sliceIndex, plane, displayMode) + .then((preview) => { + if (!cancelled && requestId === dicomRequestRef.current) { + setDicomPreview(preview); + setIsSliceChanging(false); + } + }) + .catch((error) => { + if (!cancelled && requestId === dicomRequestRef.current) { + setDicomPreview(null); + setDicomError(error instanceof Error ? error.message : 'DICOM 预览失败'); + setIsSliceChanging(false); + } + }); + + return () => { + cancelled = true; + }; + }, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, displayMode, viewMode]); + + useEffect(() => { + if (!selectedProject || viewMode !== 'mask' || !latestSegmentationResult || !selectedProject.dicomCount) { + setResultFusionVolume(null); + setResultFusionError(''); + return; + } + + let cancelled = false; + const start = Math.min(resultCutStart, resultCutEnd); + const end = Math.max(resultCutStart, resultCutEnd); + setResultFusionError(''); + getCachedDicomFusionVolume(selectedProject.id, start, end, 'soft') + .then((volume) => { + if (!cancelled) { + setResultFusionVolume(volume); + } + }) + .catch((error) => { + if (!cancelled) { + setResultFusionVolume(null); + setResultFusionError(error instanceof Error ? error.message : 'DICOM 三维融合体载入失败'); + } + }); + + return () => { + cancelled = true; + }; + }, [selectedProject?.id, selectedProject?.dicomCount, viewMode, latestSegmentationResult?.id, resultCutStart, resultCutEnd]); + + useEffect(() => () => { + if (sliceRepeatRef.current !== null) { + window.clearInterval(sliceRepeatRef.current); + } + }, []); + + useEffect(() => { + const max = Math.max(sliceTotal - 1, 0); + if (sliceIndex > max) { + setSliceIndex(max); + } + }, [sliceIndex, sliceTotal]); + + const updateModuleStyle = (fileName: string, partial: Partial) => { + const index = Math.max(0, stlFiles.indexOf(fileName)); + const next = { + ...moduleStyles, + [fileName]: makeDefaultModuleStyle(index, { + ...(moduleStyles[fileName] ?? selectedProject?.moduleStyles?.[fileName]), + ...partial, + }), + }; + commitModuleStyles(next); + }; + + const updateModulePartId = (fileName: string, value: number) => { + const nextId = Math.max(1, Math.min(255, Math.round(Number.isFinite(value) ? value : 1))); + updateModuleStyle(fileName, { partId: nextId }); + }; + + const toggleAllModules = () => { + const nextVisible = !allModulesVisible; + const next = { ...moduleStyles }; + stlFiles.forEach((fileName, index) => { + next[fileName] = makeDefaultModuleStyle(index, { + ...(next[fileName] ?? selectedProject?.moduleStyles?.[fileName]), + visible: nextVisible, + }); + }); + commitModuleStyles(next); + }; + + const stepSlice = (delta: number) => { + setSliceIndex((current) => { + const max = Math.max((dicomPreview?.total ?? selectedProject?.dicomCount ?? 1) - 1, 0); + return Math.max(0, Math.min(max, current + delta)); + }); + }; + + const stopSliceStep = () => { + if (sliceRepeatRef.current !== null) { + window.clearInterval(sliceRepeatRef.current); + sliceRepeatRef.current = null; + } + }; + + const startSliceStep = (delta: number) => { + stopSliceStep(); + stepSlice(delta); + sliceRepeatRef.current = window.setInterval(() => stepSlice(delta), 95); + }; + + const updateModelPose = (partial: Partial) => { + setModelPose((current) => clampModelPose({ + ...current, + ...partial, + })); + }; + + const nudgeModelPose = (key: ModelPoseKey, delta: number) => { + setModelPose((current) => clampModelPose({ + ...current, + [key]: clampModelPoseValue(key, current[key] + delta), + })); + }; + + const resetModelRotationPose = () => { + setModelPose((current) => ({ + ...current, + rotateX: 0, + rotateY: 0, + rotateZ: 0, + })); + }; + + const resetModelTransformPose = () => { + setModelPose((current) => ({ + ...current, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, + })); + }; + + const rotateDicom = (delta: number) => { + setRotation((current) => ((current + delta) % 360 + 360) % 360); + }; + + const downloadCurrentDicomPng = () => { + if (!dicomPreview || !selectedProject) { + setActionMessage('当前没有可下载的 DICOM 图片'); + return; + } + + const canvas = document.createElement('canvas'); + drawDicomPreviewToCanvas(canvas, dicomPreview, rotation); + const link = document.createElement('a'); + const planeLabel = planeOptions.find((option) => option.id === plane)?.label ?? plane; + const modeLabel = displayModes.find((mode) => mode.id === displayMode)?.label ?? displayMode; + link.href = canvas.toDataURL('image/png'); + link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${dicomPreview.slice + 1}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`; + document.body.appendChild(link); + link.click(); + link.remove(); + setActionMessage('已生成当前 DICOM 图片 PNG'); + }; + + const openDicomInfo = async () => { + if (!selectedProject) return; + setIsDicomInfoOpen(true); + setDicomInfoError(''); + try { + setDicomInfo(await api.getDicomInfo(selectedProject.id)); + } catch (error) { + setDicomInfo(null); + setDicomInfoError(error instanceof Error ? error.message : 'DICOM 信息查询失败'); + } + }; + + const handleCreateProject = async () => { + const name = newProjectName.trim(); + if (!name) { + setActionMessage('请输入项目名称'); + return; + } + const created = await api.createProject(name); + setNewProjectName(''); + setIsCreateModalOpen(false); + setActionMessage(`已创建项目:${created.name}`); + await refreshProjects(); + setSelectedProject(created); + }; + + const handleRenameProject = async (projectId: string) => { + const name = editingName.trim(); + if (!name) { + setActionMessage('项目名称不能为空'); + return; + } + const updated = await api.renameProject(projectId, name); + setEditingProjectId(''); + setEditingName(''); + setActionMessage(`已更新项目名称:${updated.name}`); + await refreshProjects(); + setSelectedProject(updated); + }; + + const handleEditBlur = (project: Project) => { + if (editingProjectId !== project.id) { + return; + } + if (editingName.trim() && editingName.trim() !== project.name) { + handleRenameProject(project.id); + } else { + setEditingProjectId(''); + setEditingName(''); + } + }; + + const handleDeleteProject = async () => { + if (!projectToDelete) { + return; + } + await api.deleteProject(projectToDelete.id); + setActionMessage(`已删除项目:${projectToDelete.name}`); + setProjectToDelete(null); + await refreshProjects(); + }; + + const tabs = [ + { id: 'dicom' as const, label: 'DICOM 影像', icon: ImageIcon }, + { id: 'model' as const, label: '3D 模型', icon: Box }, + { id: 'mask' as const, label: '逆向分割结果', icon: Layers }, + ]; + const renderMaskExportMenu = (widthClass = 'w-80') => ( +
+
+

导出内容

+ +
+
+ {exportOptions.map((option) => ( + + ))} +
+ {maskExportSelection.segmentation && ( +
+
+

分割类别范围

+ 附带 labels.json +
+
+ {segmentationScopeOptions.map((option) => ( + + ))} +
+
+

分割导出方式

+
+ {segmentationExportModeOptions.map((option) => ( + + ))} +
+
+
+ )} + +
+ ); + const renderResultOverlaySummary = () => ( +
+
+
+

Overlay Label Map

+

+ {resultOverlayStats.activeModules}/{resultVisibleModuleCount} 构件 · {resultOverlayStats.segmentCount} 边 · {resultOverlayStats.filledPixels} px +

+
+ 当前切片 +
+ {resultOverlayStats.modules.length ? ( +
+ {resultOverlayStats.modules.map((item) => ( +
+ + {item.name} + ID {item.partId} + {item.segmentCount} 边 + {item.filledPixels} px +
+ ))} +
+ ) : ( +
+ 当前切片暂无可见构件 +
+ )} +
+ ); + + return ( +
+ {/* Project Sidebar - Collapsible */} +
+ + + {!isSidebarCollapsed && ( +
+
+

项目列表

+ +
+
+ + setSearch(e.target.value)} + /> +
+
+ {loading &&

正在从后端载入项目...

} + {filteredProjects.map((proj) => ( +
setSelectedProject(proj)} + className={`w-full p-3 rounded-xl transition-all text-left cursor-pointer ${ + selectedProject?.id === proj.id ? 'bg-blue-600 text-white shadow-md' : 'hover:bg-slate-50' + }`} + > +
+
+ {editingProjectId === proj.id ? ( + event.stopPropagation()} + onBlur={() => handleEditBlur(proj)} + onChange={(event) => setEditingName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') handleRenameProject(proj.id); + if (event.key === 'Escape') setEditingProjectId(''); + }} + className="w-full rounded-md px-2 py-1 text-xs text-slate-900 outline-none ring-1 ring-blue-200" + /> + ) : ( +

+ {proj.name} +

+ )} +
+ {editingProjectId !== proj.id && ( +
+ + +
+ )} +
+

+ {proj.createTime} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0} +

+
+ ))} +
+ {actionMessage &&

{actionMessage}

} +
+ )} + + {isSidebarCollapsed && ( +
+ {filteredProjects.map(p => ( +
setSelectedProject(p)} + className={`w-8 h-8 rounded-lg flex items-center justify-center cursor-pointer transition-all ${ + selectedProject?.id === p.id ? 'bg-blue-600 text-white shadow-md' : 'bg-slate-50 text-slate-400' + }`} + > + +
+ ))} +
+ )} +
+ + {/* Main Content Area */} +
+ {selectedProject ? ( + <> + +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {viewMode === 'mask' ? ( +
+ + {showMaskExportMenu && renderMaskExportMenu('w-80')} +
+ ) : ( + + )} +
+
+ + {assetImportProgress && ( +
+
+
+
+ {assetImportProgress.phase === 'failed' ? : } +
+
+

+ {assetImportProgress.phase === 'failed' + ? `${describeImportKind(assetImportProgress.kind)}导入失败` + : assetImportProgress.phase === 'done' + ? `${describeImportKind(assetImportProgress.kind)}导入完成` + : assetImportProgress.phase === 'processing' + ? '上传完成,服务器正在解压与解析' + : `正在上传${describeImportKind(assetImportProgress.kind)}`} +

+

+ {assetImportProgress.fileCount} 个文件 · {formatFileSize(assetImportProgress.loadedBytes)} / {formatFileSize(assetImportProgress.totalBytes)} +

+ {assetImportProgress.phase === 'failed' && assetImportProgress.message && ( +

+ {assetImportProgress.message} +

+ )} +
+
+ + {assetImportProgress.percent}% + +
+
+
+
+
+ )} + +
+ {viewMode === 'dicom' && ( +
+ {/* Left: DICOM Viewer */} +
+
+ {planeOptions.map((option) => ( + + ))} +
+
+ {displayModes.map((mode) => ( + + ))} +
+
+ + +
+
+

PATIENT ID: {selectedProject.id}_XYZ

+

SCAN DATE: {selectedProject.createTime}

+

DICOM PATH: {selectedProject.dicomPath}

+
+
+ {dicomPreview ? ( + + ) : ( +

+ {selectedProject.dicomCount ? dicomError || '正在解析 DICOM 像素...' : '请导入DICOM影像'} +

+ )} + {isSliceChanging && dicomPreview && ( + + 切片切换中 + + )} +
+
+ WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label} + 第 {selectedProject.dicomCount ? sliceIndex + 1 : 0} / {dicomPreview?.total ?? selectedProject.dicomCount} 张 +
+
+ {/* Right: Vertical Progress Bar */} +
+ 切片 + + {sliceIndex + 1} / {sliceTotal || selectedProject.dicomCount} + + + setSliceIndex(Number(e.target.value))} + className="flex-1 w-6 accent-blue-600 cursor-pointer" + style={{ writingMode: 'vertical-lr', direction: 'rtl' }} + /> + + #{sliceIndex + 1} +
+ + + +
+
+
+ )} + + {viewMode === 'model' && ( +
+ {/* Left: 3D Visualization */} +
+ + {!stlFiles.length && ( +
+

+ 请导入STL模型 +

+
+ )} +
+ MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label} +
+
+ {/* Right: Sub-module List */} +
+
+
+
+

模型显示

+ 左键旋转 · 右键/Shift 平移 · 滚轮缩放 +
+
+ {solidityOptions.map((option) => ( + + ))} +
+
+ +
+
+

模型位姿

+
+ + +
+
+ {[ + { key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX, minus: '-90°', plus: '+90°', delta: 90 }, + { key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY, minus: '-90°', plus: '+90°', delta: 90 }, + { key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ, minus: '-90°', plus: '+90°', delta: 90 }, + { key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX, minus: '-X', plus: '+X', delta: 0.25 }, + { key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY, minus: '-Y', plus: '+Y', delta: 0.25 }, + { key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ, minus: '-Z', plus: '+Z', delta: 0.25 }, + { key: 'scale' as const, label: '缩放', min: 0.5, max: 2.5, step: 0.005, value: modelPose.scale, minus: '-0.005', plus: '+0.005', delta: 0.005 }, + ].map((item) => ( +
+ {item.label} + + updateModelPose({ [item.key]: Number(event.target.value) } as Partial)} + className="w-full accent-blue-600" + /> + + {Number(item.value).toFixed(getControlStepPrecision(item.step))} +
+ ))} +
+
+ +
+

构件层级 ({stlFiles.length})

+ +
+
+ {stlFiles.map((fileName, i) => { + const name = fileName.replace(/\.stl$/i, ''); + const style = moduleStyles[fileName] ?? { visible: true, color: defaultModuleColors[i % defaultModuleColors.length], opacity: 0.72, partId: i + 1 }; + return ( +
+ updateModuleStyle(fileName, { color: event.target.value })} + className="w-8 h-8 rounded-lg border border-white bg-white p-0.5 cursor-pointer shrink-0" + title="模型颜色" + /> +
+

{name}

+
+

STL | {fileName}

+ +
+
+ 透明度 + updateModuleStyle(fileName, { opacity: Number(event.target.value) })} + className="min-w-0 flex-1 accent-blue-600" + /> + {Math.round(style.opacity * 100)}% +
+
+
+ +
+
+ )})} +
+
+
+ )} + + {viewMode === 'mask' && ( +
+
+ {latestSegmentationResult ? ( + + ) : ( +
+ 暂无保存结果,请在逆向工作区保存当前映射。 +
+ )} + {resultFusionError && ( +

+ {resultFusionError} +

+ )} +
+ +
+ {latestSegmentationResult ? ( + { + setResultOverlayStats(stats); + setResultVisibleModuleCount(visibleCount); + }} + toolbar={( + <> +
+ {displayModes.map((mode) => ( + + ))} +
+ + + + )} + /> + ) : ( +
+ 暂无逆向分割映射视图。 +
+ )} +
+ +
+
+
+
+

逆向分割结果

+

+ 项目库仅保留最新一次保存结果,导出时默认沿用该结果的模型位姿与构件样式。 +

+
+ + {latestSegmentationResult ? '已保存' : '未保存'} + +
+
+ 构件总数:{selectedProject.modelCount ?? stlFiles.length} + + 最后保存:{latestSegmentationResult ? new Date(latestSegmentationResult.createdAt).toLocaleString('zh-CN', { hour12: false }) : '等待结果'} + +
+
+

模型位姿

+
+ RX {formatPoseCompactValue(latestResultPose.rotateX, 1)}° + RY {formatPoseCompactValue(latestResultPose.rotateY, 1)}° + RZ {formatPoseCompactValue(latestResultPose.rotateZ, 1)}° + TX {formatPoseCompactValue(latestResultPose.translateX, 3)} + TY {formatPoseCompactValue(latestResultPose.translateY, 3)} + TZ {formatPoseCompactValue(latestResultPose.translateZ, 3)} + Scale {formatPoseCompactValue(latestResultPose.scale, 3)} +
+
+
+ + {latestSegmentationResult && renderResultOverlaySummary()} +
+
+ )} +
+ + ) : ( +
+
+ +
+

请从左侧选择一个项目开始阅览

+
+ )} +
+ + {isCreateModalOpen && ( +
+
+
+

创建项目

+ +
+ setNewProjectName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleCreateProject(); + } + }} + placeholder="请输入项目名称" + className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + +
+
+
+ )} + + {isDicomInfoOpen && ( +
+
+
+
+

DICOM 详细信息

+

包含基础元数据、像素间距、切片间距和物理尺寸

+
+ +
+
+ {dicomInfoError &&

{dicomInfoError}

} + {!dicomInfo && !dicomInfoError &&

正在解析 DICOM 信息...

} + {dicomInfo && ( +
+ {[ + { + title: '患者与检查', + rows: [ + ['患者姓名', dicomInfo.patient.name], + ['患者 ID', dicomInfo.patient.id], + ['检查日期', dicomInfo.study.date], + ['检查类型', dicomInfo.study.modality], + ['设备厂商', dicomInfo.study.manufacturer], + ], + }, + { + title: '序列与文件', + rows: [ + ['序列描述', dicomInfo.series.description], + ['文件数量', dicomInfo.series.files], + ['首文件', dicomInfo.series.firstFile], + ['末文件', dicomInfo.series.lastFile], + ['DICOM 路径', dicomInfo.project.dicomPath], + ], + }, + { + title: '图像矩阵与窗宽窗位', + rows: [ + ['Rows', dicomInfo.image.rows], + ['Columns', dicomInfo.image.columns], + ['Bits Allocated', dicomInfo.image.bitsAllocated], + ['Window Center', dicomInfo.image.windowCenter], + ['Window Width', dicomInfo.image.windowWidth], + ['Rescale', `${dicomInfo.image.rescaleSlope} / ${dicomInfo.image.rescaleIntercept}`], + ], + }, + { + title: '空间距离', + rows: [ + ['像素行间距', `${displayDicomValue(dicomInfo.spacing.row)} mm`], + ['像素列间距', `${displayDicomValue(dicomInfo.spacing.column)} mm`], + ['切片间距', `${displayDicomValue(dicomInfo.spacing.slice)} mm`], + ['间距来源', dicomInfo.spacing.sliceSource], + ['切片厚度', `${displayDicomValue(dicomInfo.spacing.sliceThickness)} mm`], + ['Spacing Between Slices', `${displayDicomValue(dicomInfo.spacing.spacingBetweenSlices)} mm`], + ], + }, + { + title: '物理尺寸', + rows: [ + ['宽度', `${displayDicomValue(dicomInfo.physicalSize.width)} ${dicomInfo.physicalSize.unit}`], + ['高度', `${displayDicomValue(dicomInfo.physicalSize.height)} ${dicomInfo.physicalSize.unit}`], + ['深度', `${displayDicomValue(dicomInfo.physicalSize.depth)} ${dicomInfo.physicalSize.unit}`], + ], + }, + { + title: '空间位置', + rows: [ + ['首张位置', dicomInfo.position.firstImagePosition?.join(', ') ?? '未知'], + ['末张位置', dicomInfo.position.lastImagePosition?.join(', ') ?? '未知'], + ], + }, + ].map((section) => ( +
+

{section.title}

+
+ {section.rows.map(([label, value]) => ( +
+ {label} + {displayDicomValue(value)} +
+ ))} +
+
+ ))} +
+ )} +
+
+
+ )} + + {projectToDelete && ( +
+
+

确认删除项目

+

+ 将删除项目“{projectToDelete.name}”。该操作会从项目列表移除项目,需要恢复默认演示项目时可使用出厂设置。 +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx new file mode 100644 index 0000000..14ca5d6 --- /dev/null +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -0,0 +1,3785 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Settings2, + Download, + RotateCcw, + RotateCw, + Rotate3d, + AlertCircle, + ChevronDown, + ChevronUp, + Eye, + Maximize2, + RefreshCcw, + Save, + Upload, +} from 'lucide-react'; +import * as THREE from 'three'; +import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types'; +import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportMode, SegmentationExportScope } from '../lib/api'; + +export interface ModelPreviewPayload { + fileName: string; + triangleCount: number; + sampledTriangles: number; + vertices: number[]; + bounds?: { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; + }; +} + +export type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; +export type DicomOpacityLevel = 'low' | 'medium' | 'high'; +export type MappingDisplayMode = DicomPreview['mode']; +type ModelPoseKey = keyof ModelPose; +type PoseDraftValues = Record; +type AxisKey = 'x' | 'y' | 'z'; + +interface AxisVector2D { + dx: number; + dy: number; + opacity: number; +} + +type AxisProjection = Record; +type WorkspaceLeaveGuard = () => Promise; + +interface WorkspaceLoadState { + ready: boolean; + phase: string; + loaded: number; + total: number; + startedAt: number; + error: string; +} + +const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale']; + +export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [ + { id: 'standard', label: '标准', limit: 16000 }, + { id: 'fine', label: '精细', limit: 36000 }, + { id: 'ultra', label: '超精细', limit: 72000 }, + { id: 'solid', label: '实体', limit: 200000 }, +]; +export const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOpacity: number; volumeOpacity: number; boxOpacity: number }> = [ + { id: 'low', label: '低', sliceOpacity: 0.82, volumeOpacity: 0.12, boxOpacity: 0.32 }, + { id: 'medium', label: '中', sliceOpacity: 0.92, volumeOpacity: 0.2, boxOpacity: 0.42 }, + { id: 'high', label: '高', sliceOpacity: 1, volumeOpacity: 0.32, boxOpacity: 0.54 }, +]; +const mappingDisplayModes: Array<{ id: MappingDisplayMode; label: string }> = [ + { id: 'default', label: '默认' }, + { id: 'bone', label: '骨窗' }, + { id: 'soft', label: '软组织' }, + { id: 'contrast', label: '高对比' }, +]; +const poseStepConfig: Record = { + rotateX: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 }, + rotateY: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 }, + rotateZ: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 }, + translateX: { min: -2, max: 2, step: 0.005, minus: '-X', plus: '+X' }, + translateY: { min: -2, max: 2, step: 0.005, minus: '-Y', plus: '+Y' }, + translateZ: { min: -2, max: 2, step: 0.005, minus: '-Z', plus: '+Z' }, + scale: { min: 0.5, max: 3, step: 0.005, minus: '-S', plus: '+S' }, +}; + +const defaultModelPose: ModelPose = { + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, +}; + +const defaultSavedPoses: SavedModelPose[] = [ + { id: 'default', name: '默认', pose: defaultModelPose }, + { id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } }, + { id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } }, +]; +const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [ + { id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' }, + { id: 'stl', label: 'STL 原始模型', description: '原始三维构件' }, + { id: 'pose', label: '位姿数据', description: 'JSON 侧车' }, + { id: 'segmentation', label: '分割影像', description: '同维度 Label Map' }, +]; +const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [ + { id: 'visible', label: '可见类别', description: '仅导出当前显示构件' }, + { id: 'all', label: '所有类别', description: '包含隐藏构件' }, +]; +const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [ + { id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' }, + { id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' }, +]; +const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; +const fusionBaseExtent = 4.6; +const axisInsetLength = 17; +const defaultAxisProjection: AxisProjection = { + x: { dx: axisInsetLength, dy: 0, opacity: 0.95 }, + y: { dx: -10, dy: 10, opacity: 0.82 }, + z: { dx: 0, dy: -axisInsetLength, opacity: 0.95 }, +}; +const dicomPreviewCache = new Map>(); +const dicomFusionVolumeCache = new Map>(); +const modelPreviewCache = new Map>(); + +function rememberRequest(cache: Map>, key: string, loader: () => Promise) { + const cached = cache.get(key); + if (cached) { + return cached; + } + + const request = loader().catch((error) => { + cache.delete(key); + throw error; + }); + cache.set(key, request); + return request; +} + +export function getCachedDicomPreview( + projectId: string, + slice: number, + plane: DicomPreview['plane'] = 'axial', + mode: DicomPreview['mode'] = 'default', +) { + return rememberRequest( + dicomPreviewCache, + `${projectId}:${plane}:${mode}:${slice}`, + () => api.getDicomPreview(projectId, slice, plane, mode), + ); +} + +export function getCachedDicomFusionVolume( + projectId: string, + start: number, + end: number, + mode: DicomPreview['mode'] = 'soft', +) { + const safeStart = Math.min(start, end); + const safeEnd = Math.max(start, end); + return rememberRequest( + dicomFusionVolumeCache, + `${projectId}:${mode}:${safeStart}:${safeEnd}`, + () => api.getDicomFusionVolume(projectId, safeStart, safeEnd, mode), + ); +} + +export function getCachedModelPreview(projectId: string, fileName: string, limit: number) { + const safeLimit = Math.max(1, Math.round(limit)); + return rememberRequest( + modelPreviewCache, + `${projectId}:${fileName}:${safeLimit}`, + () => fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${safeLimit}`) + .then((response) => { + if (!response.ok) { + throw new Error('模型预览数据加载失败'); + } + return response.json() as Promise; + }), + ); +} + +export function clearCachedProjectAssets(projectId: string) { + [dicomPreviewCache, dicomFusionVolumeCache, modelPreviewCache].forEach((cache) => { + [...cache.keys()].forEach((key) => { + if (key.startsWith(`${projectId}:`)) { + cache.delete(key); + } + }); + }); +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function getStepPrecision(step: number) { + if (step >= 1) { + return 0; + } + + const text = step.toString(); + if (text.includes('e-')) { + return Number(text.split('e-')[1] ?? 2); + } + + return text.split('.')[1]?.length ?? 0; +} + +function formatPoseValue(key: ModelPoseKey, value: number) { + return Number(value).toFixed(getStepPrecision(poseStepConfig[key].step)); +} + +function formatPoseDraftValues(pose: ModelPose): PoseDraftValues { + return modelPoseKeys.reduce((accumulator, key) => ({ + ...accumulator, + [key]: formatPoseValue(key, pose[key]), + }), {} as PoseDraftValues); +} + +function isNinetyDegreeMultiple(value: number) { + const normalized = ((value % 90) + 90) % 90; + return Math.min(normalized, 90 - normalized) < 1e-6; +} + +function isOrthogonalModelPose(pose: ModelPose) { + return isNinetyDegreeMultiple(pose.rotateX) + && isNinetyDegreeMultiple(pose.rotateY) + && isNinetyDegreeMultiple(pose.rotateZ); +} + +function getRotatedModelSize(bounds: { min: THREE.Vector3; max: THREE.Vector3 }, pose: ModelPose) { + const center = new THREE.Vector3().addVectors(bounds.min, bounds.max).multiplyScalar(0.5); + const rotationMatrix = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler( + THREE.MathUtils.degToRad(pose.rotateX), + THREE.MathUtils.degToRad(pose.rotateY), + THREE.MathUtils.degToRad(pose.rotateZ), + )); + const rotatedBox = new THREE.Box3(); + [bounds.min.x, bounds.max.x].forEach((x) => { + [bounds.min.y, bounds.max.y].forEach((y) => { + [bounds.min.z, bounds.max.z].forEach((z) => { + const point = new THREE.Vector3(x, y, z).sub(center).applyMatrix4(rotationMatrix); + rotatedBox.expandByPoint(point); + }); + }); + }); + return rotatedBox.getSize(new THREE.Vector3()); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizePoseValue(input: unknown, fallback: ModelPose = defaultModelPose): ModelPose | null { + if (!isRecord(input)) { + return null; + } + + let hasPoseValue = false; + const normalized = { ...fallback }; + modelPoseKeys.forEach((key) => { + const rawValue = input[key]; + const numericValue = typeof rawValue === 'number' ? rawValue : Number(rawValue); + if (!Number.isFinite(numericValue)) { + return; + } + + const limit = poseStepConfig[key]; + normalized[key] = clamp(numericValue, limit.min, limit.max); + hasPoseValue = true; + }); + + return hasPoseValue ? normalized : null; +} + +function normalizeImportedModelPoses(input: unknown): SavedModelPose[] | null { + if (!Array.isArray(input)) { + return null; + } + + const normalized = input + .map((item, index) => { + if (!isRecord(item)) { + return null; + } + + const pose = normalizePoseValue(item.pose); + if (!pose) { + return null; + } + + const rawId = typeof item.id === 'string' && item.id.trim() + ? item.id.trim() + : `imported-pose-${Date.now()}-${index}`; + const rawName = typeof item.name === 'string' && item.name.trim() + ? item.name.trim() + : `导入位姿${index + 1}`; + + return { + id: rawId.slice(0, 80), + name: rawName.slice(0, 80), + pose, + }; + }) + .filter((item): item is SavedModelPose => Boolean(item)); + + if (!normalized.length) { + return null; + } + + const deduped = new Map(); + normalized.forEach((item) => { + deduped.set(item.id, item); + }); + + return [...deduped.values()]; +} + +function mergeImportedModelPoses(imported: SavedModelPose[]) { + const importedById = new Map(imported.map((pose) => [pose.id, pose])); + const defaults = defaultSavedPoses.map((pose) => importedById.get(pose.id) ?? pose); + const custom = imported.filter((pose) => !defaultSavedPoses.some((item) => item.id === pose.id)); + + return [...defaults, ...custom]; +} + +function poseValuesMatch(left: ModelPose, right: ModelPose) { + return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6); +} + +function stableModuleStyles(styles: Record) { + return Object.keys(styles) + .sort((left, right) => left.localeCompare(right, 'zh-Hans-CN')) + .reduce>((accumulator, key) => { + accumulator[key] = styles[key]; + return accumulator; + }, {}); +} + +function createWorkspaceSnapshot(input: { + modelPose: ModelPose; + segmentationExportScope: SegmentationExportScope; + moduleStyles: Record; + sliceStart: number; + sliceEnd: number; + mappingSlice: number; + displayLevel: DisplayLevel; + dicomOpacityLevel: DicomOpacityLevel; + showBounds: boolean; + cutEnabled: boolean; +}) { + return JSON.stringify({ + modelPose: input.modelPose, + segmentationExportScope: input.segmentationExportScope, + moduleStyles: stableModuleStyles(input.moduleStyles), + sliceStart: input.sliceStart, + sliceEnd: input.sliceEnd, + mappingSlice: input.mappingSlice, + displayLevel: input.displayLevel, + dicomOpacityLevel: input.dicomOpacityLevel, + showBounds: input.showBounds, + cutEnabled: input.cutEnabled, + }); +} + +function parseImportedPosePayload(payload: unknown) { + const record = isRecord(payload) ? payload : {}; + const importedModelPoses = normalizeImportedModelPoses(record.modelPoses); + const activePose = normalizePoseValue(record.activePose) + ?? normalizePoseValue(record.pose) + ?? normalizePoseValue(payload) + ?? importedModelPoses?.[0]?.pose + ?? null; + + return { activePose, importedModelPoses }; +} + +function projectModelAxisDirections(camera: THREE.Camera, object: THREE.Object3D): AxisProjection { + const origin = object.getWorldPosition(new THREE.Vector3()); + const originProjected = origin.clone().project(camera); + const quaternion = object.getWorldQuaternion(new THREE.Quaternion()); + const axisDirections: Record = { + x: new THREE.Vector3(1, 0, 0), + y: new THREE.Vector3(0, 1, 0), + z: new THREE.Vector3(0, 0, 1), + }; + + const projectAxis = (direction: THREE.Vector3): AxisVector2D => { + const end = origin.clone().add(direction.applyQuaternion(quaternion).normalize().multiplyScalar(0.72)); + const endProjected = end.project(camera); + const dx = endProjected.x - originProjected.x; + const dy = originProjected.y - endProjected.y; + const magnitude = Math.hypot(dx, dy); + + if (magnitude < 0.0001) { + return { dx: 0, dy: -5, opacity: 0.5 }; + } + + return { + dx: (dx / magnitude) * axisInsetLength, + dy: (dy / magnitude) * axisInsetLength, + opacity: endProjected.z < originProjected.z ? 1 : 0.58, + }; + }; + + return { + x: projectAxis(axisDirections.x), + y: projectAxis(axisDirections.y), + z: projectAxis(axisDirections.z), + }; +} + +function axisProjectionSignature(projection: AxisProjection) { + return (['x', 'y', 'z'] as AxisKey[]) + .map((key) => { + const item = projection[key]; + return `${Math.round(item.dx * 10)},${Math.round(item.dy * 10)},${Math.round(item.opacity * 100)}`; + }) + .join('|'); +} + +function createDicomTexture(frame: string, width: number, height: number) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + + const binary = atob(frame); + const imageData = context.createImageData(width, height); + for (let index = 0; index < binary.length; index += 1) { + const value = binary.charCodeAt(index); + const offset = index * 4; + imageData.data[offset] = value; + imageData.data[offset + 1] = value; + imageData.data[offset + 2] = value; + imageData.data[offset + 3] = value > 4 ? 235 : 0; + } + context.putImageData(imageData, 0, 0); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.needsUpdate = true; + return texture; +} + +function CoordinateAxesInset({ projection }: { projection: AxisProjection }) { + const origin = { x: 25, y: 31 }; + const axisItems: Array<{ key: AxisKey; label: string; color: string; labelColor: string; markerId: string }> = [ + { key: 'x', label: 'X', color: '#ef4444', labelColor: '#fecaca', markerId: 'fusion-axis-arrow-x' }, + { key: 'y', label: 'Y', color: '#22c55e', labelColor: '#bbf7d0', markerId: 'fusion-axis-arrow-y' }, + { key: 'z', label: 'Z', color: '#38bdf8', labelColor: '#bae6fd', markerId: 'fusion-axis-arrow-z' }, + ]; + + return ( +
+ +
+ ); +} + +export function FusionThreeView({ + project, + volume, + modelPose, + moduleStyles, + detailLimit, + solidMode, + dicomOpacity, + showBounds, + cutEnabled, + cutStart, + cutEnd, + viewPreset = 'workspace', +}: { + project: Project; + volume: DicomFusionVolume | null; + modelPose: ModelPose; + moduleStyles: Record; + detailLimit: number; + solidMode: boolean; + dicomOpacity: { sliceOpacity: number; volumeOpacity: number; boxOpacity: number }; + showBounds: boolean; + cutEnabled: boolean; + cutStart: number; + cutEnd: number; + viewPreset?: 'workspace' | 'libraryResult'; +}) { + const containerRef = useRef(null); + const modelPoseRef = useRef(modelPose); + const [status, setStatus] = useState('准备融合 DICOM 与 STL'); + const [loadProgress, setLoadProgress] = useState(0); + const [webglError, setWebglError] = useState(null); + const [axisProjection, setAxisProjection] = useState(defaultAxisProjection); + const axisProjectionSignatureRef = useRef(axisProjectionSignature(defaultAxisProjection)); + const resetFusionViewRef = useRef<() => void>(() => undefined); + + useEffect(() => { + modelPoseRef.current = modelPose; + }, [modelPose]); + + useEffect(() => { + const container = containerRef.current; + if (!container || !volume) return; + + container.innerHTML = ''; + setWebglError(null); + setStatus('正在构建三维融合场景...'); + setLoadProgress(8); + setAxisProjection(defaultAxisProjection); + axisProjectionSignatureRef.current = axisProjectionSignature(defaultAxisProjection); + + let disposed = false; + let animationId = 0; + const scene = new THREE.Scene(); + scene.background = new THREE.Color('#030712'); + + const width = Math.max(container.clientWidth, 1); + const height = Math.max(container.clientHeight, 1); + const camera = new THREE.PerspectiveCamera(45, width / height, 0.05, 1000); + camera.position.set(0, -6.2, 4.6); + camera.up.set(0, 0, 1); + camera.lookAt(0, 0, 0); + + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + } catch { + const message = '当前浏览器无法创建 WebGL 三维上下文'; + setStatus(message); + setLoadProgress(100); + setWebglError('三维融合视图暂不可用,请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与逆向分割映射功能仍可继续使用。'); + resetFusionViewRef.current = () => undefined; + return () => { + container.innerHTML = ''; + }; + } + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(width, height); + renderer.localClippingEnabled = true; + container.appendChild(renderer.domElement); + + scene.add(new THREE.AmbientLight(0xffffff, 0.72)); + const keyLight = new THREE.DirectionalLight(0xffffff, 1.1); + keyLight.position.set(4, -5, 5); + scene.add(keyLight); + const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55); + fillLight.position.set(-4, 3, 2); + scene.add(fillLight); + + const fusionRoot = new THREE.Group(); + const dicomGroup = new THREE.Group(); + const modelPoseGroup = new THREE.Group(); + const modelPivot = new THREE.Group(); + modelPoseGroup.add(modelPivot); + fusionRoot.add(dicomGroup); + fusionRoot.add(modelPoseGroup); + scene.add(fusionRoot); + + const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1); + const baseExtent = 4.6; + const dicomWidth = (volume.physicalSize.width / maxPhysical) * baseExtent; + const dicomHeight = (volume.physicalSize.height / maxPhysical) * baseExtent; + const dicomDepth = Math.max((volume.physicalSize.depth / maxPhysical) * baseExtent, 0.18); + const planeGeometry = new THREE.PlaneGeometry(dicomWidth, dicomHeight); + + const box = new THREE.Mesh( + new THREE.BoxGeometry(dicomWidth, dicomHeight, dicomDepth), + new THREE.MeshBasicMaterial({ color: '#020617', transparent: true, opacity: dicomOpacity.boxOpacity, depthWrite: false }), + ); + dicomGroup.add(box); + const edges = new THREE.LineSegments( + new THREE.EdgesGeometry(box.geometry), + new THREE.LineBasicMaterial({ color: '#38bdf8', transparent: true, opacity: 0.46 }), + ); + edges.visible = showBounds; + dicomGroup.add(edges); + + const sliceToZ = (sliceIndex: number) => ( + volume.total <= 1 + ? 0 + : -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1) + ); + const cutRangeStart = Math.min( + clamp(cutStart, 0, volume.total - 1), + clamp(cutEnd, 0, volume.total - 1), + ); + const cutRangeEnd = Math.max( + clamp(cutStart, 0, volume.total - 1), + clamp(cutEnd, 0, volume.total - 1), + ); + const lowerCutZ = sliceToZ(cutRangeStart); + const upperCutZ = sliceToZ(cutRangeEnd); + const lowerClippingPlane = new THREE.Plane(); + const upperClippingPlane = new THREE.Plane(); + + const textures: THREE.Texture[] = []; + volume.frames.forEach((frame, index) => { + const texture = createDicomTexture(frame, volume.width, volume.height); + if (!texture) return; + textures.push(texture); + const isLast = index === volume.frames.length - 1; + const dicomIndex = volume.indices[index] ?? (volume.start + index); + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + opacity: isLast ? dicomOpacity.sliceOpacity : dicomOpacity.volumeOpacity, + side: THREE.DoubleSide, + depthWrite: false, + }); + const slicePlane = new THREE.Mesh(planeGeometry, material); + const z = volume.total <= 1 + ? 0 + : -dicomDepth / 2 + (dicomDepth * dicomIndex) / (volume.total - 1); + slicePlane.position.set(0, 0, z + (isLast ? 0.006 : 0)); + dicomGroup.add(slicePlane); + }); + + setLoadProgress(42); + + const stlFiles = project.stlFiles ?? []; + const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false); + let modelBaseScale = 1; + let loadedModels = 0; + let failedModels = 0; + const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = []; + + Promise.allSettled(stlFiles.map((fileName, index) => ( + getCachedModelPreview(project.id, fileName, detailLimit) + .then((payload) => { + if (disposed) return; + const style = moduleStyles[fileName] ?? { + visible: true, + color: moduleColors[index % moduleColors.length], + opacity: 0.72, + partId: index + 1, + }; + if (payload.bounds) { + loadedBounds.push({ + min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z), + max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z), + }); + } + if (style.visible !== false) { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3)); + geometry.computeVertexNormals(); + const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity; + const material = new THREE.MeshStandardMaterial({ + color: style.color, + transparent: true, + opacity: materialOpacity, + roughness: solidMode ? 0.56 : 0.48, + metalness: 0.03, + side: THREE.DoubleSide, + clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [], + clipIntersection: false, + clipShadows: true, + }); + const mesh = new THREE.Mesh(geometry, material); + modelPivot.add(mesh); + } + loadedModels += 1; + setLoadProgress(42 + Math.round(((loadedModels + failedModels) / Math.max(stlFiles.length, 1)) * 46)); + }) + ))).then(() => { + if (disposed) return; + const modelBox = new THREE.Box3(); + if (loadedBounds.length) { + loadedBounds.forEach((bounds) => { + modelBox.expandByPoint(bounds.min); + modelBox.expandByPoint(bounds.max); + }); + } else { + modelBox.setFromObject(modelPivot); + } + + const center = modelBox.getCenter(new THREE.Vector3()); + const size = modelBox.getSize(new THREE.Vector3()); + const maxModelSize = Math.max(size.x, size.y, size.z, 1); + modelPivot.traverse((object) => { + if (object instanceof THREE.Mesh) { + object.geometry.translate(-center.x, -center.y, -center.z); + object.geometry.computeBoundingBox(); + object.geometry.computeBoundingSphere(); + object.geometry.computeVertexNormals(); + } + }); + const modelBounds = new THREE.LineSegments( + new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)), + new THREE.LineBasicMaterial({ color: '#facc15', transparent: true, opacity: 0.72 }), + ); + modelBounds.visible = showBounds; + modelPivot.add(modelBounds); + modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92; + modelPoseGroup.position.set(0, 0, 0); + modelPivot.position.set(0, 0, 0); + setLoadProgress(100); + setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件'); + }); + + const defaultRootPose = viewPreset === 'libraryResult' ? { + rotateX: THREE.MathUtils.degToRad(70), + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0.02, + scale: 0.94, + } : { + rotateX: THREE.MathUtils.degToRad(58), + rotateY: 0, + rotateZ: THREE.MathUtils.degToRad(-18), + translateX: 0, + translateY: 0, + scale: 1, + }; + const rootPose = { ...defaultRootPose }; + const dragState = { + active: false, + mode: 'rotate' as 'rotate' | 'pan', + pointerId: 0, + startX: 0, + startY: 0, + root: { ...rootPose }, + }; + resetFusionViewRef.current = () => { + Object.assign(rootPose, defaultRootPose); + setStatus('三维融合视角已复位'); + }; + + const handlePointerDown = (event: PointerEvent) => { + dragState.active = true; + dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate'; + dragState.pointerId = event.pointerId; + dragState.startX = event.clientX; + dragState.startY = event.clientY; + dragState.root = { ...rootPose }; + container.setPointerCapture(event.pointerId); + }; + const handlePointerMove = (event: PointerEvent) => { + if (!dragState.active || event.pointerId !== dragState.pointerId) return; + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + if (dragState.mode === 'pan') { + rootPose.translateX = dragState.root.translateX + deltaX * 0.006; + rootPose.translateY = dragState.root.translateY - deltaY * 0.006; + return; + } + rootPose.rotateZ = dragState.root.rotateZ + deltaX * 0.008; + rootPose.rotateX = dragState.root.rotateX + deltaY * 0.008; + }; + const stopPointerDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) return; + dragState.active = false; + if (container.hasPointerCapture(event.pointerId)) { + container.releasePointerCapture(event.pointerId); + } + }; + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + rootPose.scale = clamp(rootPose.scale - event.deltaY * 0.001, 0.45, 2.2); + }; + const preventContextMenu = (event: MouseEvent) => event.preventDefault(); + + container.addEventListener('pointerdown', handlePointerDown); + container.addEventListener('pointermove', handlePointerMove); + container.addEventListener('pointerup', stopPointerDrag); + container.addEventListener('pointercancel', stopPointerDrag); + container.addEventListener('wheel', handleWheel, { passive: false }); + container.addEventListener('contextmenu', preventContextMenu); + + const handleResize = () => { + if (!container.clientWidth || !container.clientHeight) return; + camera.aspect = container.clientWidth / container.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(container.clientWidth, container.clientHeight); + }; + window.addEventListener('resize', handleResize); + + const animate = () => { + if (disposed) return; + fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ); + fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0); + fusionRoot.scale.setScalar(rootPose.scale); + fusionRoot.updateMatrixWorld(true); + if (cutEnabled) { + const rootQuaternion = fusionRoot.getWorldQuaternion(new THREE.Quaternion()); + const lowerNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(rootQuaternion).normalize(); + const upperNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(rootQuaternion).normalize(); + const lowerCutPoint = new THREE.Vector3(0, 0, lowerCutZ).applyMatrix4(fusionRoot.matrixWorld); + const upperCutPoint = new THREE.Vector3(0, 0, upperCutZ).applyMatrix4(fusionRoot.matrixWorld); + lowerClippingPlane.setFromNormalAndCoplanarPoint(lowerNormal, lowerCutPoint); + upperClippingPlane.setFromNormalAndCoplanarPoint(upperNormal, upperCutPoint); + } + + const pose = modelPoseRef.current; + modelPoseGroup.rotation.set( + THREE.MathUtils.degToRad(pose.rotateX), + THREE.MathUtils.degToRad(pose.rotateY), + THREE.MathUtils.degToRad(pose.rotateZ), + ); + modelPoseGroup.position.set( + pose.translateX, + pose.translateY, + pose.translateZ, + ); + modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale); + modelPoseGroup.updateMatrixWorld(true); + const nextAxisProjection = projectModelAxisDirections(camera, modelPoseGroup); + const nextAxisSignature = axisProjectionSignature(nextAxisProjection); + if (axisProjectionSignatureRef.current !== nextAxisSignature) { + axisProjectionSignatureRef.current = nextAxisSignature; + setAxisProjection(nextAxisProjection); + } + renderer.render(scene, camera); + animationId = window.requestAnimationFrame(animate); + }; + animate(); + + return () => { + disposed = true; + window.cancelAnimationFrame(animationId); + window.removeEventListener('resize', handleResize); + container.removeEventListener('pointerdown', handlePointerDown); + container.removeEventListener('pointermove', handlePointerMove); + container.removeEventListener('pointerup', stopPointerDrag); + container.removeEventListener('pointercancel', stopPointerDrag); + container.removeEventListener('wheel', handleWheel); + container.removeEventListener('contextmenu', preventContextMenu); + textures.forEach((texture) => texture.dispose()); + scene.traverse((object) => { + if (object instanceof THREE.Mesh) { + object.geometry.dispose(); + const material = object.material; + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + } else { + material.dispose(); + } + } + }); + renderer.dispose(); + resetFusionViewRef.current = () => undefined; + container.innerHTML = ''; + }; + }, [ + project.id, + project.stlFiles?.join('|'), + volume, + JSON.stringify(moduleStyles), + detailLimit, + solidMode, + dicomOpacity.sliceOpacity, + dicomOpacity.volumeOpacity, + dicomOpacity.boxOpacity, + showBounds, + cutEnabled, + cutStart, + cutEnd, + viewPreset, + ]); + + return ( +
+
+ {webglError && ( +
+
+ +

三维融合视图无法启动

+

{webglError}

+
+
+ )} +
+ {status} +
+
+ DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0} +
+ + + {loadProgress < 100 && ( +
+
+ 正在融合三维影像与模型 + {loadProgress}% +
+
+
+
+
+ )} + {!volume && ( +
+ 正在载入 DICOM 三维体... +
+ )} +
+ ); +} + +function CutSectionPreview({ + project, + volume, + modelPose, + moduleStyles, + detailLimit, + cutEnabled, + cutStart, + cutEnd, +}: { + project: Project | null; + volume: DicomFusionVolume | null; + modelPose: ModelPose; + moduleStyles: Record; + detailLimit: number; + cutEnabled: boolean; + cutStart: number; + cutEnd: number; +}) { + const containerRef = useRef(null); + const modelPoseRef = useRef(modelPose); + const [webglError, setWebglError] = useState(null); + + useEffect(() => { + modelPoseRef.current = modelPose; + }, [modelPose]); + + useEffect(() => { + const container = containerRef.current; + if (!container || !project || !volume) return; + + container.innerHTML = ''; + setWebglError(null); + let disposed = false; + let animationId = 0; + const scene = new THREE.Scene(); + scene.background = new THREE.Color('#020617'); + + const width = Math.max(container.clientWidth, 1); + const height = Math.max(container.clientHeight, 1); + const camera = new THREE.PerspectiveCamera(42, width / height, 0.05, 1000); + camera.position.set(0, -5.6, 3.4); + camera.up.set(0, 0, 1); + camera.lookAt(0, 0, 0); + + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + } catch { + setWebglError('当前浏览器无法创建 WebGL 三维上下文,STL 切面三维预览暂不可用。'); + return () => { + container.innerHTML = ''; + }; + } + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(width, height); + renderer.localClippingEnabled = true; + container.appendChild(renderer.domElement); + + scene.add(new THREE.AmbientLight(0xffffff, 0.78)); + const keyLight = new THREE.DirectionalLight(0xffffff, 1.25); + keyLight.position.set(3, -4, 5); + scene.add(keyLight); + const rimLight = new THREE.DirectionalLight(0x93c5fd, 0.72); + rimLight.position.set(-4, 3, 2); + scene.add(rimLight); + + const fusionRoot = new THREE.Group(); + const modelPoseGroup = new THREE.Group(); + const modelPivot = new THREE.Group(); + modelPoseGroup.add(modelPivot); + fusionRoot.add(modelPoseGroup); + scene.add(fusionRoot); + + const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1); + const baseExtent = 4.4; + const dicomWidth = (volume.physicalSize.width / maxPhysical) * baseExtent; + const dicomHeight = (volume.physicalSize.height / maxPhysical) * baseExtent; + const dicomDepth = Math.max((volume.physicalSize.depth / maxPhysical) * baseExtent, 0.18); + const sliceToZ = (sliceIndex: number) => ( + volume.total <= 1 + ? 0 + : -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1) + ); + const cutRangeStart = Math.min( + clamp(cutStart, 0, volume.total - 1), + clamp(cutEnd, 0, volume.total - 1), + ); + const cutRangeEnd = Math.max( + clamp(cutStart, 0, volume.total - 1), + clamp(cutEnd, 0, volume.total - 1), + ); + const lowerCutZ = sliceToZ(cutRangeStart); + const upperCutZ = sliceToZ(cutRangeEnd); + const lowerClippingPlane = new THREE.Plane(); + const upperClippingPlane = new THREE.Plane(); + + let modelBaseScale = 1; + let loadedModels = 0; + let failedModels = 0; + const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = []; + const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false); + + Promise.allSettled(stlFiles.map((fileName, index) => ( + getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 200000)) + .then((payload) => { + if (disposed) return; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3)); + geometry.computeVertexNormals(); + const style = moduleStyles[fileName] ?? { + visible: true, + color: moduleColors[index % moduleColors.length], + opacity: 1, + partId: index + 1, + }; + const material = new THREE.MeshStandardMaterial({ + color: style.color, + roughness: 0.5, + metalness: 0.04, + side: THREE.DoubleSide, + clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [], + clipIntersection: false, + clipShadows: true, + }); + modelPivot.add(new THREE.Mesh(geometry, material)); + if (payload.bounds) { + loadedBounds.push({ + min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z), + max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z), + }); + } + loadedModels += 1; + }) + .catch(() => { + failedModels += 1; + }) + ))).then(() => { + if (disposed || (loadedModels + failedModels === 0)) return; + const modelBox = new THREE.Box3(); + if (loadedBounds.length) { + loadedBounds.forEach((bounds) => { + modelBox.expandByPoint(bounds.min); + modelBox.expandByPoint(bounds.max); + }); + } else { + modelBox.setFromObject(modelPivot); + } + const center = modelBox.getCenter(new THREE.Vector3()); + const size = modelBox.getSize(new THREE.Vector3()); + const maxModelSize = Math.max(size.x, size.y, size.z, 1); + modelPivot.traverse((object) => { + if (object instanceof THREE.Mesh) { + object.geometry.translate(-center.x, -center.y, -center.z); + object.geometry.computeBoundingBox(); + object.geometry.computeBoundingSphere(); + object.geometry.computeVertexNormals(); + } + }); + modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.98; + modelPoseGroup.position.set(0, 0, 0); + modelPivot.position.set(0, 0, 0); + }); + + const rootPose = { + rotateX: THREE.MathUtils.degToRad(58), + rotateY: 0, + rotateZ: THREE.MathUtils.degToRad(-18), + translateX: 0, + translateY: 0, + scale: 1, + }; + const dragState = { + active: false, + mode: 'rotate' as 'rotate' | 'pan', + pointerId: 0, + startX: 0, + startY: 0, + root: { ...rootPose }, + }; + + const handlePointerDown = (event: PointerEvent) => { + dragState.active = true; + dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate'; + dragState.pointerId = event.pointerId; + dragState.startX = event.clientX; + dragState.startY = event.clientY; + dragState.root = { ...rootPose }; + container.setPointerCapture(event.pointerId); + }; + const handlePointerMove = (event: PointerEvent) => { + if (!dragState.active || event.pointerId !== dragState.pointerId) return; + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + if (dragState.mode === 'pan') { + rootPose.translateX = dragState.root.translateX + deltaX * 0.006; + rootPose.translateY = dragState.root.translateY - deltaY * 0.006; + return; + } + rootPose.rotateZ = dragState.root.rotateZ + deltaX * 0.008; + rootPose.rotateX = dragState.root.rotateX + deltaY * 0.008; + }; + const stopPointerDrag = (event: PointerEvent) => { + if (event.pointerId !== dragState.pointerId) return; + dragState.active = false; + if (container.hasPointerCapture(event.pointerId)) { + container.releasePointerCapture(event.pointerId); + } + }; + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + rootPose.scale = clamp(rootPose.scale - event.deltaY * 0.001, 0.55, 2.4); + }; + const preventContextMenu = (event: MouseEvent) => event.preventDefault(); + const handleResize = () => { + if (!container.clientWidth || !container.clientHeight) return; + camera.aspect = container.clientWidth / container.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(container.clientWidth, container.clientHeight); + }; + + container.addEventListener('pointerdown', handlePointerDown); + container.addEventListener('pointermove', handlePointerMove); + container.addEventListener('pointerup', stopPointerDrag); + container.addEventListener('pointercancel', stopPointerDrag); + container.addEventListener('wheel', handleWheel, { passive: false }); + container.addEventListener('contextmenu', preventContextMenu); + window.addEventListener('resize', handleResize); + + const animate = () => { + if (disposed) return; + fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ); + fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0); + fusionRoot.scale.setScalar(rootPose.scale); + if (cutEnabled) { + fusionRoot.updateMatrixWorld(true); + const rootQuaternion = fusionRoot.getWorldQuaternion(new THREE.Quaternion()); + const lowerNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(rootQuaternion).normalize(); + const upperNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(rootQuaternion).normalize(); + const lowerCutPoint = new THREE.Vector3(0, 0, lowerCutZ).applyMatrix4(fusionRoot.matrixWorld); + const upperCutPoint = new THREE.Vector3(0, 0, upperCutZ).applyMatrix4(fusionRoot.matrixWorld); + lowerClippingPlane.setFromNormalAndCoplanarPoint(lowerNormal, lowerCutPoint); + upperClippingPlane.setFromNormalAndCoplanarPoint(upperNormal, upperCutPoint); + } + + const pose = modelPoseRef.current; + modelPoseGroup.rotation.set( + THREE.MathUtils.degToRad(pose.rotateX), + THREE.MathUtils.degToRad(pose.rotateY), + THREE.MathUtils.degToRad(pose.rotateZ), + ); + modelPoseGroup.position.set(pose.translateX, pose.translateY, pose.translateZ); + modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale); + renderer.render(scene, camera); + animationId = window.requestAnimationFrame(animate); + }; + animate(); + + return () => { + disposed = true; + window.cancelAnimationFrame(animationId); + window.removeEventListener('resize', handleResize); + container.removeEventListener('pointerdown', handlePointerDown); + container.removeEventListener('pointermove', handlePointerMove); + container.removeEventListener('pointerup', stopPointerDrag); + container.removeEventListener('pointercancel', stopPointerDrag); + container.removeEventListener('wheel', handleWheel); + container.removeEventListener('contextmenu', preventContextMenu); + scene.traverse((object) => { + if (object instanceof THREE.Mesh) { + object.geometry.dispose(); + const material = object.material; + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + } else { + material.dispose(); + } + } + }); + renderer.dispose(); + container.innerHTML = ''; + }; + }, [ + project?.id, + project?.stlFiles?.join('|'), + volume, + JSON.stringify(moduleStyles), + detailLimit, + cutEnabled, + cutStart, + cutEnd, + ]); + + return ( +
+
+ {webglError && ( +
+
+ {webglError} +
+
+ )} + {(!project || !volume) && ( +
+ 正在载入 STL 切面... +
+ )} +
+ ); +} + +interface ModelBounds { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; +} + +interface Point2D { + x: number; + y: number; +} + +interface Point3D { + x: number; + y: number; + z: number; +} + +interface ModelSceneMetrics { + center: Point3D; + modelBaseScale: number; + modelPivotOffsetZ: number; + dicomWidth: number; + dicomHeight: number; + dicomDepth: number; +} + +interface PlaneSegment { + a: Point2D; + b: Point2D; +} + +export interface OverlayStats { + activeModules: number; + filledPixels: number; + segmentCount: number; + modules: Array<{ + fileName: string; + name: string; + color: string; + opacity: number; + partId: number; + segmentCount: number; + filledPixels: number; + }>; +} + +function getPayloadBounds(payload: ModelPreviewPayload): ModelBounds | null { + if (payload.bounds) { + return payload.bounds; + } + + if (payload.vertices.length < 3) { + return null; + } + + const bounds: ModelBounds = { + min: { x: Infinity, y: Infinity, z: Infinity }, + max: { x: -Infinity, y: -Infinity, z: -Infinity }, + }; + + for (let index = 0; index < payload.vertices.length; index += 3) { + const x = payload.vertices[index]; + const y = payload.vertices[index + 1]; + const z = payload.vertices[index + 2]; + bounds.min.x = Math.min(bounds.min.x, x); + bounds.min.y = Math.min(bounds.min.y, y); + bounds.min.z = Math.min(bounds.min.z, z); + bounds.max.x = Math.max(bounds.max.x, x); + bounds.max.y = Math.max(bounds.max.y, y); + bounds.max.z = Math.max(bounds.max.z, z); + } + + return Number.isFinite(bounds.min.x) ? bounds : null; +} + +function getGlobalModelBounds(files: string[], previews: Record) { + const bounds: ModelBounds = { + min: { x: Infinity, y: Infinity, z: Infinity }, + max: { x: -Infinity, y: -Infinity, z: -Infinity }, + }; + let hasBounds = false; + + files.forEach((fileName) => { + const payloadBounds = previews[fileName] ? getPayloadBounds(previews[fileName]) : null; + if (!payloadBounds) { + return; + } + hasBounds = true; + bounds.min.x = Math.min(bounds.min.x, payloadBounds.min.x); + bounds.min.y = Math.min(bounds.min.y, payloadBounds.min.y); + bounds.min.z = Math.min(bounds.min.z, payloadBounds.min.z); + bounds.max.x = Math.max(bounds.max.x, payloadBounds.max.x); + bounds.max.y = Math.max(bounds.max.y, payloadBounds.max.y); + bounds.max.z = Math.max(bounds.max.z, payloadBounds.max.z); + }); + + return hasBounds ? bounds : null; +} + +function getPreviewPhysicalSize(preview: DicomPreview) { + const columnSpacing = preview.spacing?.displayX ?? preview.spacing?.column ?? 1; + const rowSpacing = preview.spacing?.displayY ?? preview.spacing?.row ?? 1; + const width = preview.physicalSize?.width ?? preview.width * columnSpacing; + const height = preview.physicalSize?.height ?? preview.height * rowSpacing; + + return { + width: Math.max(width, 0.001), + height: Math.max(height, 0.001), + columnSpacing: Math.max(columnSpacing, 0.001), + rowSpacing: Math.max(rowSpacing, 0.001), + sliceSpacing: Math.max(preview.spacing?.slice ?? 1, 0.001), + }; +} + +function getFovCanvasSize(preview: DicomPreview) { + const physical = getPreviewPhysicalSize(preview); + const unit = Math.max(0.001, Math.min(physical.columnSpacing, physical.rowSpacing)); + const rawWidth = Math.max(1, Math.round(physical.width / unit)); + const rawHeight = Math.max(1, Math.round(physical.height / unit)); + const maxDimension = 960; + const scale = Math.min(1, maxDimension / Math.max(rawWidth, rawHeight)); + + return { + width: Math.max(1, Math.round(rawWidth * scale)), + height: Math.max(1, Math.round(rawHeight * scale)), + }; +} + +function getModelSceneMetrics( + files: string[], + previews: Record, + preview: DicomPreview, + totalSlices: number, +): ModelSceneMetrics | null { + const globalBounds = getGlobalModelBounds(files, previews); + if (!globalBounds) { + return null; + } + + const spanX = Math.max(globalBounds.max.x - globalBounds.min.x, 0.001); + const spanY = Math.max(globalBounds.max.y - globalBounds.min.y, 0.001); + const spanZ = Math.max(globalBounds.max.z - globalBounds.min.z, 0.001); + const maxModelSize = Math.max(spanX, spanY, spanZ, 1); + const physical = getPreviewPhysicalSize(preview); + const physicalDepth = Math.max(totalSlices, 1) * physical.sliceSpacing; + const maxPhysical = Math.max(physical.width, physical.height, physicalDepth, 1); + const dicomWidth = (physical.width / maxPhysical) * fusionBaseExtent; + const dicomHeight = (physical.height / maxPhysical) * fusionBaseExtent; + const dicomDepth = Math.max((physicalDepth / maxPhysical) * fusionBaseExtent, 0.18); + const modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92; + + return { + center: { + x: (globalBounds.min.x + globalBounds.max.x) / 2, + y: (globalBounds.min.y + globalBounds.max.y) / 2, + z: (globalBounds.min.z + globalBounds.max.z) / 2, + }, + modelBaseScale, + modelPivotOffsetZ: dicomDepth * 0.08, + dicomWidth, + dicomHeight, + dicomDepth, + }; +} + +function transformPointForPose(x: number, y: number, z: number, metrics: ModelSceneMetrics, pose: ModelPose): Point3D { + const scalar = metrics.modelBaseScale * pose.scale; + let px = (x - metrics.center.x) * scalar; + let py = (y - metrics.center.y) * scalar; + let pz = (z - metrics.center.z + metrics.modelPivotOffsetZ) * scalar; + + const rotateX = THREE.MathUtils.degToRad(pose.rotateX); + const rotateY = THREE.MathUtils.degToRad(pose.rotateY); + const rotateZ = THREE.MathUtils.degToRad(pose.rotateZ); + const cosX = Math.cos(rotateX); + const sinX = Math.sin(rotateX); + const cosY = Math.cos(rotateY); + const sinY = Math.sin(rotateY); + const cosZ = Math.cos(rotateZ); + const sinZ = Math.sin(rotateZ); + + const afterX = { + x: px, + y: py * cosX - pz * sinX, + z: py * sinX + pz * cosX, + }; + const afterY = { + x: afterX.x * cosY + afterX.z * sinY, + y: afterX.y, + z: -afterX.x * sinY + afterX.z * cosY, + }; + px = afterY.x * cosZ - afterY.y * sinZ; + py = afterY.x * sinZ + afterY.y * cosZ; + pz = afterY.z; + + return { + x: px + pose.translateX, + y: py + pose.translateY, + z: pz + pose.translateZ, + }; +} + +function intersectEdgeWithPlane(start: Point3D, end: Point3D, targetZ: number): Point2D | null { + const epsilon = 1e-5; + const startDistance = start.z - targetZ; + const endDistance = end.z - targetZ; + + if (Math.abs(startDistance) <= epsilon && Math.abs(endDistance) <= epsilon) { + return null; + } + + if (Math.abs(startDistance) <= epsilon) { + return { x: start.x, y: start.y }; + } + + if (Math.abs(endDistance) <= epsilon) { + return { x: end.x, y: end.y }; + } + + if ((startDistance > 0 && endDistance > 0) || (startDistance < 0 && endDistance < 0)) { + return null; + } + + const t = startDistance / (startDistance - endDistance); + return { + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + }; +} + +function pointDistanceSquared(a: Point2D, b: Point2D) { + const dx = a.x - b.x; + const dy = a.y - b.y; + return dx * dx + dy * dy; +} + +function intersectTriangleWithPlane(a: Point3D, b: Point3D, c: Point3D, targetZ: number): PlaneSegment | null { + const intersections = [ + intersectEdgeWithPlane(a, b, targetZ), + intersectEdgeWithPlane(b, c, targetZ), + intersectEdgeWithPlane(c, a, targetZ), + ].filter((point): point is Point2D => Boolean(point)); + + const uniquePoints: Point2D[] = []; + intersections.forEach((point) => { + const exists = uniquePoints.some((current) => pointDistanceSquared(current, point) < 1e-8); + if (!exists) { + uniquePoints.push(point); + } + }); + + if (uniquePoints.length < 2) { + return null; + } + + let segment: PlaneSegment = { a: uniquePoints[0], b: uniquePoints[1] }; + let maxDistance = pointDistanceSquared(segment.a, segment.b); + for (let first = 0; first < uniquePoints.length; first += 1) { + for (let second = first + 1; second < uniquePoints.length; second += 1) { + const distance = pointDistanceSquared(uniquePoints[first], uniquePoints[second]); + if (distance > maxDistance) { + maxDistance = distance; + segment = { a: uniquePoints[first], b: uniquePoints[second] }; + } + } + } + + return maxDistance > 1e-8 ? segment : null; +} + +function parseHexColor(color: string) { + const normalized = color.replace('#', '').trim(); + const value = normalized.length === 3 + ? normalized.split('').map((item) => item + item).join('') + : normalized.padEnd(6, '0').slice(0, 6); + const parsed = Number.parseInt(value, 16); + + if (!Number.isFinite(parsed)) { + return { r: 59, g: 130, b: 246 }; + } + + return { + r: (parsed >> 16) & 255, + g: (parsed >> 8) & 255, + b: parsed & 255, + }; +} + +function fillInternalMaskHoles( + maskData: ImageData, + width: number, + height: number, + rgb: { r: number; g: number; b: number }, + alpha: number, +) { + const outside = new Uint8Array(width * height); + const stack: number[] = []; + const pushIfEmpty = (x: number, y: number) => { + if (x < 0 || x >= width || y < 0 || y >= height) { + return; + } + + const index = y * width + x; + if (outside[index] || maskData.data[index * 4 + 3] > 0) { + return; + } + + outside[index] = 1; + stack.push(index); + }; + + for (let x = 0; x < width; x += 1) { + pushIfEmpty(x, 0); + pushIfEmpty(x, height - 1); + } + for (let y = 0; y < height; y += 1) { + pushIfEmpty(0, y); + pushIfEmpty(width - 1, y); + } + + while (stack.length) { + const index = stack.pop(); + if (index === undefined) { + continue; + } + + const x = index % width; + const y = Math.floor(index / width); + pushIfEmpty(x + 1, y); + pushIfEmpty(x - 1, y); + pushIfEmpty(x, y + 1); + pushIfEmpty(x, y - 1); + } + + let patchedPixels = 0; + for (let index = 0; index < outside.length; index += 1) { + const offset = index * 4; + if (!outside[index] && maskData.data[offset + 3] === 0) { + maskData.data[offset] = rgb.r; + maskData.data[offset + 1] = rgb.g; + maskData.data[offset + 2] = rgb.b; + maskData.data[offset + 3] = alpha; + patchedPixels += 1; + } + } + + return patchedPixels; +} + +function drawFallbackClosedRegion( + context: CanvasRenderingContext2D, + width: number, + height: number, + segments: PlaneSegment[], + color: string, + opacity: number, +) { + const points = segments.flatMap((segment) => [segment.a, segment.b]) + .filter((point) => ( + Number.isFinite(point.x) + && Number.isFinite(point.y) + && point.x >= -width + && point.x <= width * 2 + && point.y >= -height + && point.y <= height * 2 + )); + + if (points.length < 3) { + return 0; + } + + const center = points.reduce((accumulator, point) => ({ + x: accumulator.x + point.x / points.length, + y: accumulator.y + point.y / points.length, + }), { x: 0, y: 0 }); + const ordered = [...points].sort((left, right) => ( + Math.atan2(left.y - center.y, left.x - center.x) - Math.atan2(right.y - center.y, right.x - center.x) + )); + + context.save(); + context.globalAlpha = clamp(opacity, 0.1, 1) * 0.48; + context.fillStyle = color; + context.beginPath(); + ordered.forEach((point, index) => { + if (index === 0) { + context.moveTo(point.x, point.y); + return; + } + context.lineTo(point.x, point.y); + }); + context.closePath(); + context.fill(); + context.restore(); + + return Math.max(1, Math.round(points.length / 2)); +} + +function fillSegmentsAsSolidMask( + context: CanvasRenderingContext2D, + width: number, + height: number, + segments: PlaneSegment[], + color: string, + opacity: number, +) { + if (!segments.length) { + return 0; + } + + const rows: number[][] = Array.from({ length: height }, () => []); + segments.forEach((segment) => { + const { a, b } = segment; + if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) { + return; + } + + const deltaY = b.y - a.y; + if (Math.abs(deltaY) < 0.01) { + return; + } + + const minY = Math.max(0, Math.floor(Math.min(a.y, b.y))); + const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y))); + for (let row = minY; row <= maxY; row += 1) { + const sampleY = row + 0.5; + const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y); + if (!crosses) { + continue; + } + + const t = (sampleY - a.y) / deltaY; + const x = a.x + (b.x - a.x) * t; + if (Number.isFinite(x)) { + rows[row].push(x); + } + } + }); + + const rgb = parseHexColor(color); + const alpha = Math.round(clamp(opacity, 0.1, 1) * 190); + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = width; + maskCanvas.height = height; + const maskContext = maskCanvas.getContext('2d'); + if (!maskContext) { + return 0; + } + const maskData = maskContext.createImageData(width, height); + let filledPixels = 0; + + rows.forEach((intersections, row) => { + if (intersections.length < 2) { + return; + } + + intersections.sort((left, right) => left - right); + const cleaned: number[] = []; + intersections.forEach((x) => { + const previous = cleaned[cleaned.length - 1]; + if (previous === undefined || Math.abs(previous - x) > 0.35) { + cleaned.push(x); + } + }); + + for (let index = 0; index + 1 < cleaned.length; index += 2) { + const rawStartX = cleaned[index]; + const rawEndX = cleaned[index + 1]; + if (rawEndX < 0 || rawStartX > width - 1) { + continue; + } + + const startX = clamp(Math.ceil(rawStartX), 0, width - 1); + const endX = clamp(Math.floor(rawEndX), 0, width - 1); + if (endX < startX) { + continue; + } + + for (let x = startX; x <= endX; x += 1) { + const offset = (row * width + x) * 4; + maskData.data[offset] = rgb.r; + maskData.data[offset + 1] = rgb.g; + maskData.data[offset + 2] = rgb.b; + maskData.data[offset + 3] = alpha; + filledPixels += 1; + } + } + }); + + filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha); + maskContext.putImageData(maskData, 0, 0); + context.drawImage(maskCanvas, 0, 0); + if (filledPixels === 0 && segments.length >= 3) { + filledPixels = drawFallbackClosedRegion(context, width, height, segments, color, opacity); + } + + context.save(); + context.globalAlpha = clamp(opacity, 0.1, 1) * 0.82; + context.strokeStyle = color; + context.lineWidth = Math.max(1.2, Math.max(width, height) * 0.003); + context.lineCap = 'round'; + context.lineJoin = 'round'; + context.beginPath(); + segments.forEach((segment) => { + context.moveTo(segment.a.x, segment.a.y); + context.lineTo(segment.b.x, segment.b.y); + }); + context.stroke(); + context.restore(); + + return filledPixels; +} + +function drawDicomBaseLayer(canvas: HTMLCanvasElement, preview: DicomPreview) { + const fovCanvas = getFovCanvasSize(preview); + canvas.width = fovCanvas.width; + canvas.height = fovCanvas.height; + const context = canvas.getContext('2d'); + if (!context) { + return; + } + + const binary = atob(preview.pixels); + const imageData = context.createImageData(fovCanvas.width, fovCanvas.height); + for (let y = 0; y < fovCanvas.height; y += 1) { + const sourceY = Math.min(preview.height - 1, Math.floor((y / fovCanvas.height) * preview.height)); + for (let x = 0; x < fovCanvas.width; x += 1) { + const sourceX = Math.min(preview.width - 1, Math.floor((x / fovCanvas.width) * preview.width)); + const value = binary.charCodeAt(sourceY * preview.width + sourceX); + const offset = (y * fovCanvas.width + x) * 4; + imageData.data[offset] = value; + imageData.data[offset + 1] = value; + imageData.data[offset + 2] = value; + imageData.data[offset + 3] = 255; + } + } + context.putImageData(imageData, 0, 0); +} + +function drawVoxelOverlayLayer( + canvas: HTMLCanvasElement, + preview: DicomPreview, + files: string[], + previews: Record, + moduleStyles: Record, + modelPose: ModelPose, + slice: number, + totalSlices: number, +): OverlayStats { + const fovCanvas = getFovCanvasSize(preview); + canvas.width = fovCanvas.width; + canvas.height = fovCanvas.height; + const context = canvas.getContext('2d'); + if (!context) { + return { activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] }; + } + + context.clearRect(0, 0, fovCanvas.width, fovCanvas.height); + const metrics = getModelSceneMetrics(files, previews, preview, totalSlices); + if (!metrics) { + return { activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] }; + } + + const safeSlice = clamp(slice, 0, Math.max(totalSlices - 1, 0)); + const targetZ = totalSlices <= 1 + ? 0 + : -metrics.dicomDepth / 2 + (metrics.dicomDepth * safeSlice) / (totalSlices - 1); + const mapPoint = (point: Point2D): Point2D => ({ + x: ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * fovCanvas.width, + y: fovCanvas.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * fovCanvas.height, + }); + let activeModules = 0; + let filledPixels = 0; + let segmentCount = 0; + const modules: OverlayStats['modules'] = []; + + files.forEach((fileName, index) => { + const payload = previews[fileName]; + const style = moduleStyles[fileName] ?? { + visible: true, + color: moduleColors[index % moduleColors.length], + opacity: 0.72, + partId: index + 1, + }; + + if (!payload || style.visible === false) { + return; + } + + const segments: PlaneSegment[] = []; + + for (let vertexIndex = 0; vertexIndex < payload.vertices.length; vertexIndex += 9) { + const a = transformPointForPose( + payload.vertices[vertexIndex], + payload.vertices[vertexIndex + 1], + payload.vertices[vertexIndex + 2], + metrics, + modelPose, + ); + const b = transformPointForPose( + payload.vertices[vertexIndex + 3], + payload.vertices[vertexIndex + 4], + payload.vertices[vertexIndex + 5], + metrics, + modelPose, + ); + const c = transformPointForPose( + payload.vertices[vertexIndex + 6], + payload.vertices[vertexIndex + 7], + payload.vertices[vertexIndex + 8], + metrics, + modelPose, + ); + const segment = intersectTriangleWithPlane(a, b, c, targetZ); + + if (segment) { + segments.push({ + a: mapPoint(segment.a), + b: mapPoint(segment.b), + }); + } + } + + const modulePixels = fillSegmentsAsSolidMask(context, fovCanvas.width, fovCanvas.height, segments, style.color, style.opacity); + if (segments.length > 0 || modulePixels > 0) { + activeModules += 1; + modules.push({ + fileName, + name: fileName.replace(/\.stl$/i, ''), + color: style.color, + opacity: style.opacity, + partId: style.partId, + segmentCount: segments.length, + filledPixels: modulePixels, + }); + } + filledPixels += modulePixels; + segmentCount += segments.length; + }); + + return { activeModules, filledPixels, segmentCount, modules }; +} + +export function VoxelizationMappingView({ + project, + moduleStyles, + modelPose, + detailLimit, + slice, + totalSlices, + onSliceChange, + displayMode, + rotation, + variant = 'workspace', + toolbar, + overlayPlacement, + onOverlayStatsChange, +}: { + project: Project | null; + moduleStyles: Record; + modelPose: ModelPose; + detailLimit: number; + slice: number; + totalSlices: number; + onSliceChange: (slice: number) => void; + displayMode: MappingDisplayMode; + rotation: number; + variant?: 'workspace' | 'library'; + toolbar?: React.ReactNode; + overlayPlacement?: 'bottom' | 'side' | 'none'; + onOverlayStatsChange?: (stats: OverlayStats, visibleModuleCount: number) => void; +}) { + const baseCanvasRef = useRef(null); + const overlayCanvasRef = useRef(null); + const [dicomPreview, setDicomPreview] = useState(null); + const [modelPreviews, setModelPreviews] = useState>({}); + const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片'); + const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射'); + const [overlayStats, setOverlayStats] = useState({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] }); + const [mappingViewport, setMappingViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 }); + const mappingPanRef = useRef({ + active: false, + pointerId: 0, + startX: 0, + startY: 0, + offsetX: 0, + offsetY: 0, + }); + const maxSlice = Math.max(totalSlices - 1, 0); + const safeSlice = clamp(slice, 0, maxSlice); + const stlFiles = project?.stlFiles ?? []; + const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length; + const isLibraryVariant = variant === 'library'; + const activeOverlayPlacement = overlayPlacement ?? (isLibraryVariant ? 'side' : 'bottom'); + + useEffect(() => { + onOverlayStatsChange?.(overlayStats, visibleModuleCount); + }, [onOverlayStatsChange, overlayStats, visibleModuleCount]); + + useEffect(() => { + if (!project?.dicomCount) { + setDicomPreview(null); + setDicomStatus('没有可显示的 DICOM 切片'); + return; + } + + let disposed = false; + setDicomStatus('正在载入 DICOM Base Layer...'); + getCachedDicomPreview(project.id, safeSlice, 'axial', displayMode) + .then((preview) => { + if (disposed) return; + setDicomPreview(preview); + setDicomStatus('DICOM Base Layer 已就绪'); + }) + .catch((error) => { + if (disposed) return; + setDicomPreview(null); + setDicomStatus(error instanceof Error ? error.message : 'DICOM 切片载入失败'); + }); + + return () => { + disposed = true; + }; + }, [project?.id, project?.dicomCount, safeSlice, displayMode]); + + useEffect(() => { + if (!project || !stlFiles.length) { + setModelPreviews({}); + setOverlayStatus('当前项目没有 STL 构件'); + return; + } + + let disposed = false; + setOverlayStatus('正在载入 STL 构件层级...'); + Promise.allSettled(stlFiles.map((fileName) => ( + getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 200000)) + .then((payload) => ({ fileName, payload })) + ))).then((results) => { + if (disposed) return; + const nextPreviews: Record = {}; + results.forEach((result) => { + if (result.status === 'fulfilled') { + nextPreviews[result.value.fileName] = result.value.payload; + } + }); + setModelPreviews(nextPreviews); + setOverlayStatus(Object.keys(nextPreviews).length ? 'Overlay Label Map 已就绪' : 'Overlay Layer 无可用 STL 数据'); + }); + + return () => { + disposed = true; + }; + }, [project?.id, stlFiles.join('|'), detailLimit]); + + useEffect(() => { + const canvas = baseCanvasRef.current; + if (!canvas || !dicomPreview) { + return; + } + drawDicomBaseLayer(canvas, dicomPreview); + }, [dicomPreview]); + + useEffect(() => { + const canvas = overlayCanvasRef.current; + if (!canvas || !dicomPreview) { + return; + } + const frame = window.requestAnimationFrame(() => { + const stats = drawVoxelOverlayLayer( + canvas, + dicomPreview, + stlFiles, + modelPreviews, + moduleStyles, + modelPose, + safeSlice, + Math.max(totalSlices, 1), + ); + setOverlayStats(stats); + }); + + return () => window.cancelAnimationFrame(frame); + }, [ + dicomPreview, + stlFiles.join('|'), + modelPreviews, + JSON.stringify(moduleStyles), + modelPose.rotateX, + modelPose.rotateY, + modelPose.rotateZ, + modelPose.translateX, + modelPose.translateY, + modelPose.translateZ, + modelPose.scale, + safeSlice, + totalSlices, + ]); + + const stepSlice = (delta: number) => { + onSliceChange(clamp(safeSlice + delta, 0, maxSlice)); + }; + const slicePercent = maxSlice > 0 ? (safeSlice / maxSlice) * 100 : 0; + const resetMappingViewport = () => { + setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 }); + }; + const handleMappingWheel = (event: React.WheelEvent) => { + event.preventDefault(); + const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1; + setMappingViewport((current) => ({ + ...current, + scale: clamp(current.scale * scaleFactor, 0.45, 6), + })); + }; + const handleMappingPointerDown = (event: React.PointerEvent) => { + if (event.button !== 0) { + return; + } + mappingPanRef.current = { + active: true, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + offsetX: mappingViewport.offsetX, + offsetY: mappingViewport.offsetY, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + const handleMappingPointerMove = (event: React.PointerEvent) => { + const dragState = mappingPanRef.current; + if (!dragState.active || dragState.pointerId !== event.pointerId) { + return; + } + setMappingViewport((current) => ({ + ...current, + offsetX: dragState.offsetX + event.clientX - dragState.startX, + offsetY: dragState.offsetY + event.clientY - dragState.startY, + })); + }; + const stopMappingPointerDrag = (event: React.PointerEvent) => { + const dragState = mappingPanRef.current; + if (!dragState.active || dragState.pointerId !== event.pointerId) { + return; + } + mappingPanRef.current = { ...dragState, active: false }; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + const renderOverlaySummary = (placement: 'bottom' | 'side') => ( +
+
+ Overlay Label Map + + {overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px + +
+
+ {overlayStats.modules.length ? ( +
+ {overlayStats.modules.map((item) => ( +
+ + {item.name} + ID {item.partId} + {item.segmentCount} 边 + {item.filledPixels} px +
+ ))} +
+ ) : ( +
+ 当前切片暂无可见构件 +
+ )} +
+
+ ); + + if (isLibraryVariant) { + return ( +
+
+
+ 逆向分割映射视图 +
+
+ {toolbar} + +
+
+ +
+
+
+ {dicomPreview ? ( +
+ + +
+ ) : ( +
+ {dicomStatus} +
+ )} +
+

DICOM 切片位置

+

+ {safeSlice + 1} / {Math.max(totalSlices, 1)} +

+
+
+ {activeOverlayPlacement === 'bottom' && renderOverlaySummary('bottom')} +
+ + +
+
+ ); + } + + return ( +
+
+
+ + Base DICOM + + + Overlay Label Map + + + Z {safeSlice + 1}/{Math.max(totalSlices, 1)} + +
+ +
+ +
+
+
+ {dicomPreview ? ( +
+ + +
+ ) : ( +
+ {dicomStatus} +
+ )} +
+ +
+
+ Overlay Label Map + + {overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px + +
+
+ {overlayStats.modules.length ? ( +
+ {overlayStats.modules.map((item) => ( +
+ + {item.name} + ID {item.partId} + {item.segmentCount} 边 + {item.filledPixels} px +
+ ))} +
+ ) : ( +
+ 当前切片暂无可见构件 +
+ )} +
+
+
+ + +
+
+ ); +} + +export default function ReverseWorkspace({ + projectId, + onLeaveGuardChange, +}: { + projectId: string; + onLeaveGuardChange?: (handler: WorkspaceLeaveGuard | null) => void; +}) { + const [sliceStart, setSliceStart] = useState(0); + const [sliceEnd, setSliceEnd] = useState(49); + const [mappingSlice, setMappingSlice] = useState(0); + const [modelPose, setModelPose] = useState(defaultModelPose); + const [poseValueDrafts, setPoseValueDrafts] = useState(() => formatPoseDraftValues(defaultModelPose)); + const [focusedPoseInput, setFocusedPoseInput] = useState(null); + const [poseImportStatus, setPoseImportStatus] = useState(''); + const [displayLevel, setDisplayLevel] = useState('standard'); + const [dicomOpacityLevel, setDicomOpacityLevel] = useState('low'); + const [mappingDisplayMode, setMappingDisplayMode] = useState('soft'); + const [mappingRotation, setMappingRotation] = useState(0); + const [showBounds, setShowBounds] = useState(true); + const [cutEnabled, setCutEnabled] = useState(false); + const [moduleStyles, setModuleStyles] = useState>({}); + const [savedPoses, setSavedPoses] = useState(defaultSavedPoses); + const [selectedPoseId, setSelectedPoseId] = useState('default'); + const [showExportMenu, setShowExportMenu] = useState(false); + const [exportSelection, setExportSelection] = useState>({ + dicom: false, + segmentation: true, + pose: true, + stl: false, + }); + const [segmentationExportScope, setSegmentationExportScope] = useState('visible'); + const [segmentationExportMode, setSegmentationExportMode] = useState('combined'); + const [project, setProject] = useState(null); + const [fusionVolume, setFusionVolume] = useState(null); + const [fusionError, setFusionError] = useState(''); + const [saveStatus, setSaveStatus] = useState(''); + const [exporting, setExporting] = useState(false); + const [stretchingAxis, setStretchingAxis] = useState(null); + const modelBoundsCacheRef = useRef(new Map()); + const [workspaceLoadState, setWorkspaceLoadState] = useState({ + ready: false, + phase: '正在读取项目配置...', + loaded: 0, + total: 1, + startedAt: Date.now(), + error: '', + }); + const workspaceLoadProjectRef = useRef(''); + const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null }); + const poseImportInputRef = useRef(null); + const visualToolbarScrollRef = useRef(null); + const saveToastTimerRef = useRef(null); + const savedWorkspaceSnapshotRef = useRef(''); + const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false }); + + const handleExportSelected = async () => { + const selectedItems = exportOptions + .filter((option) => exportSelection[option.id]) + .map((option) => option.id); + if (!selectedItems.length) { + setFusionError('请至少选择一个导出内容'); + return; + } + + setExporting(true); + setFusionError(''); + try { + await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', { + pose: modelPose, + segmentationScope: segmentationExportScope, + segmentationExportMode, + }); + window.setTimeout(() => setExporting(false), 900); + setShowExportMenu(false); + } catch (error) { + setFusionError(error instanceof Error ? error.message : '导出失败'); + setExporting(false); + } + }; + + const getCurrentWorkspaceSnapshot = useCallback(() => createWorkspaceSnapshot({ + modelPose, + segmentationExportScope, + moduleStyles, + sliceStart, + sliceEnd, + mappingSlice, + displayLevel, + dicomOpacityLevel, + showBounds, + cutEnabled, + }), [ + modelPose, + segmentationExportScope, + moduleStyles, + sliceStart, + sliceEnd, + mappingSlice, + displayLevel, + dicomOpacityLevel, + showBounds, + cutEnabled, + ]); + + const handleSaveSegmentationResult = useCallback(async (options: { showToast?: boolean } = {}) => { + if (!project) { + return false; + } + + setFusionError(''); + setSaveStatus(''); + try { + const updated = await api.saveProjectSegmentationResult(project.id, { + name: '逆向分割结果', + pose: modelPose, + segmentationScope: segmentationExportScope, + moduleStyles, + sliceStart: clamp(sliceStart, 0, Math.max(project.dicomCount - 1, 0)), + sliceEnd: clamp(sliceEnd, 0, Math.max(project.dicomCount - 1, 0)), + mappingSlice: clamp(mappingSlice, 0, Math.max(project.dicomCount - 1, 0)), + displayLevel, + dicomOpacityLevel, + showBounds, + cutEnabled, + }); + setProject(updated); + savedWorkspaceSnapshotRef.current = getCurrentWorkspaceSnapshot(); + if (options.showToast !== false) { + setSaveStatus('已保存至项目库的分割结果区域'); + } + return true; + } catch (error) { + const message = error instanceof Error ? error.message : '保存至项目库失败'; + setFusionError(message); + if (options.showToast === false) { + window.alert(message); + } + return false; + } + }, [ + project, + modelPose, + segmentationExportScope, + moduleStyles, + sliceStart, + sliceEnd, + mappingSlice, + displayLevel, + dicomOpacityLevel, + showBounds, + cutEnabled, + getCurrentWorkspaceSnapshot, + ]); + + useEffect(() => { + if (!saveStatus) { + return undefined; + } + + if (saveToastTimerRef.current !== null) { + window.clearTimeout(saveToastTimerRef.current); + } + saveToastTimerRef.current = window.setTimeout(() => { + setSaveStatus(''); + saveToastTimerRef.current = null; + }, 2600); + + return () => { + if (saveToastTimerRef.current !== null) { + window.clearTimeout(saveToastTimerRef.current); + saveToastTimerRef.current = null; + } + }; + }, [saveStatus]); + + useEffect(() => { + if (!onLeaveGuardChange) { + return undefined; + } + + onLeaveGuardChange(async () => { + if (!project) { + return true; + } + if (savedWorkspaceSnapshotRef.current === getCurrentWorkspaceSnapshot()) { + return true; + } + const shouldSave = window.confirm('是否保存当前结果至项目库? 确定:保存后退出。取消:直接退出,不保存当前结果。'); + if (!shouldSave) { + return true; + } + return handleSaveSegmentationResult({ showToast: false }); + }); + + return () => onLeaveGuardChange(null); + }, [getCurrentWorkspaceSnapshot, handleSaveSegmentationResult, onLeaveGuardChange, project]); + + const makeDefaultModuleStyle = (index: number, fallback?: Partial): ModuleStyle => ({ + visible: fallback?.visible ?? true, + color: fallback?.color ?? moduleColors[index % moduleColors.length], + opacity: fallback?.opacity ?? 0.72, + partId: clamp(Math.round(fallback?.partId ?? index + 1), 1, 255), + }); + + const commitModuleStyles = (next: Record) => { + setModuleStyles(next); + if (!project) { + return; + } + api.updateProjectModuleStyles(project.id, next) + .then((updated) => { + setProject(updated); + }) + .catch(() => { + setFusionError('构件样式保存失败,请稍后重试'); + }); + }; + + const loadFusionVolume = async (start: number, end: number) => { + if (!project?.dicomCount) return null; + const maxSliceValue = Math.max(project.dicomCount - 1, 0); + const safeA = clamp(start, 0, maxSliceValue); + const safeB = clamp(end, 0, maxSliceValue); + const safeStart = Math.min(safeA, safeB); + const rangeEnd = Math.max(safeA, safeB); + const volumePayload = await getCachedDicomFusionVolume(project.id, safeStart, rangeEnd, 'soft'); + return volumePayload; + }; + + const loadGlobalModelBounds = async () => { + if (!project) { + return null; + } + const modelFiles = project.stlFiles ?? []; + const cacheKey = `${project.id}:global:${modelFiles.join('|')}`; + const cached = modelBoundsCacheRef.current.get(cacheKey); + if (cached) { + return cached; + } + + const modelBox = new THREE.Box3(); + const results = await Promise.allSettled(modelFiles.map((fileName) => ( + getCachedModelPreview(project.id, fileName, 1000) + ))); + results.forEach((result) => { + if (result.status !== 'fulfilled' || !result.value.bounds) { + return; + } + modelBox.expandByPoint(new THREE.Vector3(result.value.bounds.min.x, result.value.bounds.min.y, result.value.bounds.min.z)); + modelBox.expandByPoint(new THREE.Vector3(result.value.bounds.max.x, result.value.bounds.max.y, result.value.bounds.max.z)); + }); + + if (modelBox.isEmpty()) { + return null; + } + const bounds = { min: modelBox.min.clone(), max: modelBox.max.clone() }; + modelBoundsCacheRef.current.set(cacheKey, bounds); + return bounds; + }; + + const applyModelStretchByAxis = async (axis: AxisKey, options: { silentInitial?: boolean } = {}) => { + if (!project || !fusionVolume) { + setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型'); + return; + } + if (!isOrthogonalModelPose(modelPose)) { + setPoseImportStatus(''); + setFusionError('模型拉伸仅在旋转 X/Y/Z 均为 90° 的整数倍时可用'); + return; + } + + setStretchingAxis(axis); + setFusionError(''); + try { + const bounds = await loadGlobalModelBounds(); + if (!bounds) { + throw new Error('未获取到 STL 构件边界'); + } + const rawSize = new THREE.Vector3().subVectors(bounds.max, bounds.min); + const rotatedSize = getRotatedModelSize(bounds, modelPose); + const maxModelSize = Math.max(rawSize.x, rawSize.y, rawSize.z, 1); + const maxPhysical = Math.max( + fusionVolume.physicalSize.width, + fusionVolume.physicalSize.height, + fusionVolume.physicalSize.depth, + 1, + ); + const baseExtent = 4.6; + const dicomSize = { + x: (fusionVolume.physicalSize.width / maxPhysical) * baseExtent, + y: (fusionVolume.physicalSize.height / maxPhysical) * baseExtent, + z: Math.max((fusionVolume.physicalSize.depth / maxPhysical) * baseExtent, 0.18), + }; + const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92; + const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6); + const axisFitScale = dicomSize[axis] / (rotatedAxisSize * baseScale); + const containmentScale = Math.min( + dicomSize.x / (Math.max(rotatedSize.x, 1e-6) * baseScale), + dicomSize.y / (Math.max(rotatedSize.y, 1e-6) * baseScale), + dicomSize.z / (Math.max(rotatedSize.z, 1e-6) * baseScale), + ); + const limitedByVolume = axisFitScale > containmentScale + 1e-6; + const nextScale = clampPoseValue('scale', Math.min(axisFitScale, containmentScale)); + const nextPose = { ...modelPose, scale: nextScale }; + updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true }); + setPoseImportStatus( + limitedByVolume + ? `已按 ${axis.toUpperCase()} 方向拉伸,并限制在 DICOM 体范围内` + : `已按 ${axis.toUpperCase()} 方向进行三维等比例拉伸`, + ); + if (options.silentInitial) { + savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({ + modelPose: nextPose, + segmentationExportScope, + moduleStyles, + sliceStart, + sliceEnd, + mappingSlice, + displayLevel, + dicomOpacityLevel, + showBounds, + cutEnabled, + }); + } + } catch (error) { + setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败'); + } finally { + setStretchingAxis(null); + } + }; + + useEffect(() => { + workspaceLoadProjectRef.current = ''; + setWorkspaceLoadState({ + ready: false, + phase: '正在读取项目配置...', + loaded: 0, + total: 1, + startedAt: Date.now(), + error: '', + }); + api.getProject(projectId).then((item) => { + setProject(item); + const maxIndex = Math.max((item.dicomCount || 1) - 1, 0); + const latestResult = item.segmentationResults?.[item.segmentationResults.length - 1]; + const restoredSliceStart = clamp(latestResult?.sliceStart ?? 0, 0, maxIndex); + const restoredSliceEnd = clamp(latestResult?.sliceEnd ?? maxIndex, 0, maxIndex); + const restoredMappingSlice = clamp(latestResult?.mappingSlice ?? restoredSliceEnd, 0, maxIndex); + setSliceStart(restoredSliceStart); + setSliceEnd(restoredSliceEnd); + setMappingSlice(restoredMappingSlice); + const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses; + const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0]; + const restoredPose = latestResult?.pose ?? preferredPose?.pose ?? defaultModelPose; + initialZStretchRef.current = { projectId: item.id, pending: !latestResult }; + setModelPose(restoredPose); + setPoseValueDrafts(formatPoseDraftValues(restoredPose)); + const nextStyles: Record = {}; + (item.stlFiles ?? []).forEach((fileName, index) => { + nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? item.moduleStyles?.[fileName]); + }); + setModuleStyles(nextStyles); + setSavedPoses(nextPoses); + setSelectedPoseId(latestResult ? 'reverse-result' : preferredPose?.id ?? 'default'); + setSegmentationExportScope(latestResult?.segmentationScope ?? 'visible'); + setDisplayLevel(latestResult?.displayLevel ?? 'standard'); + setDicomOpacityLevel(latestResult?.dicomOpacityLevel ?? 'low'); + setMappingDisplayMode('soft'); + setMappingRotation(0); + setShowBounds(latestResult?.showBounds ?? true); + setCutEnabled(latestResult?.cutEnabled ?? false); + savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({ + modelPose: restoredPose, + segmentationExportScope: latestResult?.segmentationScope ?? 'visible', + moduleStyles: nextStyles, + sliceStart: restoredSliceStart, + sliceEnd: restoredSliceEnd, + mappingSlice: restoredMappingSlice, + displayLevel: latestResult?.displayLevel ?? 'standard', + dicomOpacityLevel: latestResult?.dicomOpacityLevel ?? 'low', + showBounds: latestResult?.showBounds ?? true, + cutEnabled: latestResult?.cutEnabled ?? false, + }); + }).catch(() => { + setProject(null); + setFusionVolume(null); + setWorkspaceLoadState({ + ready: false, + phase: '项目配置读取失败', + loaded: 0, + total: 1, + startedAt: Date.now(), + error: '项目配置读取失败', + }); + savedWorkspaceSnapshotRef.current = ''; + }); + }, [projectId]); + + useEffect(() => { + if (!project?.dicomCount) return; + const maxSlice = Math.max(project.dicomCount - 1, 0); + const safeStart = clamp(sliceStart, 0, maxSlice); + const safeEnd = clamp(sliceEnd, 0, maxSlice); + const timer = window.setTimeout(() => { + setFusionError(''); + loadFusionVolume(safeStart, safeEnd) + .then(setFusionVolume) + .catch((error) => { + setFusionVolume(null); + setFusionError(error instanceof Error ? error.message : 'DICOM 融合体加载失败'); + }); + }, 180); + return () => window.clearTimeout(timer); + }, [project?.id, project?.dicomCount, sliceStart, sliceEnd]); + + useEffect(() => () => { + if (poseRepeatRef.current.timeout !== null) { + window.clearTimeout(poseRepeatRef.current.timeout); + } + if (poseRepeatRef.current.interval !== null) { + window.clearInterval(poseRepeatRef.current.interval); + } + }, []); + + useEffect(() => { + setPoseValueDrafts((current) => { + const next = { ...current }; + modelPoseKeys.forEach((key) => { + if (focusedPoseInput !== key) { + next[key] = formatPoseValue(key, modelPose[key]); + } + }); + return next; + }); + }, [ + focusedPoseInput, + modelPose.rotateX, + modelPose.rotateY, + modelPose.rotateZ, + modelPose.translateX, + modelPose.translateY, + modelPose.translateZ, + modelPose.scale, + ]); + + const clampPoseValue = (key: ModelPoseKey, value: number) => { + const limit = poseStepConfig[key]; + const precision = getStepPrecision(limit.step); + return Number(clamp(value, limit.min, limit.max).toFixed(precision)); + }; + + const updateModelPose = (partial: Partial, options: { markCustom?: boolean; keepStatus?: boolean } = {}) => { + setModelPose((current) => { + const next = { ...current }; + modelPoseKeys.forEach((key) => { + const value = partial[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + next[key] = clampPoseValue(key, value); + } + }); + return next; + }); + if (options.markCustom !== false) { + setSelectedPoseId('custom'); + } + if (!options.keepStatus) { + setPoseImportStatus(''); + } + }; + + const restoreVisualToolbarScroll = (scrollTop: number | null) => { + if (scrollTop === null) { + return; + } + window.requestAnimationFrame(() => { + if (visualToolbarScrollRef.current) { + visualToolbarScrollRef.current.scrollTop = scrollTop; + } + }); + }; + + const nudgeModelPose = (key: ModelPoseKey, delta: number) => { + const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null; + setModelPose((current) => ({ + ...current, + [key]: clampPoseValue(key, current[key] + delta), + })); + setSelectedPoseId('custom'); + setPoseImportStatus(''); + restoreVisualToolbarScroll(scrollTop); + }; + + const handlePoseInputChange = (key: ModelPoseKey, value: string) => { + setPoseValueDrafts((current) => ({ ...current, [key]: value })); + if (!value.trim()) { + return; + } + + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return; + } + + updateModelPose({ [key]: numericValue } as Partial); + }; + + const commitPoseInputValue = (key: ModelPoseKey) => { + const draftValue = poseValueDrafts[key]; + const numericValue = draftValue.trim() ? Number(draftValue) : NaN; + if (Number.isFinite(numericValue)) { + const nextValue = clampPoseValue(key, numericValue); + if (Math.abs(nextValue - modelPose[key]) > 1e-9) { + updateModelPose({ [key]: nextValue } as Partial); + } + setPoseValueDrafts((current) => ({ ...current, [key]: formatPoseValue(key, nextValue) })); + } else { + setPoseValueDrafts((current) => ({ ...current, [key]: formatPoseValue(key, modelPose[key]) })); + } + setFocusedPoseInput(null); + }; + + const stopPoseRepeat = () => { + if (poseRepeatRef.current.timeout !== null) { + window.clearTimeout(poseRepeatRef.current.timeout); + poseRepeatRef.current.timeout = null; + } + if (poseRepeatRef.current.interval !== null) { + window.clearInterval(poseRepeatRef.current.interval); + poseRepeatRef.current.interval = null; + } + }; + + const startPoseRepeat = (key: ModelPoseKey, delta: number) => { + stopPoseRepeat(); + poseRepeatRef.current.timeout = window.setTimeout(() => { + nudgeModelPose(key, delta); + poseRepeatRef.current.interval = window.setInterval(() => nudgeModelPose(key, delta), 90); + }, 360); + }; + + const resetRotationPose = () => { + setModelPose((current) => ({ + ...current, + rotateX: 0, + rotateY: 0, + rotateZ: 0, + })); + setSelectedPoseId('custom'); + setPoseImportStatus(''); + }; + + const resetTransformPose = () => { + setModelPose((current) => ({ + ...current, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, + })); + setSelectedPoseId('custom'); + setPoseImportStatus(''); + }; + + const updateModuleStyle = (fileName: string, partial: Partial) => { + const stlFiles = project?.stlFiles ?? []; + const index = Math.max(0, stlFiles.indexOf(fileName)); + const next = { + ...moduleStyles, + [fileName]: makeDefaultModuleStyle(index, { + ...(moduleStyles[fileName] ?? project?.moduleStyles?.[fileName]), + ...partial, + }), + }; + commitModuleStyles(next); + }; + + const updateModulePartId = (fileName: string, value: number) => { + updateModuleStyle(fileName, { partId: clamp(Math.round(Number.isFinite(value) ? value : 1), 1, 255) }); + }; + + const commitSavedPoses = (next: SavedModelPose[]) => { + setSavedPoses(next); + if (!project) { + return; + } + api.updateProjectModelPoses(project.id, next) + .then((updated) => { + setProject(updated); + setSavedPoses(updated.modelPoses?.length ? updated.modelPoses : next); + }) + .catch(() => { + setFusionError('位姿保存失败,请稍后重试'); + }); + }; + + const saveCurrentPose = () => { + const nextPose = { + id: `pose-${Date.now()}`, + name: `位姿${savedPoses.length - 2}`, + pose: { ...modelPose }, + }; + commitSavedPoses([...savedPoses, nextPose]); + setSelectedPoseId(nextPose.id); + }; + + const renamePose = (poseId: string, name: string) => { + if (poseId === 'default') return; + const nextName = name.trim(); + commitSavedPoses(savedPoses.map((item) => ( + item.id === poseId ? { ...item, name: nextName || item.name } : item + ))); + }; + + const selectPose = (poseId: string) => { + const selected = savedPoses.find((item) => item.id === poseId); + if (!selected) return; + setSelectedPoseId(poseId); + setModelPose(selected.pose); + setPoseImportStatus(''); + }; + + const handleImportPoseFile = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) { + return; + } + + try { + const payload = JSON.parse(await file.text()) as unknown; + const { activePose, importedModelPoses } = parseImportedPosePayload(payload); + if (!activePose && !importedModelPoses?.length) { + throw new Error('未找到可用位姿数据'); + } + + const nextSavedPoses = importedModelPoses?.length + ? mergeImportedModelPoses(importedModelPoses) + : savedPoses; + if (importedModelPoses?.length) { + commitSavedPoses(nextSavedPoses); + } + + if (activePose) { + setModelPose(activePose); + setPoseValueDrafts(formatPoseDraftValues(activePose)); + const matchedPose = nextSavedPoses.find((item) => poseValuesMatch(item.pose, activePose)); + setSelectedPoseId(matchedPose?.id ?? 'custom'); + } + + setFusionError(''); + setPoseImportStatus(importedModelPoses?.length ? '位姿数据已导入并保存' : '当前位姿已导入'); + } catch (error) { + setPoseImportStatus(''); + setFusionError(error instanceof Error ? `位姿导入失败:${error.message}` : '位姿导入失败'); + } + }; + + const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0); + const safeSliceStart = clamp(sliceStart, 0, maxSlice); + const safeSliceEnd = clamp(sliceEnd, 0, maxSlice); + const safeMappingSlice = clamp(mappingSlice, 0, maxSlice); + const displayStart = Math.min(safeSliceStart, safeSliceEnd); + const displayEnd = Math.max(safeSliceStart, safeSliceEnd); + const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0; + const rangeEndPercent = maxSlice > 0 ? (displayEnd / maxSlice) * 100 : 0; + const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0]; + const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0]; + const stretchEnabled = Boolean(project && fusionVolume && isOrthogonalModelPose(modelPose)); + const workspaceProgress = workspaceLoadState.total > 0 + ? Math.round((workspaceLoadState.loaded / workspaceLoadState.total) * 100) + : 0; + const workspaceElapsedSeconds = Math.max((Date.now() - workspaceLoadState.startedAt) / 1000, 0.1); + const workspaceLoadSpeed = workspaceLoadState.loaded / workspaceElapsedSeconds; + + useEffect(() => { + if (!project?.dicomCount) { + return undefined; + } + if (workspaceLoadProjectRef.current === project.id) { + return undefined; + } + + let cancelled = false; + const stlFilesForLoad = project.stlFiles ?? []; + const fusionStart = Math.min(displayStart, displayEnd); + const fusionEnd = Math.max(displayStart, displayEnd); + const previewLimit = selectedDisplay.limit; + const mappingPreviewLimit = Math.max(previewLimit, 200000); + const total = 2 + stlFilesForLoad.length * 2; + const startedAt = Date.now(); + let loaded = 0; + + const updateLoadState = (phase: string, error = '') => { + if (cancelled) { + return; + } + setWorkspaceLoadState({ + ready: false, + phase, + loaded, + total, + startedAt, + error, + }); + }; + const markLoaded = (phase: string) => { + loaded += 1; + updateLoadState(phase); + }; + + updateLoadState('正在载入 DICOM 三维体与 STL 构件预览...'); + + const tasks: Array> = [ + getCachedDicomFusionVolume(project.id, fusionStart, fusionEnd, 'soft') + .then((volume) => { + if (!cancelled) { + setFusionVolume(volume); + } + markLoaded('DICOM 三维融合体已载入'); + }), + getCachedDicomPreview(project.id, safeMappingSlice, 'axial', mappingDisplayMode) + .then(() => markLoaded('DICOM 切片预览已载入')), + ...stlFilesForLoad.map((fileName) => ( + getCachedModelPreview(project.id, fileName, previewLimit) + .then(() => markLoaded(`三维模型预览已缓存:${fileName.replace(/\.stl$/i, '')}`)) + )), + ...stlFilesForLoad.map((fileName) => ( + getCachedModelPreview(project.id, fileName, mappingPreviewLimit) + .then(() => markLoaded(`二维映射网格已缓存:${fileName.replace(/\.stl$/i, '')}`)) + )), + ]; + + Promise.allSettled(tasks).then((results) => { + if (cancelled) { + return; + } + const fusionFailed = results[0]?.status === 'rejected'; + if (fusionFailed) { + setFusionVolume(null); + setWorkspaceLoadState({ + ready: false, + phase: 'DICOM 三维融合体载入失败', + loaded, + total, + startedAt, + error: 'DICOM 三维融合体载入失败,请检查数据或刷新页面重试。', + }); + return; + } + workspaceLoadProjectRef.current = project.id; + setWorkspaceLoadState({ + ready: true, + phase: '逆向工作区已就绪', + loaded: total, + total, + startedAt, + error: '', + }); + }); + + return () => { + cancelled = true; + }; + }, [ + project?.id, + project?.dicomCount, + project?.stlFiles?.join('|'), + displayStart, + displayEnd, + safeMappingSlice, + selectedDisplay.limit, + mappingDisplayMode, + ]); + + useEffect(() => { + if (!project || !fusionVolume || !workspaceLoadState.ready) { + return; + } + const stretchState = initialZStretchRef.current; + if (stretchState.projectId !== project.id || !stretchState.pending || !isOrthogonalModelPose(modelPose)) { + return; + } + initialZStretchRef.current = { projectId: project.id, pending: false }; + void applyModelStretchByAxis('z', { silentInitial: true }); + }, [ + project?.id, + fusionVolume, + workspaceLoadState.ready, + modelPose.rotateX, + modelPose.rotateY, + modelPose.rotateZ, + ]); + + if (!workspaceLoadState.ready) { + return ( +
+
+
+
+

Reverse Workspace

+

正在载入完整逆向工作区

+

+ DICOM 三维体、STL 构件预览和二维映射网格准备完成后再显示工作区。 +

+
+ + {Math.max(0, Math.min(100, workspaceProgress))}% + +
+
+
+
+
+
+ 当前阶段 + {workspaceLoadState.phase} +
+
+ 加载进度 + + {workspaceLoadState.loaded} / {workspaceLoadState.total} + +
+
+ 加载速度 + + {workspaceLoadSpeed.toFixed(1)} 项/秒 + +
+
+ {workspaceLoadState.error && ( +
+ {workspaceLoadState.error} +
+ )} +
+
+ ); + } + + return ( +
+ {saveStatus && ( + <> + +
+ {saveStatus} +
+ + )} +
+
+ {project && ( +
+ 当前项目:{project.name} + DICOM {project.dicomCount} + STL {project.modelCount ?? 0} +
+ )} + {!project &&

配准 DICOM 影像与三维模型,生成像素映射关系

} +
+
+ +
+ + {showExportMenu && ( +
+
+

导出内容

+ +
+
+ {exportOptions.map((option) => ( + + ))} +
+ {exportSelection.segmentation && ( +
+
+

分割类别范围

+ 附带 labels.json +
+
+ {segmentationScopeOptions.map((option) => ( + + ))} +
+
+

分割导出方式

+
+ {segmentationExportModeOptions.map((option) => ( + + ))} +
+
+
+ )} + +
+ )} +
+
+
+ +
+
+
+

+ + 影像与模型融合视角 +

+
+ + Layer: {displayStart + 1}-{displayEnd + 1}/{project?.dicomCount ?? 0} + +
+ + + 自动拉伸 + + {(['x', 'y', 'z'] as AxisKey[]).map((axis) => ( + + ))} +
+
+
+ + {project ? ( +
+ +
+ ) : ( +
+ 正在载入项目... +
+ )} + + {fusionError && ( +
+ + {fusionError} +
+ )} + +
+
+

DICOM 切片范围

+ + {displayStart + 1} - {displayEnd + 1} / {project?.dicomCount ?? 0} + +
+
+
+
+
+ setSliceStart(Number(event.target.value))} + className="dicom-range-input" + style={{ zIndex: safeSliceStart >= safeSliceEnd ? 5 : 4 }} + /> + setSliceEnd(Number(event.target.value))} + className="dicom-range-input" + style={{ zIndex: safeSliceStart >= safeSliceEnd ? 4 : 5 }} + /> +
+
+ 起点 {safeSliceStart + 1} + 范围 + 终点 {safeSliceEnd + 1} +
+
+
+
+ +
+
+

+ + 可视化工具栏 +

+
+ +
+
+
+

模型显示

+
+ {displayOptions.map((option) => ( + + ))} +
+
+ +
+

融合显示

+
+ {dicomOpacityOptions.map((option) => ( + + ))} +
+ +
+ +
+
+

模型切分

+ +
+

+ 按 DICOM 切片范围 {displayStart + 1}-{displayEnd + 1} 保留模型中间区域 +

+
+ +
+
+

模型位姿

+
+ + + +
+
+ + {selectedPoseId !== 'default' && selectedPoseId !== 'custom' && ( + item.id === selectedPoseId)?.name ?? ''} + onChange={(event) => renamePose(selectedPoseId, event.target.value)} + className="mb-2 h-8 w-full rounded-lg border border-slate-200 bg-white px-2 text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400" + placeholder="位姿名称" + /> + )} + {poseImportStatus && ( +

+ {poseImportStatus} +

+ )} +
+ + +
+
+ {[ + { key: 'rotateX' as const, label: '旋转 X', value: modelPose.rotateX }, + { key: 'rotateY' as const, label: '旋转 Y', value: modelPose.rotateY }, + { key: 'rotateZ' as const, label: '旋转 Z', value: modelPose.rotateZ }, + { key: 'translateX' as const, label: '平移 X', value: modelPose.translateX }, + { key: 'translateY' as const, label: '平移 Y', value: modelPose.translateY }, + { key: 'translateZ' as const, label: '平移 Z', value: modelPose.translateZ }, + { key: 'scale' as const, label: '缩放', value: modelPose.scale }, + ].map((item) => ( +
+ {item.label} + + updateModelPose({ [item.key]: Number(event.target.value) })} + className="accent-blue-600" + /> + + setFocusedPoseInput(item.key)} + onChange={(event) => handlePoseInputChange(item.key, event.target.value)} + onBlur={() => commitPoseInputValue(item.key)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + className="h-7 min-w-0 rounded-md border border-slate-200 bg-white px-1.5 text-right font-mono text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400 focus:bg-blue-50/40" + /> + {poseStepConfig[item.key].quick && ( +
+ + +
+ )} +
+ ))} +
+
+ +
+
+

构件层级

+ {project?.stlFiles?.length ?? 0} +
+
+ {(project?.stlFiles ?? []).map((fileName, index) => { + const style = moduleStyles[fileName] ?? { + visible: true, + color: moduleColors[index % moduleColors.length], + opacity: 0.72, + partId: index + 1, + }; + return ( +
+
+ updateModuleStyle(fileName, { color: event.target.value })} + className="h-7 w-7 shrink-0 rounded border border-white bg-white p-0.5" + title="模型颜色" + /> +
+

{fileName.replace(/\.stl$/i, '')}

+ +
+ +
+
+ 透明度 + updateModuleStyle(fileName, { opacity: Number(event.target.value) })} + className="min-w-0 flex-1 accent-blue-600" + /> + {Math.round(style.opacity * 100)}% +
+
+ ); + })} +
+
+
+
+
+ +
+
+ +
+ {mappingDisplayModes.map((mode) => ( + + ))} +
+ + + + )} + /> +
+
+
+
+ ); +} diff --git a/WebSite/src/components/Sidebar.tsx b/WebSite/src/components/Sidebar.tsx new file mode 100644 index 0000000..82c93c0 --- /dev/null +++ b/WebSite/src/components/Sidebar.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { motion } from 'motion/react'; +import { + BarChart3, + FolderRoot, + Workflow, + Settings, + LogOut, + ChevronLeft, + ChevronRight, + UserCircle +} from 'lucide-react'; +import { ViewType } from '../types'; +import { cn } from '../lib/utils'; + +interface SidebarProps { + activeView: ViewType; + setActiveView: (view: ViewType) => void; + onLogout: () => void; + collapsed: boolean; + setCollapsed: (collapsed: boolean) => void; +} + +export default function Sidebar({ + activeView, + setActiveView, + onLogout, + collapsed, + setCollapsed +}: SidebarProps) { + const menuItems = [ + { id: ViewType.OVERVIEW, icon: BarChart3, label: '总体概况' }, + { id: ViewType.PROJECTS, icon: FolderRoot, label: '项目库' }, + { id: ViewType.WORKSPACE, icon: Workflow, label: '逆向工作区' }, + { id: ViewType.SYSTEM, icon: Settings, label: '系统管理工作区' }, + ]; + + return ( + +
+
+ 模型逆向系统 +
+ {!collapsed && ( + +

模型逆向系统

+
+ )} +
+ + + +
+
+ + {!collapsed && ( +
+

Admin

+

管理员

+
+ )} +
+ + +
+ + +
+ ); +} diff --git a/WebSite/src/components/UserManagement.tsx b/WebSite/src/components/UserManagement.tsx new file mode 100644 index 0000000..0360a17 --- /dev/null +++ b/WebSite/src/components/UserManagement.tsx @@ -0,0 +1,447 @@ +import { useEffect, useMemo, useState } from 'react'; +import { motion } from 'motion/react'; +import { + Users, + UserPlus, + Search, + Shield, + Calendar, + RotateCcw, + Edit2, + Trash2, + Key, + X, +} from 'lucide-react'; +import { api } from '../lib/api'; +import { SessionState, UserRecord } from '../types'; + +type UserFormMode = 'create' | 'edit' | 'password'; + +interface UserFormState { + id?: number; + name: string; + account: string; + department: string; + password: string; + confirmPassword: string; +} + +const emptyUserForm: UserFormState = { + name: '', + account: '', + department: '', + password: '', + confirmPassword: '', +}; + +export default function UserManagement() { + const [users, setUsers] = useState([]); + const [session, setSession] = useState(null); + const [message, setMessage] = useState('登录、用户和项目状态由后端统一同步'); + const [resetting, setResetting] = useState(false); + const [search, setSearch] = useState(''); + const [formMode, setFormMode] = useState(null); + const [form, setForm] = useState(emptyUserForm); + const [saving, setSaving] = useState(false); + + const refreshUsers = async () => { + try { + const [items, currentSession] = await Promise.all([api.getUsers(), api.getSession()]); + setUsers(items); + setSession(currentSession); + } catch (error) { + setMessage(error instanceof Error ? error.message : '用户列表同步失败'); + } + }; + + useEffect(() => { + void refreshUsers(); + }, []); + + const filteredUsers = useMemo(() => { + const keyword = search.trim().toLowerCase(); + if (!keyword) { + return users; + } + return users.filter((user) => ( + user.name.toLowerCase().includes(keyword) + || user.account.toLowerCase().includes(keyword) + || user.department.toLowerCase().includes(keyword) + )); + }, [users, search]); + + const currentAccount = session?.currentUser?.account ?? ''; + + const openCreateForm = () => { + setForm(emptyUserForm); + setFormMode('create'); + setMessage('正在新增系统用户'); + }; + + const openEditForm = (user: UserRecord) => { + setForm({ + id: user.id, + name: user.name, + account: user.account, + department: user.department, + password: '', + confirmPassword: '', + }); + setFormMode('edit'); + setMessage(`正在编辑用户:${user.name}`); + }; + + const openPasswordForm = (user: UserRecord) => { + setForm({ + id: user.id, + name: user.name, + account: user.account, + department: user.department, + password: '', + confirmPassword: '', + }); + setFormMode('password'); + setMessage(`正在修改密码:${user.name}`); + }; + + const closeForm = () => { + setFormMode(null); + setForm(emptyUserForm); + setSaving(false); + }; + + const handleSaveUser = async () => { + if (!formMode) { + return; + } + const name = form.name.trim(); + const account = form.account.trim(); + const department = form.department.trim(); + const password = form.password.trim(); + const confirmPassword = form.confirmPassword.trim(); + + if (!name || !account || !department) { + setMessage('姓名、账号、科室不能为空'); + return; + } + if ((formMode === 'create' || formMode === 'password') && (!password || !confirmPassword)) { + setMessage('请输入两遍密码'); + return; + } + if ((formMode === 'create' || formMode === 'password') && password !== confirmPassword) { + setMessage('两次输入的密码不一致'); + return; + } + if ((formMode === 'create' || formMode === 'edit') && users.some((user) => user.id !== form.id && user.account === account)) { + setMessage('账号已存在,请更换账号'); + return; + } + + setSaving(true); + try { + if (formMode === 'create') { + await api.createUser({ name, account, department, password }); + setMessage(`已添加用户:${name}`); + } else if (form.id) { + await api.updateUser(form.id, { + name, + account, + department, + ...(formMode === 'password' ? { password } : {}), + }); + setMessage(formMode === 'password' ? `已更新 ${name} 的密码` : `已更新用户:${name}`); + } + closeForm(); + await refreshUsers(); + } catch (error) { + const message = error instanceof Error ? error.message : '用户保存失败'; + setMessage(message === '账号已存在' ? '账号已存在,请更换账号' : message); + setSaving(false); + } + }; + + const handleDeleteUser = async (user: UserRecord) => { + if (user.account === currentAccount) { + setMessage('不能删除当前登录用户'); + return; + } + const confirmed = window.confirm(`确认删除用户 ${user.name}?该操作不可恢复。`); + if (!confirmed) { + return; + } + try { + await api.deleteUser(user.id); + setMessage(`已删除用户:${user.name}`); + await refreshUsers(); + } catch (error) { + setMessage(error instanceof Error ? error.message : '删除用户失败'); + } + }; + + const handleReset = async () => { + setResetting(true); + setMessage('正在恢复演示环境...'); + try { + const result = await api.resetDemo(); + setUsers(result.users); + setSession(await api.getSession()); + setMessage('演示环境已恢复:默认用户、Head_CT_DICOM 与 Head_CT_ReConstruct 项目已重新载入'); + } catch (err) { + setMessage(err instanceof Error ? err.message : '恢复失败'); + } finally { + setResetting(false); + } + }; + + return ( +
+
+
+

系统管理工作区

+

{message}

+
+
+ + +
+
+ +
+
+
+ + setSearch(event.target.value)} + className="w-full rounded-xl border border-slate-200 py-2 pl-10 pr-4 text-sm outline-none transition-all focus:ring-2 focus:ring-blue-500" + /> +
+
+ + 当前账号:{currentAccount || '未同步'} +
+
+ +
+ + + + + + + + + + + + {filteredUsers.map((user, index) => { + const isCurrentUser = user.account === currentAccount; + return ( + + + + + + + + ); + })} + +
用户名账号所在科室注册日期操作
+
+
+ {user.name[0]} +
+
+

{user.name}

+ {isCurrentUser &&

当前登录

} +
+
+
{user.account} + + {user.department} + + +

+ + {user.date} +

+
+
+ +
+ + +
+
+
+ +
+

共 {filteredUsers.length} / {users.length} 条数据

+
+ + 用户管理操作实时写入后端状态 +
+
+
+ + {formMode && ( +
+
+
+

+ {formMode === 'create' && '添加用户'} + {formMode === 'edit' && '编辑用户'} + {formMode === 'password' && '修改密码'} +

+ +
+
+ {formMode !== 'password' ? ( + <> + + + + {formMode === 'create' && ( + <> + + + + )} + + ) : ( + <> +
+

当前修改对象

+
+ {form.name} + {form.account} + {form.department} +
+
+ + + + )} +
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/WebSite/src/index.css b/WebSite/src/index.css new file mode 100644 index 0000000..512b44b --- /dev/null +++ b/WebSite/src/index.css @@ -0,0 +1,242 @@ +@import "tailwindcss"; + +.dicom-range-input { + appearance: none; + -webkit-appearance: none; + background: transparent; + height: 100%; + inset: 0; + pointer-events: none; + position: absolute; + width: 100%; +} + +.dicom-range-input:focus { + outline: none; +} + +.dicom-range-input::-webkit-slider-runnable-track { + background: transparent; + border: 0; + height: 8px; +} + +.dicom-range-input::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + background: #2563eb; + border: 3px solid #ffffff; + border-radius: 9999px; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28); + cursor: grab; + height: 20px; + margin-top: -6px; + pointer-events: auto; + width: 20px; +} + +.dicom-range-input::-moz-range-track { + background: transparent; + border: 0; + height: 8px; +} + +.dicom-range-input::-moz-range-thumb { + background: #2563eb; + border: 3px solid #ffffff; + border-radius: 9999px; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28); + cursor: grab; + height: 14px; + pointer-events: auto; + width: 14px; +} + +.dicom-range-input:active::-webkit-slider-thumb { + cursor: grabbing; +} + +.dicom-range-input:active::-moz-range-thumb { + cursor: grabbing; +} + +.mapping-slice-input { + appearance: none; + -webkit-appearance: none; + background: transparent; + height: 100%; + inset: 0; + position: absolute; + width: 100%; +} + +.mapping-slice-input:focus { + outline: none; +} + +.mapping-slice-input::-webkit-slider-runnable-track { + background: transparent; + border: 0; + height: 8px; +} + +.mapping-slice-input::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + background: #22d3ee; + border: 3px solid #0f172a; + border-radius: 9999px; + box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45); + cursor: grab; + height: 22px; + margin-top: -7px; + width: 22px; +} + +.mapping-slice-input::-moz-range-track { + background: transparent; + border: 0; + height: 8px; +} + +.mapping-slice-input::-moz-range-thumb { + background: #22d3ee; + border: 3px solid #0f172a; + border-radius: 9999px; + box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45); + cursor: grab; + height: 16px; + width: 16px; +} + +.mapping-slice-input:active::-webkit-slider-thumb { + cursor: grabbing; +} + +.mapping-slice-input:active::-moz-range-thumb { + cursor: grabbing; +} + +.mapping-slice-vertical-input { + appearance: none; + -webkit-appearance: none; + background: transparent; + height: 100%; + left: 50%; + position: absolute; + top: 0; + transform: translateX(-50%); + width: 32px; + direction: rtl; + writing-mode: vertical-rl; +} + +.mapping-slice-vertical-input:focus { + outline: none; +} + +.mapping-slice-vertical-input::-webkit-slider-runnable-track { + background: transparent; + border: 0; + margin: 0 auto; + width: 8px; +} + +.mapping-slice-vertical-input::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + background: #2563eb; + border: 3px solid #ffffff; + border-radius: 9999px; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28); + cursor: grab; + height: 22px; + margin-left: -7px; + width: 22px; +} + +.mapping-slice-vertical-input::-moz-range-track { + background: transparent; + border: 0; + width: 8px; +} + +.mapping-slice-vertical-input::-moz-range-thumb { + background: #2563eb; + border: 3px solid #ffffff; + border-radius: 9999px; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28); + cursor: grab; + height: 16px; + width: 16px; +} + +.mapping-slice-vertical-input:active::-webkit-slider-thumb { + cursor: grabbing; +} + +.mapping-slice-vertical-input:active::-moz-range-thumb { + cursor: grabbing; +} + +.mapping-slice-dark-vertical-input { + appearance: none; + -webkit-appearance: none; + background: transparent; + height: 100%; + left: 50%; + position: absolute; + top: 0; + transform: translateX(-50%); + width: 30px; + direction: rtl; + writing-mode: vertical-rl; +} + +.mapping-slice-dark-vertical-input:focus { + outline: none; +} + +.mapping-slice-dark-vertical-input::-webkit-slider-runnable-track { + background: transparent; + border: 0; + margin: 0 auto; + width: 6px; +} + +.mapping-slice-dark-vertical-input::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + background: #22d3ee; + border: 3px solid #0f172a; + border-radius: 9999px; + box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45); + cursor: grab; + height: 20px; + margin-left: -7px; + width: 20px; +} + +.mapping-slice-dark-vertical-input::-moz-range-track { + background: transparent; + border: 0; + width: 6px; +} + +.mapping-slice-dark-vertical-input::-moz-range-thumb { + background: #22d3ee; + border: 3px solid #0f172a; + border-radius: 9999px; + box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45); + cursor: grab; + height: 14px; + width: 14px; +} + +.mapping-slice-dark-vertical-input:active::-webkit-slider-thumb { + cursor: grabbing; +} + +.mapping-slice-dark-vertical-input:active::-moz-range-thumb { + cursor: grabbing; +} diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts new file mode 100644 index 0000000..9ca1b12 --- /dev/null +++ b/WebSite/src/lib/api.ts @@ -0,0 +1,226 @@ +import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types'; + +export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl'; +export type SegmentationExportMode = 'combined' | 'separate'; +export type ProjectAssetImportKind = 'dicom' | 'stl'; +export type { SegmentationExportScope } from '../types'; + +export interface ProjectAssetImportProgress { + loaded: number; + total: number; + percent: number; +} + +async function request(path: string, options: RequestInit = {}): Promise { + const response = await fetch(path, { + headers: { + 'Content-Type': 'application/json', + ...(options.headers ?? {}), + }, + ...options, + }); + + if (!response.ok) { + let message = `请求失败:${response.status}`; + try { + const data = await response.json(); + if (typeof data?.message === 'string') { + message = data.message; + } + } catch { + // Keep the status-based message when the response is not JSON. + } + throw new Error(message); + } + + return response.json() as Promise; +} + +function parseXhrError(xhr: XMLHttpRequest) { + let message = `请求失败:${xhr.status}`; + try { + const data = JSON.parse(xhr.responseText); + if (typeof data?.message === 'string') { + message = data.message; + } + } catch { + if (xhr.responseText) { + message = xhr.responseText.slice(0, 240); + } + } + return message; +} + +function uploadProjectAssetFiles( + projectId: string, + kind: ProjectAssetImportKind, + files: File[], + onProgress?: (progress: ProjectAssetImportProgress) => void, +) { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('kind', kind); + files.forEach((file) => { + formData.append('files', file, file.name); + }); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', `/api/projects/${projectId}/import-assets`); + xhr.upload.onprogress = (event) => { + const total = event.lengthComputable ? event.total : files.reduce((sum, file) => sum + file.size, 0); + const loaded = event.lengthComputable ? event.loaded : Math.min(total, files.reduce((sum, file) => sum + file.size, 0)); + const percent = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0; + onProgress?.({ loaded, total, percent }); + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText) as Project); + } catch { + reject(new Error('导入响应解析失败')); + } + return; + } + reject(new Error(parseXhrError(xhr))); + }; + xhr.onerror = () => reject(new Error('网络连接中断,导入失败')); + xhr.onabort = () => reject(new Error('导入已取消')); + xhr.send(formData); + }); +} + +export const api = { + getSession: () => request('/api/session'), + login: (account: string, password: string) => + request('/api/login', { + method: 'POST', + body: JSON.stringify({ account, password }), + }), + logout: () => request('/api/logout', { method: 'POST' }), + getOverview: () => request('/api/overview'), + getProjects: () => request('/api/projects'), + getProject: (projectId: string) => request(`/api/projects/${projectId}`), + createProject: (name: string) => + request('/api/projects', { + method: 'POST', + body: JSON.stringify({ name }), + }), + renameProject: (projectId: string, name: string) => + request(`/api/projects/${projectId}`, { + method: 'PATCH', + body: JSON.stringify({ name }), + }), + deleteProject: (projectId: string) => + request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, { + method: 'DELETE', + }), + updateProjectModuleStyles: (projectId: string, moduleStyles: Record) => + request(`/api/projects/${projectId}/module-styles`, { + method: 'PATCH', + body: JSON.stringify({ moduleStyles }), + }), + updateProjectModelPoses: (projectId: string, modelPoses: SavedModelPose[]) => + request(`/api/projects/${projectId}/model-poses`, { + method: 'PATCH', + body: JSON.stringify({ modelPoses }), + }), + importProjectAssets: ( + projectId: string, + kind: ProjectAssetImportKind, + files: File[], + onProgress?: (progress: ProjectAssetImportProgress) => void, + ) => uploadProjectAssetFiles(projectId, kind, files, onProgress), + saveProjectSegmentationResult: ( + projectId: string, + payload: { + name?: string; + pose: ModelPose; + segmentationScope: SegmentationExportScope; + moduleStyles: Record; + sliceStart?: number; + sliceEnd?: number; + mappingSlice?: number; + displayLevel?: SegmentationDisplayLevel; + dicomOpacityLevel?: SegmentationDicomOpacityLevel; + showBounds?: boolean; + cutEnabled?: boolean; + }, + ) => + request(`/api/projects/${projectId}/segmentation-results`, { + method: 'POST', + body: JSON.stringify(payload), + }), + getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') => + request(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`), + getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') => + request(`/api/projects/${projectId}/dicom-fusion-volume?start=${start}&end=${end}&mode=${mode}`), + getDicomInfo: (projectId: string) => request(`/api/projects/${projectId}/dicom-info`), + getUsers: () => request('/api/users'), + createUser: (payload: { name: string; account: string; department: string; password: string }) => + request('/api/users', { + method: 'POST', + body: JSON.stringify(payload), + }), + updateUser: (userId: number, payload: { name: string; account: string; department: string; password?: string }) => + request(`/api/users/${userId}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }), + deleteUser: (userId: number) => + request<{ ok: boolean; deletedId: number }>(`/api/users/${userId}`, { + method: 'DELETE', + }), + resetDemo: () => + request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', { + method: 'POST', + }), +}; + +function triggerFileDownload(url: string) { + const link = document.createElement('a'); + link.href = url; + link.rel = 'noopener'; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +function appendPose(params: URLSearchParams, pose?: ModelPose) { + if (pose) { + params.set('pose', JSON.stringify(pose)); + } +} + +export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz', pose?: ModelPose, segmentationScope: SegmentationExportScope = 'visible') { + const params = new URLSearchParams({ format }); + appendPose(params, pose); + params.set('segmentationScope', segmentationScope); + triggerFileDownload(`/api/projects/${projectId}/export-mask?${params.toString()}`); +} + +export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) { + const params = new URLSearchParams({ target, format }); + if (target === 'segmentation' || target === 'pose') { + appendPose(params, options.pose); + } + if (target === 'segmentation') { + params.set('segmentationScope', options.segmentationScope ?? 'visible'); + params.set('segmentationExportMode', options.segmentationExportMode ?? 'combined'); + } + triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`); +} + +export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) { + const params = new URLSearchParams({ + targets: targets.join(','), + format, + segmentationScope: options.segmentationScope ?? 'visible', + segmentationExportMode: options.segmentationExportMode ?? 'combined', + }); + appendPose(params, options.pose); + triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`); +} + +export async function downloadDicomArchive(projectId: string) { + triggerFileDownload(`/api/projects/${projectId}/dicom-archive`); +} diff --git a/WebSite/src/lib/utils.ts b/WebSite/src/lib/utils.ts new file mode 100644 index 0000000..2819a83 --- /dev/null +++ b/WebSite/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/WebSite/src/main.tsx b/WebSite/src/main.tsx new file mode 100644 index 0000000..080dac3 --- /dev/null +++ b/WebSite/src/main.tsx @@ -0,0 +1,10 @@ +import {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts new file mode 100644 index 0000000..3bae540 --- /dev/null +++ b/WebSite/src/types.ts @@ -0,0 +1,201 @@ +export enum ViewType { + OVERVIEW = 'overview', + PROJECTS = 'projects', + WORKSPACE = 'workspace', + SYSTEM = 'system', +} + +export interface Project { + id: string; + name: string; + createTime: string; + status: 'pending' | 'completed' | 'processing'; + thumbnail?: string; + dicomCount: number; + hasModel: boolean; + dicomPath?: string; + modelPath?: string; + modelCount?: number; + stlFiles?: string[]; + maskFormats?: Array<'nii' | 'nii.gz'>; + exportedMaskCount?: number; + isDefault?: boolean; + moduleStyles?: Record; + modelPoses?: SavedModelPose[]; + segmentationResults?: SegmentationResult[]; +} + +export interface ModuleStyle { + visible: boolean; + color: string; + opacity: number; + partId: number; +} + +export interface ModelPose { + rotateX: number; + rotateY: number; + rotateZ: number; + translateX: number; + translateY: number; + translateZ: number; + scale: number; +} + +export interface SavedModelPose { + id: string; + name: string; + pose: ModelPose; +} + +export type SegmentationExportScope = 'all' | 'visible'; +export type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; +export type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high'; + +export interface SegmentationResult { + id: string; + schemaVersion?: number; + name: string; + createdAt: string; + segmentationScope: SegmentationExportScope; + pose: ModelPose; + moduleStyles: Record; + sliceStart?: number; + sliceEnd?: number; + mappingSlice?: number; + displayLevel?: SegmentationDisplayLevel; + dicomOpacityLevel?: SegmentationDicomOpacityLevel; + showBounds?: boolean; + cutEnabled?: boolean; +} + +export interface MaskMapping { + className: string; + color: string; + maskId: number; +} + +export interface UserRecord { + id: number; + name: string; + account: string; + department: string; + date: string; +} + +export interface SessionState { + authenticated: boolean; + currentUser: Omit | null; + lastUpdated: string; +} + +export interface OverviewSummary { + totalProjects: number; + processedProjects: number; + exportedMaskProjects: number; + dicomCount: number; + modelCount: number; + chartData: Array<{ + name: string; + projects: number; + processing: number; + }>; +} + +export interface DicomPreview { + width: number; + height: number; + pixels: string; + plane: 'axial' | 'sagittal' | 'coronal'; + mode: 'default' | 'bone' | 'soft' | 'contrast'; + slice: number; + total: number; + fileName: string; + windowCenter: number; + windowWidth: number; + spacing?: { + row: number; + column: number; + slice: number; + displayX?: number; + displayY?: number; + }; + physicalSize?: { + width: number; + height: number; + }; +} + +export interface DicomFusionVolume { + width: number; + height: number; + start: number; + end: number; + total: number; + indices: number[]; + frames: string[]; + mode: DicomPreview['mode']; + spacing: { + row: number; + column: number; + slice: number; + }; + physicalSize: { + width: number; + height: number; + depth: number; + unit: string; + }; +} + +export interface DicomInfo { + project: { + id: string; + name: string; + dicomPath: string; + }; + patient: { + name: string; + id: string; + }; + study: { + date: string; + description: string; + modality: string; + manufacturer: string; + }; + series: { + description: string; + files: number; + firstFile: string; + lastFile: string; + }; + image: { + rows: number; + columns: number; + bitsAllocated: number; + pixelRepresentation: number; + windowCenter: number; + windowWidth: number; + rescaleIntercept: number; + rescaleSlope: number; + }; + spacing: { + row: number | null; + column: number | null; + slice: number | null; + sliceSource: string; + sliceThickness: number | null; + spacingBetweenSlices: number | null; + }; + physicalSize: { + width: number | null; + height: number | null; + depth: number | null; + unit: string; + }; + position: { + firstImagePosition: number[] | null; + lastImagePosition: number[] | null; + }; +} diff --git a/WebSite/tsconfig.json b/WebSite/tsconfig.json new file mode 100644 index 0000000..d88f175 --- /dev/null +++ b/WebSite/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} diff --git a/WebSite/vite.config.ts b/WebSite/vite.config.ts new file mode 100644 index 0000000..0506f1b --- /dev/null +++ b/WebSite/vite.config.ts @@ -0,0 +1,24 @@ +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import {defineConfig, loadEnv} from 'vite'; + +export default defineConfig(({mode}) => { + const env = loadEnv(mode, '.', ''); + return { + plugins: [react(), tailwindcss()], + define: { + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, + server: { + // HMR is disabled in AI Studio via DISABLE_HMR env var. + // Do not modify—file watching is disabled to prevent flickering during agent edits. + hmr: process.env.DISABLE_HMR !== 'true', + }, + }; +}); diff --git a/docker_compose.nas.yaml b/docker_compose.nas.yaml new file mode 100644 index 0000000..2fc5acc --- /dev/null +++ b/docker_compose.nas.yaml @@ -0,0 +1,68 @@ +# ReVoxelSeg DICOM / QNAP QTS Container Station 独立部署版。 +# 建议完整目录放到:/share/Container/revoxelseg_dicom +# Container Station 可能不在项目根目录执行 Compose,因此这里使用绝对路径。 + +name: revoxelseg-dicom-qnap-standalone + +services: + revoxelseg_web: + image: revoxelseg-dicom:standalone-qnap-20260521 + build: + context: /share/Container/revoxelseg_dicom + dockerfile: Dockerfile + args: + HTTP_PROXY: http://192.168.31.7:7893 + HTTPS_PROXY: http://192.168.31.7:7893 + NO_PROXY: localhost,127.0.0.1,192.168.31.0/24,192.168.3.0/24,revoxelseg_web,revoxelseg_frpc + restart: unless-stopped + ports: + - "4000:4000" + environment: + NODE_ENV: production + PORT: 4000 + APP_URL: https://revoxel.huijutec.cn + TRUST_PROXY: "true" + HTTP_PROXY: http://192.168.31.7:7893 + HTTPS_PROXY: http://192.168.31.7:7893 + http_proxy: http://192.168.31.7:7893 + https_proxy: http://192.168.31.7:7893 + NO_PROXY: localhost,127.0.0.1,192.168.31.0/24,192.168.3.0/24,revoxelseg_web,revoxelseg_frpc + volumes: + - /share/Container/revoxelseg_dicom/data:/app/WebSite/data + - /share/Container/revoxelseg_dicom/exports:/app/WebSite/exports + healthcheck: + test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + revoxelseg_frpc: + image: snowdreamtech/frpc:latest + restart: unless-stopped + entrypoint: ["/bin/sh"] + command: + - -c + - | + cat > /tmp/frpc.toml <<'EOF' + serverAddr = "82.157.255.195" + serverPort = 7000 + + auth.method = "token" + auth.token = "en.xjtu.edu.cn" + + transport.poolCount = 5 + transport.heartbeatTimeout = -1 + + [[proxies]] + name = "ReVoxelSeg_DICOM" + type = "tcp" + localIP = "revoxelseg_web" + localPort = 4000 + remotePort = 10008 + EOF + exec frpc -c /tmp/frpc.toml + depends_on: + revoxelseg_web: + condition: service_healthy + diff --git a/docker_compose.yaml b/docker_compose.yaml new file mode 100644 index 0000000..f81bcfe --- /dev/null +++ b/docker_compose.yaml @@ -0,0 +1,60 @@ +# ReVoxelSeg DICOM 独立 Docker 本机部署版。 +# 在本目录执行:docker compose -f docker_compose.yaml up -d --build +# 局域网访问:http://192.168.3.11:4000/ +# 公网访问:https://revoxel.huijutec.cn/ + +name: revoxelseg-dicom-standalone + +services: + revoxelseg_web: + image: revoxelseg-dicom:standalone-20260521 + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "4000:4000" + environment: + NODE_ENV: production + PORT: 4000 + APP_URL: http://192.168.3.11:4000 + TRUST_PROXY: "true" + volumes: + - ./data:/app/WebSite/data + - ./exports:/app/WebSite/exports + healthcheck: + test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + revoxelseg_frpc: + image: snowdreamtech/frpc:latest + restart: unless-stopped + entrypoint: ["/bin/sh"] + command: + - -c + - | + cat > /tmp/frpc.toml <<'EOF' + serverAddr = "82.157.255.195" + serverPort = 7000 + + auth.method = "token" + auth.token = "en.xjtu.edu.cn" + + transport.poolCount = 5 + transport.heartbeatTimeout = -1 + + [[proxies]] + name = "ReVoxelSeg_DICOM" + type = "tcp" + localIP = "revoxelseg_web" + localPort = 4000 + remotePort = 10008 + EOF + exec frpc -c /tmp/frpc.toml + depends_on: + revoxelseg_web: + condition: service_healthy +