diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..af5c326 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +**/node_modules +**/dist +packages/backend/public +.git +.env +.env.* +*.log +.DS_Store +.cursor +.tmp +tmp diff --git a/Dockerfile b/Dockerfile index faaa383..65dfa07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ # Build frontend + backend FROM node:22-bookworm-slim AS build WORKDIR /app +# CAD viewer + Online3DViewer + Mermaid/Excalidraw make Vite chunking +# exceed the default ~2GB heap inside slim Docker builds. +ENV NODE_OPTIONS=--max-old-space-size=8192 COPY package.json package-lock.json* ./ COPY packages/backend/package.json packages/backend/ diff --git a/package-lock.json b/package-lock.json index 930faee..0ac2f64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2866,6 +2866,16 @@ "resolved": "packages/frontend", "link": true }, + "node_modules/@simonwep/pickr": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.9.0.tgz", + "integrity": "sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==", + "license": "MIT", + "dependencies": { + "core-js": "3.32.2", + "nanopop": "2.3.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4107,6 +4117,17 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.2.tgz", + "integrity": "sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4785,6 +4806,12 @@ "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", @@ -5237,6 +5264,14 @@ "node": ">= 0.4" } }, + "node_modules/glm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glm/-/glm-1.0.0.tgz", + "integrity": "sha512-9/ubsXH/3w4pWunc1b3iVY9O/avtwt0kiEM07lydjASWI6CC4oFJHPURlpn8KuluDRlSHwNH6FlHFc5P1SBiJQ==", + "engines": { + "node": "*" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -5977,6 +6012,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanopop": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.3.0.tgz", + "integrity": "sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -6022,12 +6063,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/occt-import-js": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/occt-import-js/-/occt-import-js-0.0.23.tgz", - "integrity": "sha512-RFfYQXYFX5C1mB1Aywm0ShcUKzXOr/VzTnlzhBSDJOR6YCAPt1HYCzeXWg1vwwjn/cUxwqRNhhtf1dlewoZYCQ==", - "license": "LGPL-2.1" - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6040,6 +6075,29 @@ "node": ">= 0.8" } }, + "node_modules/online-3d-viewer": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/online-3d-viewer/-/online-3d-viewer-0.18.0.tgz", + "integrity": "sha512-y7ZlV/zkakNUyjqcXz6XecA7vXgLEUnaAey9tyx8o6/wcdV64RfjXAQOjGXGY2JOZoDi4Cg1ic9icSWMWAvRQA==", + "license": "MIT", + "dependencies": { + "@simonwep/pickr": "1.9.0", + "fflate": "0.8.2", + "three": "0.176.0" + } + }, + "node_modules/online-3d-viewer/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/online-3d-viewer/node_modules/three": { + "version": "0.176.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.176.0.tgz", + "integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==", + "license": "MIT" + }, "node_modules/open-color": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz", @@ -6422,6 +6480,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/rhino3dm": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/rhino3dm/-/rhino3dm-8.17.0.tgz", + "integrity": "sha512-6m3nE/DUwu8vgc4Y7ffmLvV+WvUgjGYAf68q5WAn/CJZvboAPRlU6wTM7PpPU+wxiKfyfkOKKEkXVyPGIQyGeg==", + "license": "MIT" + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -7755,12 +7819,6 @@ "typescript": ">=5.0.0" } }, - "node_modules/web-ifc": { - "version": "0.0.77", - "resolved": "https://registry.npmjs.org/web-ifc/-/web-ifc-0.0.77.tgz", - "integrity": "sha512-VzQ0W/Iiqbidxn1ECUvz6qJ6p2sXBVNcOsUOBCETzy77psAH6yFLKQm74aXabkx3JH4OvFVHe8k1qS6+Z2zl1w==", - "license": "MPL-2.0" - }, "node_modules/webworkify": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz", @@ -7869,17 +7927,21 @@ "@mlightcad/dxf-json-converter": "^1.11.2", "@rhwp/editor": "^0.7.18", "@vueuse/core": "^11.3.0", + "draco3d": "^1.5.7", "element-plus": "^2.14.3", + "fflate": "^0.8.2", "lodash-es": "^4.17.21", "mermaid": "^11.16.0", - "occt-import-js": "^0.0.23", + "occt-import-js": "^0.0.22", + "online-3d-viewer": "^0.18.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "rhino3dm": "^8.17.0", "three": "^0.172.0", "vue": "^3.5.13", "vue-i18n": "^11.4.7", "vue-router": "^4.5.0", - "web-ifc": "^0.0.77" + "web-ifc": "^0.0.68" }, "devDependencies": { "@fontsource-variable/noto-sans-kr": "^5.3.0", @@ -7892,6 +7954,26 @@ "vite": "^6.2.2", "vue-tsc": "^2.2.8" } + }, + "packages/frontend/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" + }, + "packages/frontend/node_modules/occt-import-js": { + "version": "0.0.22", + "resolved": "https://registry.npmjs.org/occt-import-js/-/occt-import-js-0.0.22.tgz", + "integrity": "sha512-pqvP0hJGMgAoFKji+nL+2zIHJ2NJeFTpI2oEEJrVQtvLErDApd2TeAJNhPO9oU+aWUKIUSWoxSfIrF0KisrPtw==", + "license": "LGPL-2.1" + }, + "packages/frontend/node_modules/web-ifc": { + "version": "0.0.68", + "resolved": "https://registry.npmjs.org/web-ifc/-/web-ifc-0.0.68.tgz", + "integrity": "sha512-SqAmmwpultii8lQZoFvn2vnusW4tRVaed8GJNGuh8EenFMPe4x0g6NIXGu+5eBrXsiV+AaOdwjmOKTHEGXM4Gg==", + "dependencies": { + "glm": "^1.0.0" + } } } } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 8825b4f..777b37c 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -25,17 +25,21 @@ "@mlightcad/dxf-json-converter": "^1.11.2", "@rhwp/editor": "^0.7.18", "@vueuse/core": "^11.3.0", + "draco3d": "^1.5.7", "element-plus": "^2.14.3", + "fflate": "^0.8.2", "lodash-es": "^4.17.21", "mermaid": "^11.16.0", - "occt-import-js": "^0.0.23", + "occt-import-js": "^0.0.22", + "online-3d-viewer": "^0.18.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "rhino3dm": "^8.17.0", "three": "^0.172.0", "vue": "^3.5.13", "vue-i18n": "^11.4.7", "vue-router": "^4.5.0", - "web-ifc": "^0.0.77" + "web-ifc": "^0.0.68" }, "devDependencies": { "@fontsource-variable/noto-sans-kr": "^5.3.0", diff --git a/packages/frontend/scripts/copy-viewer-assets.mjs b/packages/frontend/scripts/copy-viewer-assets.mjs index 517ee7f..b4fb42f 100644 --- a/packages/frontend/scripts/copy-viewer-assets.mjs +++ b/packages/frontend/scripts/copy-viewer-assets.mjs @@ -1,4 +1,4 @@ -import { cp, mkdir, writeFile } from 'node:fs/promises'; +import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -7,78 +7,169 @@ const frontendDir = path.resolve( '..', ); const rootDir = path.resolve(frontendDir, '../..'); -const nodeModules = path.join(rootDir, 'node_modules'); const publicDir = path.join(frontendDir, 'public'); -const assets = [ +function resolvePackage(...parts) { + const candidates = [ + path.join(frontendDir, 'node_modules', ...parts), + path.join(rootDir, 'node_modules', ...parts), + ]; + return candidates; +} + +async function copyFirst(sources, destination) { + const output = path.join(publicDir, destination); + await mkdir(path.dirname(output), { recursive: true }); + let lastError; + for (const source of sources) { + try { + await cp(source, output); + return output; + } catch (error) { + lastError = error; + } + } + throw lastError ?? new Error(`Missing asset for ${destination}`); +} + +const cadAssets = [ [ - '@mlightcad/cad-simple-viewer/dist/dxf-parser-worker.js', + resolvePackage('@mlightcad/cad-simple-viewer/dist/dxf-parser-worker.js'), 'assets/dxf-parser-worker.js', ], [ - '@mlightcad/cad-simple-viewer/dist/libredwg-parser-worker.js', + resolvePackage( + '@mlightcad/cad-simple-viewer/dist/libredwg-parser-worker.js', + ), 'assets/libredwg-parser-worker.js', ], [ - '@mlightcad/cad-simple-viewer/dist/mtext-renderer-worker.js', + resolvePackage( + '@mlightcad/cad-simple-viewer/dist/mtext-renderer-worker.js', + ), 'assets/mtext-renderer-worker.js', ], [ - 'occt-import-js/dist/occt-import-js.wasm', - 'viewer-assets/wasm/occt-import-js.wasm', - ], - ['web-ifc/web-ifc.wasm', 'viewer-assets/wasm/web-ifc.wasm'], - [ - '@noto-pdf-ts/fonts-kr/dist/fonts/NotoSansKR-VF.ttf', + resolvePackage('@noto-pdf-ts/fonts-kr/dist/fonts/NotoSansKR-VF.ttf'), 'viewer-assets/cad-resources/fonts/NotoSansKR-VF.ttf', ], [ - '@fontsource-variable/noto-sans-kr/LICENSE', + resolvePackage('@fontsource-variable/noto-sans-kr/LICENSE'), 'viewer-assets/cad-resources/fonts/OFL-1.1.txt', ], ]; +const o3dvAssets = [ + [ + resolvePackage('rhino3dm/rhino3dm.min.js'), + 'viewer-assets/o3dv/rhino3dm.min.js', + ], + [ + resolvePackage('rhino3dm/rhino3dm.wasm'), + 'viewer-assets/o3dv/rhino3dm.wasm', + ], + [ + resolvePackage('web-ifc/web-ifc-api-iife.js'), + 'viewer-assets/o3dv/web-ifc-api-iife.js', + ], + [resolvePackage('web-ifc/web-ifc.wasm'), 'viewer-assets/o3dv/web-ifc.wasm'], + [ + resolvePackage('draco3d/draco_decoder_nodejs.js'), + 'viewer-assets/o3dv/draco_decoder_nodejs.min.js', + ], + [ + resolvePackage('occt-import-js/dist/occt-import-js.js'), + 'viewer-assets/o3dv/occt-import-js.js', + ], + [ + resolvePackage('occt-import-js/dist/occt-import-js-worker.js'), + 'viewer-assets/o3dv/occt-import-js-worker.js', + ], + [ + resolvePackage('occt-import-js/dist/occt-import-js.wasm'), + 'viewer-assets/o3dv/occt-import-js.wasm', + ], + [ + resolvePackage('occt-import-js/dist/license.occt-import-js.txt'), + 'viewer-assets/o3dv/licenses/license.occt-import-js.txt', + ], + [ + resolvePackage('occt-import-js/dist/license.occt.txt'), + 'viewer-assets/o3dv/licenses/license.occt.txt', + ], + [ + resolvePackage('online-3d-viewer/LICENSE.md'), + 'viewer-assets/o3dv/licenses/online-3d-viewer.LICENSE.md', + ], + [ + resolvePackage('rhino3dm/README.md'), + 'viewer-assets/o3dv/licenses/rhino3dm.README.md', + ], +]; + await Promise.all( - assets.map(async ([source, destination]) => { - const output = path.join(publicDir, destination); - await mkdir(path.dirname(output), { recursive: true }); - await cp(path.join(nodeModules, source), output); - }), + [...cadAssets, ...o3dvAssets].map(([sources, destination]) => + copyFirst(sources, destination), + ), ); +const envmapCandidates = resolvePackage( + 'online-3d-viewer/website/assets/envmaps/fishermans_bastion', +); +const envmapDest = path.join( + publicDir, + 'viewer-assets/o3dv/envmaps/fishermans_bastion', +); +await mkdir(path.dirname(envmapDest), { recursive: true }); +{ + let copied = false; + for (const candidate of envmapCandidates) { + try { + await cp(candidate, envmapDest, { recursive: true }); + copied = true; + break; + } catch { + // try next + } + } + if (!copied) { + throw new Error('Missing Online3DViewer envmap assets'); + } +} + const resources = path.join(publicDir, 'viewer-assets', 'cad-resources'); await mkdir(path.join(resources, 'fonts'), { recursive: true }); await mkdir(path.join(resources, 'templates'), { recursive: true }); const fontManifest = `${JSON.stringify( - [ - { - name: [ - 'Noto Sans KR', - 'arial', - 'batang', - 'dotum', - 'ghs', - 'gulim', - 'hztxt', - 'isocpeur', - 'malgun', - 'malgun gothic', - 'pmingliu', - 'romans', - 'sanss___', - 'simplex', - 'swisscbo', - 'whgtxt', - 'whs', - ], - file: 'NotoSansKR-VF.ttf', - type: 'mesh', - description: 'Open-source Korean CAD fallback (SIL OFL 1.1)', - }, - ], - null, - 2, - )}\n`; + [ + { + name: [ + 'Noto Sans KR', + 'arial', + 'batang', + 'dotum', + 'ghs', + 'gulim', + 'hztxt', + 'isocpeur', + 'malgun', + 'malgun gothic', + 'pmingliu', + 'romans', + 'sanss___', + 'simplex', + 'swisscbo', + 'whgtxt', + 'whs', + ], + file: 'NotoSansKR-VF.ttf', + type: 'mesh', + description: 'Open-source Korean CAD fallback (SIL OFL 1.1)', + }, + ], + null, + 2, +)}\n`; await Promise.all([ writeFile(path.join(resources, 'fonts.json'), fontManifest), writeFile(path.join(resources, 'fonts', 'fonts.json'), fontManifest), @@ -92,3 +183,58 @@ await writeFile( '', ].join('\n'), ); + +await writeFile( + path.join(publicDir, 'viewer-assets/o3dv/README.txt'), + [ + 'Self-hosted Online3DViewer external libraries.', + 'CDN URLs in online-3d-viewer are rewritten to /viewer-assets/o3dv/.', + '', + ].join('\n'), +); + +const O3DV_BASE = '/viewer-assets/o3dv/'; +const replacements = [ + [ + 'https://cdn.jsdelivr.net/npm/occt-import-js@0.0.22/dist/', + O3DV_BASE, + ], + [ + 'https://cdn.jsdelivr.net/npm/rhino3dm@8.17.0/rhino3dm.min.js', + `${O3DV_BASE}rhino3dm.min.js`, + ], + [ + 'https://cdn.jsdelivr.net/npm/web-ifc@0.0.68/web-ifc-api-iife.js', + `${O3DV_BASE}web-ifc-api-iife.js`, + ], + [ + 'https://cdn.jsdelivr.net/npm/draco3d@1.5.7/draco_decoder_nodejs.min.js', + `${O3DV_BASE}draco_decoder_nodejs.min.js`, + ], +]; + +async function patchO3dvFile(relativePath) { + for (const candidate of resolvePackage(relativePath)) { + try { + let source = await readFile(candidate, 'utf8'); + let changed = false; + for (const [from, to] of replacements) { + if (source.includes(from)) { + source = source.split(from).join(to); + changed = true; + } + } + if (changed) { + await writeFile(candidate, source); + console.log(`patched ${candidate}`); + } + return; + } catch { + // try next path + } + } + throw new Error(`Could not patch ${relativePath}`); +} + +await patchO3dvFile('online-3d-viewer/build/engine/o3dv.module.js'); +await patchO3dvFile('online-3d-viewer/build/engine/o3dv.min.js'); diff --git a/packages/frontend/src/components/model3d/Model3DCanvas.vue b/packages/frontend/src/components/model3d/Model3DCanvas.vue index c273398..81c6351 100644 --- a/packages/frontend/src/components/model3d/Model3DCanvas.vue +++ b/packages/frontend/src/components/model3d/Model3DCanvas.vue @@ -1,167 +1,306 @@