From b87b1f999d9dfa65f1c89af727edf9196726b29f Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 29 Jun 2026 08:49:53 -0400 Subject: [PATCH] feat(editor): floorplan export (multi-page PDF, per level) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Floorplan group to the settings Export panel alongside the 3D model exports, with "Full floorplan" and "Structure only" buttons. Export re-runs the live registry-driven floorplan pipeline (def.floorplan -> FloorplanGeometryRenderer) headlessly with a neutral viewState, fits each level to its own page, and titles each page with the level label. Every level of the active building becomes a page in one landscape A4 PDF. "Structure only" keeps category === 'structure' nodes; "Full" keeps every visible node with a floorplan builder. - new lib/floorplan/floorplan-export.tsx; jsPDF + svg2pdf dynamically imported so they only load on export - export five pure helpers from floorplan-registry-layer for reuse (buildContext, getFloorplanLevelData, floorplanLayerRank, splitFloorplanOverlay, isFloorplanNodeVisible) — no behaviour change - bake vector-effect:non-scaling-stroke widths into real units before svg2pdf (which ignores the hint and would otherwise draw door/window/ stair linework as metre-wide strokes) Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 54 +++ packages/editor/package.json | 6 +- .../renderers/floorplan-registry-layer.tsx | 10 +- .../sidebar/panels/settings-panel/index.tsx | 77 ++-- .../src/lib/floorplan/floorplan-export.tsx | 348 ++++++++++++++++++ 5 files changed, 462 insertions(+), 33 deletions(-) create mode 100644 packages/editor/src/lib/floorplan/floorplan-export.tsx diff --git a/bun.lock b/bun.lock index b100cd22d..3eb9cb4e4 100644 --- a/bun.lock +++ b/bun.lock @@ -147,10 +147,12 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "howler": "^2.2.4", + "jspdf": "^4.2.1", "lucide-react": "^1.7.0", "mitt": "^3.0.1", "motion": "^12.34.3", "nanoid": "^5.1.6", + "svg2pdf.js": "^2.7.0", "tailwind-merge": "^3.5.0", "three-mesh-bvh": "~0.9.8", "zod": "^4.3.6", @@ -857,6 +859,10 @@ "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], + "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], + + "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -867,6 +873,8 @@ "@types/three": ["@types/three@0.184.1", "", { "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" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -961,6 +969,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="], @@ -995,6 +1005,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], + "canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], @@ -1035,12 +1047,18 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -1075,6 +1093,8 @@ "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], + "dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -1173,6 +1193,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -1201,6 +1223,8 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "font-family-papandreou": ["font-family-papandreou@0.2.0-patch2", "", {}, "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], @@ -1269,6 +1293,8 @@ "howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -1295,6 +1321,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1385,6 +1413,8 @@ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="], + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -1541,6 +1571,8 @@ "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -1557,6 +1589,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -1589,6 +1623,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -1615,6 +1651,8 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1629,6 +1667,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -1677,6 +1717,10 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "specificity": ["specificity@0.4.1", "", { "bin": { "specificity": "./bin/specificity" } }, "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg=="], + + "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="], + "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], @@ -1715,6 +1759,12 @@ "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="], + "svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="], + + "svg2pdf.js": ["svg2pdf.js@2.7.0", "", { "dependencies": { "cssesc": "^3.0.0", "font-family-papandreou": "^0.2.0-patch1", "specificity": "^0.4.1", "svgpath": "^2.3.0" }, "peerDependencies": { "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" } }, "sha512-nXK4Wx28H0KtOktanm5nsphl1KMEoLNMelAT/776qxPAj9DshwYcqgdpKuBnY1nrcYOriQFHVQLE4tIag+aDJA=="], + + "svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -1723,6 +1773,8 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="], "three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="], @@ -1799,6 +1851,8 @@ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], diff --git a/packages/editor/package.json b/packages/editor/package.json index 56a41681b..02ead6068 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -43,14 +43,16 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "howler": "^2.2.4", + "jspdf": "^4.2.1", "lucide-react": "^1.7.0", "mitt": "^3.0.1", "motion": "^12.34.3", "nanoid": "^5.1.6", + "svg2pdf.js": "^2.7.0", "tailwind-merge": "^3.5.0", + "three-mesh-bvh": "~0.9.8", "zod": "^4.3.6", - "zustand": "^5.0.11", - "three-mesh-bvh": "~0.9.8" + "zustand": "^5.0.11" }, "devDependencies": { "@pascal-app/core": "^0.9.1", diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 3d120ce74..12b34276e 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -1488,7 +1488,7 @@ function buildFloorplanEntryGeometry({ return entry } -function getFloorplanLevelData( +export function getFloorplanLevelData( type: string, nodes: Record, liveOverrides: Map, @@ -2426,7 +2426,7 @@ function applyPositionLiveTransform( } as AnyNode } -function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean { +export function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean { const overrideVisible = liveOverride?.visible if (typeof overrideVisible === 'boolean') return overrideVisible return (node as { visible?: boolean }).visible !== false @@ -2452,7 +2452,7 @@ function isFloorplanHierarchyVisible( return true } -function buildContext( +export function buildContext( node: AnyNode, nodes: Record, viewState: { @@ -2560,7 +2560,7 @@ const OVERLAY_KINDS = new Set([ * / translations apply in both passes. Empty groups collapse to `null` * so the caller can skip emitting an `` when there's nothing to draw. */ -function splitFloorplanOverlay(g: FloorplanGeometry): { +export function splitFloorplanOverlay(g: FloorplanGeometry): { base: FloorplanGeometry | null overlay: FloorplanGeometry | null } { @@ -2726,7 +2726,7 @@ function depsValueEqual(a: unknown, b: unknown): boolean { * Sort is stable in modern JS engines, so siblings within the same * bucket keep their DFS order (= scene tree order). */ -function floorplanLayerRank(type: string): number { +export function floorplanLayerRank(type: string): number { switch (type) { case 'zone': return 0 diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 3d2163bc4..e559ab394 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -1,7 +1,7 @@ import { emitter, useScene, validateBuildJson } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { TreeView, VisualJson } from '@visual-json/react' -import { Camera, Download, Save, Trash2, Upload } from 'lucide-react' +import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react' import { type KeyboardEvent, type SyntheticEvent, @@ -10,6 +10,7 @@ import { useRef, useState, } from 'react' +import { exportFloorplanPdf } from '../../../../../lib/floorplan/floorplan-export' import { Button } from './../../../../../components/ui/primitives/button' import { Dialog, @@ -344,32 +345,56 @@ export function SettingsPanel({ )} {/* Export Section */} -
+
- - - + +
+
3D model
+ + + +
+ +
+
Floorplan
+ + +
{/* Thumbnail Section (only for cloud projects) */} diff --git a/packages/editor/src/lib/floorplan/floorplan-export.tsx b/packages/editor/src/lib/floorplan/floorplan-export.tsx new file mode 100644 index 000000000..8c9ff4e66 --- /dev/null +++ b/packages/editor/src/lib/floorplan/floorplan-export.tsx @@ -0,0 +1,348 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type FloorplanGeometry, + type LiveNodeOverrides, + nodeRegistry, + resolveBuildingForLevel, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { createElement } from 'react' +import { flushSync } from 'react-dom' +import { createRoot } from 'react-dom/client' +import { FloorplanGeometryRenderer } from '../../components/editor-2d/renderers/floorplan-geometry-renderer' +import { + buildContext, + floorplanLayerRank, + getFloorplanLevelData, + isFloorplanNodeVisible, + splitFloorplanOverlay, +} from '../../components/editor-2d/renderers/floorplan-registry-layer' + +/** + * Floorplan PDF export. + * + * Re-runs the same registry-driven geometry pipeline the live 2D layer uses + * (`def.floorplan(node, ctx)` → `FloorplanGeometryRenderer`) headlessly, with + * a neutral `viewState` so nodes render in their default, unselected form. + * Every level of the active building becomes its own page, titled with the + * level's label, with the plan fit to the page (independent of the live + * pan/zoom). jsPDF + svg2pdf are dynamically imported so they only load when + * an export actually runs. + * + * `scope: 'structure'` keeps only `category === 'structure'` nodes (walls, + * slabs, ceilings, doors, windows, stairs, columns, roofs…); `'full'` keeps + * every node that has a floorplan builder and is visible. + */ +export type FloorplanExportScope = 'full' | 'structure' + +const SVG_NS = 'http://www.w3.org/2000/svg' +/** Meters of margin around the plan bounds. */ +const PADDING_M = 1 +/** PDF page margin + title band, in pt. */ +const PAGE_MARGIN_PT = 36 +const TITLE_BAND_PT = 28 + +// Neutral view state — no selection / hover / palette, so builders emit their +// default appearance (the core palette only carries selection/handle colors). +const NEUTRAL_VIEW_STATE = { + selected: false, + highlighted: false, + hovered: false, + moving: false, + palette: undefined, +} as const + +type ExportLevel = { id: AnyNodeId; label: string } + +export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise { + const nodes = useScene.getState().nodes + const levels = resolveExportLevels(nodes) + if (levels.length === 0) { + console.warn('[floorplan-export] no level to export') + return + } + + const [{ jsPDF }, { svg2pdf }] = await Promise.all([import('jspdf'), import('svg2pdf.js')]) + const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' }) + const pageW = doc.internal.pageSize.getWidth() + const pageH = doc.internal.pageSize.getHeight() + + const host = document.createElement('div') + host.style.cssText = + 'position:fixed;left:-10000px;top:0;width:1px;height:1px;overflow:hidden;pointer-events:none;' + document.body.appendChild(host) + + let pageCount = 0 + try { + for (const level of levels) { + const geometries = collectFloorplanGeometry(nodes, level.id, scope) + if (geometries.length === 0) continue + + const mounted = await mountFloorplanSvg(host, geometries) + if (!mounted) continue + + try { + if (pageCount > 0) doc.addPage() + pageCount++ + + doc.setFontSize(14) + doc.text(level.label, PAGE_MARGIN_PT, PAGE_MARGIN_PT + 12) + + // Fit the plan into the page below the title band, preserving aspect. + const boxX = PAGE_MARGIN_PT + const boxY = PAGE_MARGIN_PT + TITLE_BAND_PT + const boxW = pageW - PAGE_MARGIN_PT * 2 + const boxH = pageH - PAGE_MARGIN_PT * 2 - TITLE_BAND_PT + const aspect = mounted.width / mounted.height + let w = boxW + let h = w / aspect + if (h > boxH) { + h = boxH + w = h * aspect + } + const x = boxX + (boxW - w) / 2 + const y = boxY + (boxH - h) / 2 + + // svg2pdf doesn't honour `vector-effect: non-scaling-stroke` (which + // many builders use to keep door/window/stair line weights constant + // on screen). Left as-is, those pixel-sized widths render as + // metre-wide strokes — huge grey blobs. Convert them to the real-unit + // width that lands at the intended point weight once svg2pdf scales + // the plan onto the page. + inlineNonScalingStrokes(mounted.svg, w / mounted.width) + + await svg2pdf(mounted.svg, doc, { x, y, width: w, height: h }) + } finally { + mounted.cleanup() + } + } + + if (pageCount === 0) { + console.warn(`[floorplan-export] nothing to export for scope "${scope}"`) + return + } + + const date = new Date().toISOString().split('T')[0] + doc.save(`floorplan_${scope}_${date}.pdf`) + } finally { + host.remove() + } +} + +type MountedFloorplan = { + svg: SVGSVGElement + /** Padded viewBox dimensions, in meters — used for aspect-preserving fit. */ + width: number + height: number + cleanup: () => void +} + +async function mountFloorplanSvg( + parent: HTMLElement, + geometries: { id: AnyNodeId; base: FloorplanGeometry }[], +): Promise { + const container = document.createElement('div') + parent.appendChild(container) + const root = createRoot(container) + const cleanup = () => { + root.unmount() + container.remove() + } + + // Render a full `` as the React root child so React enters the SVG + // namespace at the `` tag, then mutate the DOM node afterwards — + // viewBox/background depend on the post-mount measured bounds. + flushSync(() => { + root.render( + createElement( + 'svg', + { xmlns: SVG_NS }, + createElement( + 'g', + { 'data-floorplan-content': '' }, + geometries.map(({ id, base }) => + createElement(FloorplanGeometryRenderer, { key: id, geometry: base }), + ), + ), + ), + ) + }) + + // Give async asset images (item icons) a couple of frames to resolve so + // they're included in the measured bounds and the rendered output. + await nextFrames(2) + + const svg = container.querySelector('svg') + const content = svg?.querySelector('[data-floorplan-content]') as SVGGraphicsElement | null + const bbox = content?.getBBox() + if (!svg || !bbox || bbox.width === 0 || bbox.height === 0) { + cleanup() + return null + } + + const minX = bbox.x - PADDING_M + const minY = bbox.y - PADDING_M + const width = bbox.width + PADDING_M * 2 + const height = bbox.height + PADDING_M * 2 + svg.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`) + svg.setAttribute('width', `${width}`) + svg.setAttribute('height', `${height}`) + + const background = document.createElementNS(SVG_NS, 'rect') + background.setAttribute('x', `${minX}`) + background.setAttribute('y', `${minY}`) + background.setAttribute('width', `${width}`) + background.setAttribute('height', `${height}`) + background.setAttribute('fill', '#ffffff') + svg.insertBefore(background, svg.firstChild) + + return { svg, width, height, cleanup } +} + +/** + * Bake `vector-effect: non-scaling-stroke` widths into real user units. + * + * svg2pdf ignores the non-scaling hint, so a `stroke-width="1.25"` meant as + * "1.25 screen px" would otherwise render as 1.25 metres on the page. We + * rewrite each such width (and any dash pattern) to `px / ptPerUnit` so it + * lands at ~`px` points once svg2pdf scales the plan by `ptPerUnit`, then drop + * the now-misleading attribute. + */ +function inlineNonScalingStrokes(svg: SVGSVGElement, ptPerUnit: number) { + if (!Number.isFinite(ptPerUnit) || ptPerUnit <= 0) return + for (const el of svg.querySelectorAll('[vector-effect="non-scaling-stroke"]')) { + const sw = el.getAttribute('stroke-width') + if (sw) { + const px = Number.parseFloat(sw) + if (Number.isFinite(px)) el.setAttribute('stroke-width', `${px / ptPerUnit}`) + } + const dash = el.getAttribute('stroke-dasharray') + if (dash) { + const scaled = dash + .split(/[\s,]+/) + .map((v) => { + const n = Number.parseFloat(v) + return Number.isFinite(n) ? `${n / ptPerUnit}` : v + }) + .join(' ') + el.setAttribute('stroke-dasharray', scaled) + } + el.removeAttribute('vector-effect') + } +} + +function collectFloorplanGeometry( + nodes: Record, + levelId: AnyNodeId, + scope: FloorplanExportScope, +): { id: AnyNodeId; base: FloorplanGeometry }[] { + const noLiveOverrides = new Map() + const levelNodeIdsByType = new Map() + const entries: { id: AnyNodeId; node: AnyNode }[] = [] + + const visit = (id: AnyNodeId) => { + const node = nodes[id] + if (!node) return + const def = nodeRegistry.get(node.type) + if (def?.computeFloorplanLevelData) { + const ids = levelNodeIdsByType.get(node.type) + if (ids) ids.push(id) + else levelNodeIdsByType.set(node.type, [id]) + } + if ( + def?.floorplan && + isFloorplanNodeVisible(node) && + (scope === 'full' || def.category === 'structure') + ) { + entries.push({ id, node }) + } + const childIds = (node as { children?: AnyNodeId[] }).children + if (Array.isArray(childIds)) for (const cid of childIds) visit(cid) + } + visit(levelId) + + // Document order is paint order — sort the same way the live layer does so + // zones sit under walls/slabs/furniture rather than on top of them. + entries.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type)) + + // One-shot per-type cache for `computeFloorplanLevelData`; value type is + // module-private to the registry layer, so let it infer. + const levelDataCache = new Map() + const out: { id: AnyNodeId; base: FloorplanGeometry }[] = [] + for (const { id, node } of entries) { + const builder = nodeRegistry.get(node.type)?.floorplan + if (!builder) continue + const levelData = getFloorplanLevelData( + node.type, + nodes, + noLiveOverrides, + levelNodeIdsByType, + levelDataCache, + ) + const ctx = buildContext(node, nodes, NEUTRAL_VIEW_STATE, levelData) + const geometry = builder(node, ctx) + if (!geometry) continue + const { base } = splitFloorplanOverlay(geometry) + if (base) out.push({ id, base }) + } + return out +} + +/** + * Levels to export, ordered bottom-to-top. The active building (the building + * owning the selected level, or the first one found) contributes all of its + * level children; if there is no building wrapper we fall back to the single + * resolved level. + */ +function resolveExportLevels(nodes: Record): ExportLevel[] { + const selected = useViewer.getState().selection.levelId as AnyNodeId | null | undefined + const activeLevelId = selected && nodes[selected] ? selected : firstLevelId(nodes) + if (!activeLevelId) return [] + + const buildingId = resolveBuildingForLevel(activeLevelId, nodes as Record) + let levelNodes: AnyNode[] + if (buildingId) { + const childIds = (nodes[buildingId] as { children?: AnyNodeId[] }).children ?? [] + levelNodes = childIds.map((id) => nodes[id]).filter((n): n is AnyNode => n?.type === 'level') + } else { + const node = nodes[activeLevelId] + levelNodes = node ? [node] : [] + } + + levelNodes.sort((a, b) => levelIndexOf(a) - levelIndexOf(b)) + return levelNodes.map((n) => ({ id: n.id as AnyNodeId, label: levelLabelOf(n) })) +} + +function firstLevelId(nodes: Record): AnyNodeId | null { + for (const node of Object.values(nodes)) { + if (node.type === 'level') return node.id as AnyNodeId + } + return null +} + +function levelIndexOf(node: AnyNode): number { + return (node as { level?: number }).level ?? 0 +} + +function levelLabelOf(node: AnyNode): string { + const name = node.name?.trim() + if (name) return name + return `Level ${levelIndexOf(node)}` +} + +function nextFrames(count: number): Promise { + return new Promise((resolve) => { + const tick = (remaining: number) => { + if (remaining <= 0) { + resolve() + return + } + requestAnimationFrame(() => tick(remaining - 1)) + } + tick(count) + }) +}