Merge pull request #449 from pascalorg/feat/floorplan-export
feat(editor): floorplan export (multi-page PDF, per level)
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1488,7 +1488,7 @@ function buildFloorplanEntryGeometry({
|
||||
return entry
|
||||
}
|
||||
|
||||
function getFloorplanLevelData(
|
||||
export function getFloorplanLevelData(
|
||||
type: string,
|
||||
nodes: Record<string, AnyNode>,
|
||||
liveOverrides: Map<string, LiveNodeOverrides>,
|
||||
@@ -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<string, AnyNode>,
|
||||
viewState: {
|
||||
@@ -2560,7 +2560,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
||||
* / translations apply in both passes. Empty groups collapse to `null`
|
||||
* so the caller can skip emitting an `<g>` 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
|
||||
|
||||
@@ -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 */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('glb')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export GLB
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('stl')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export STL
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('obj')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export OBJ
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">3D model</div>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('glb')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export GLB
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('stl')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export STL
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportScene?.('obj')}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Export OBJ
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">Floorplan</div>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportFloorplanPdf('full')}
|
||||
variant="outline"
|
||||
>
|
||||
<MapIcon className="size-4" />
|
||||
Full floorplan
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportFloorplanPdf('structure')}
|
||||
variant="outline"
|
||||
>
|
||||
<MapIcon className="size-4" />
|
||||
Structure only
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Section (only for cloud projects) */}
|
||||
|
||||
@@ -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<void> {
|
||||
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<MountedFloorplan | null> {
|
||||
const container = document.createElement('div')
|
||||
parent.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const cleanup = () => {
|
||||
root.unmount()
|
||||
container.remove()
|
||||
}
|
||||
|
||||
// Render a full `<svg>` as the React root child so React enters the SVG
|
||||
// namespace at the `<svg>` 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<string, AnyNode>,
|
||||
levelId: AnyNodeId,
|
||||
scope: FloorplanExportScope,
|
||||
): { id: AnyNodeId; base: FloorplanGeometry }[] {
|
||||
const noLiveOverrides = new Map<string, LiveNodeOverrides>()
|
||||
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
||||
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<string, AnyNode>): 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<AnyNodeId, AnyNode>)
|
||||
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<string, AnyNode>): 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<void> {
|
||||
return new Promise((resolve) => {
|
||||
const tick = (remaining: number) => {
|
||||
if (remaining <= 0) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => tick(remaining - 1))
|
||||
}
|
||||
tick(count)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user