A new way to shape stone

Design a space
worth living in.

Bring your room, your measurements and your instinct for material together. Build a considered stone concept before it reaches the workshop.

See How It Works

Live 3D designer: generate, inspect and export your stone concept in the browser.

Concept workspaceCalacatta / Kitchen study
From photograph to possibility

A clearer way to make
material decisions.

The designer gathers the practical details a fabricator needs, then creates a structured brief that can grow into a visual scene.

01

Capture your space

Photographs and dimensions create a useful starting point for the room.

02

Shape the brief

Describe the atmosphere, stone, finish and details you want to explore.

03

Prepare for craft

Save a structured concept ready for a professional conversation and quote.

Stone library

Materials samples.

Browse popular Prime Marble finishes and start building your concept around the stone you love most.

AI Stone Designer

Start a new project

Local draft
Step 01

What are you making?

Choose the project that best describes your space. This shapes the first concept brief.

:root{--designer-ink:#171512;--designer-paper:#f5f1eb;--designer-stone:#d8d0c4;--designer-rust:#b95b27;--designer-line:rgba(23,21,18,.14);--designer-soft:#ebe4da} .designerPage{background:var(--designer-paper);color:var(--designer-ink)} .designerPage header{background:rgba(245,241,235,.92);border-color:var(--designer-line)} .designerHero{min-height:690px;position:relative;overflow:hidden;background:#201c18;color:#f8f2e8} .designerHeroTexture{position:absolute;inset:0;opacity:.25;background:linear-gradient(115deg,transparent 0 38%,rgba(214,198,177,.15) 38.2% 39%,transparent 39.2% 54%,rgba(214,198,177,.12) 54.2% 54.5%,transparent 54.7%),radial-gradient(circle at 80% 14%,rgba(189,119,67,.38),transparent 28%),linear-gradient(135deg,#1a1714,#342a22 55%,#171513)} .designerHeroGrid{position:relative;z-index:1;display:grid;grid-template-columns:1fr 1fr;gap:70px;align-items:center;min-height:690px} .designerHeroCopy{max-width:620px}.designerKicker,.designerSectionHeading .eyebrow{font-size:12px;letter-spacing:2px;text-transform:uppercase;font-weight:700;color:#d49a70}.designerHero h1{font-size:clamp(52px,6vw,86px);line-height:.98;letter-spacing:-2px;margin:22px 0 28px;color:#f8f2e8}.designerHero h1 em{font-style:normal;color:#d7a578}.designerHeroCopy>p{font-size:19px;line-height:1.7;color:rgba(248,242,232,.78);max-width:560px}.designerGhostButton{background:transparent;color:#f8f2e8;border-color:rgba(248,242,232,.35)}.designerGhostButton:hover{background:rgba(255,255,255,.08)}.designerDisclaimer{font-size:12px!important;color:rgba(248,242,232,.56)!important;margin-top:30px}.designerDisclaimer span{color:#d49a70;margin-right:7px} .designerHeroVisual{height:520px;position:relative;border:1px solid rgba(248,242,232,.22);background:linear-gradient(145deg,rgba(255,255,255,.08),rgba(255,255,255,.01));box-shadow:20px 30px 90px rgba(0,0,0,.25);overflow:hidden}.heroCompass{position:absolute;top:24px;left:26px;font-size:11px;letter-spacing:2px;line-height:1.4;color:#e3c4a3;z-index:2}.heroCompass span{color:rgba(255,255,255,.45)}.roomSketch{position:absolute;inset:55px 30px 70px;transform:perspective(720px) rotateX(8deg) rotateY(-10deg);transform-style:preserve-3d}.sketchWall{position:absolute;inset:5% 10% 32% 8%;background:linear-gradient(145deg,#b2a89d,#ddd4c7);border:1px solid rgba(255,255,255,.38);box-shadow:inset 0 0 0 1px rgba(38,31,24,.12)}.sketchFloor{position:absolute;left:0;right:0;bottom:0;height:42%;background:repeating-linear-gradient(110deg,rgba(87,66,48,.18) 0 2px,transparent 2px 50px),linear-gradient(155deg,#92745d,#c4a98b);clip-path:polygon(8% 0,100% 15%,100% 100%,0 100%)}.sketchIsland{position:absolute;left:25%;bottom:21%;width:49%;height:22%;background:linear-gradient(#e6ded2 0 18%,#6f5140 19% 100%);transform:skewY(-10deg);box-shadow:18px 18px 0 rgba(36,26,19,.18)}.sketchStone{position:absolute;background:#eee9e0;box-shadow:inset 0 0 0 1px rgba(111,74,51,.2)}.slab-one{left:31%;bottom:41%;width:43%;height:5%;transform:skewY(-10deg)}.slab-two{right:13%;top:21%;width:8%;height:43%;background:linear-gradient(100deg,#e9e1d6,#b99f8c,#f2ece3)}.sketchLabel{position:absolute;font-size:9px;letter-spacing:1.5px;color:#3f3025}.label-stone{right:0;top:17%}.label-space{left:7%;bottom:9%}.heroVisualCaption{position:absolute;left:26px;right:26px;bottom:22px;border-top:1px solid rgba(248,242,232,.22);padding-top:13px;display:flex;justify-content:space-between;gap:16px;font-size:11px;text-transform:uppercase;letter-spacing:1px;color:rgba(248,242,232,.55)}.heroVisualCaption strong{color:#f8f2e8;font-weight:500} .designerIntro{background:#f5f1eb}.designerSectionHeading{display:grid;grid-template-columns:1fr 1fr;gap:50px;align-items:end}.designerSectionHeading h2{font-size:48px;line-height:1.05;margin:16px 0 0}.designerSectionHeading .lead{margin:0}.designerProcessGrid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--designer-line);margin-top:75px}.designerProcessGrid article{background:#f5f1eb;padding:28px 28px 8px 0;min-height:185px}.designerProcessGrid article+article{padding-left:28px}.processNumber{font-family:var(--serif);font-size:26px;color:var(--designer-rust)}.designerProcessGrid h3{font-family:var(--sans);font-size:16px;margin:26px 0 8px}.designerProcessGrid p{font-size:14px;line-height:1.65;color:#6d665d;max-width:260px} .materialsSampleSection{padding-top:28px}.materialsSampleGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin-top:32px}.materialSampleCard{background:#fff;border:1px solid var(--designer-line);padding:14px;display:grid;gap:12px}.materialSampleCard.selected{border-color:var(--designer-rust);box-shadow:inset 0 0 0 1px var(--designer-rust)}.materialSampleImage{overflow:hidden;border:1px solid rgba(23,21,18,.08);background:#f2eee8}.materialSampleImage img{display:block;width:100%;height:220px;object-fit:cover}.materialSampleMeta{display:flex;justify-content:space-between;align-items:center;gap:12px}.materialSampleCategory{display:inline-block;font-size:10px;letter-spacing:1.4px;text-transform:uppercase;color:#7b695d;padding:6px 8px;background:#f2eadf}.materialSampleMeta h3{margin:10px 0 0;font-size:20px}.materialSampleCard p{margin:0;color:#625b55;line-height:1.6}.materialSampleCard small{font-size:12px;letter-spacing:.5px;color:#6f655d} .designerApp{padding-top:72px;background:#eae1d6}.designerAppHeader{display:flex;justify-content:space-between;align-items:end;margin-bottom:24px}.designerAppHeader h2{font-size:42px;margin:12px 0 0}.designerSaveState{font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#6e6255;border-bottom:1px solid var(--designer-line);padding-bottom:8px}.designerLayout{display:grid;grid-template-columns:220px minmax(0,1fr);background:#f8f5f0;min-height:680px;border:1px solid var(--designer-line);box-shadow:0 20px 50px rgba(51,37,25,.1)}.wizardRail{background:#27231f;color:#f6efe6;padding:28px 20px;display:flex;flex-direction:column}.wizardRailTop{display:flex;justify-content:space-between;align-items:start;padding-bottom:28px;border-bottom:1px solid rgba(255,255,255,.16)}.wizardOverline{font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:#c3a68a}.wizardRailTop strong{font-family:var(--serif);font-size:22px;font-weight:600}.stepList{list-style:none;padding:25px 0;margin:0;display:grid;gap:5px}.stepList button{width:100%;text-align:left;border:0;background:transparent;color:rgba(246,239,230,.52);padding:12px 8px;display:flex;gap:12px;align-items:center;font:600 12px var(--sans);cursor:pointer}.stepList button span{font:12px var(--serif);color:rgba(246,239,230,.38)}.stepList li.active button,.stepList li.done button{color:#f6efe6}.stepList li.active button{background:rgba(255,255,255,.08)}.stepList li.active button span,.stepList li.done button span{color:#d49a70}.wizardRailNote{margin-top:auto;border-top:1px solid rgba(255,255,255,.16);padding-top:20px;display:flex;gap:10px}.wizardRailNote>span{color:#d49a70}.wizardRailNote p{font-size:11px;line-height:1.6;color:rgba(246,239,230,.55);margin:0} .wizardPanel{padding:50px 6%;min-width:0}.wizardStep{display:none;min-height:575px}.wizardStep.active{display:flex;flex-direction:column}.stepIntro{max-width:650px}.stepLabel{font-size:11px;text-transform:uppercase;letter-spacing:1.6px;color:var(--designer-rust);font-weight:700}.stepIntro h3{font-size:38px;margin:14px 0 10px}.stepIntro p{font-size:16px;color:#746b61;line-height:1.65;margin:0 0 30px}.projectTypeGrid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.projectTypeCard{min-height:125px;border:1px solid var(--designer-line);background:#fff;text-align:left;padding:16px;cursor:pointer;transition:.2s ease;display:flex;flex-direction:column;justify-content:space-between}.projectTypeCard:hover,.projectTypeCard.selected{border-color:var(--designer-rust);box-shadow:inset 0 0 0 1px var(--designer-rust);transform:translateY(-2px)}.projectTypeCard .typeIcon{font-size:23px;color:var(--designer-rust)}.projectTypeCard strong{font:600 14px var(--sans)}.projectTypeCard small{font-size:11px;line-height:1.4;color:#81766b}.wizardActions{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:auto;padding-top:35px}.wizardActions .btn{box-shadow:none}.uploadDrop{min-height:190px;border:1px dashed #aa9a89;background:rgba(255,255,255,.45);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;text-align:center;cursor:pointer}.uploadDrop:hover{background:#fff;border-color:var(--designer-rust)}.uploadIcon{font-size:32px;color:var(--designer-rust);line-height:1}.uploadDrop strong{font-size:16px}.uploadDrop span{font-size:13px;color:#756c62}.uploadDrop small{font-size:11px;color:#a09384}.photoGrid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:16px}.photoItem{position:relative;aspect-ratio:1;overflow:hidden;background:#ddd}.photoItem img{width:100%;height:100%;object-fit:cover}.photoItem button{position:absolute;top:5px;right:5px;width:27px;height:27px;border:0;border-radius:50%;background:#201c18;color:#fff;cursor:pointer}.formHint{font-size:12px;color:#83776b}.unitSwitcher{display:flex;align-items:center;gap:5px;margin-bottom:20px;color:#776e64;font-size:12px}.unitSwitcher button{border:1px solid var(--designer-line);background:transparent;padding:8px 13px;font:700 12px var(--sans);cursor:pointer}.unitSwitcher button.active{background:#28231e;color:#fff;border-color:#28231e}.measurementGrid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.measurementGrid label,.optionalGrid label,.materialToolbar label{font-size:12px;font-weight:700;color:#453d36;position:relative}.measurementGrid label>span{display:block;float:right;font-size:10px;color:var(--designer-rust);font-weight:500}.measurementGrid input{margin-top:7px;padding:16px 40px 16px 14px;border-radius:0;background:#fff;border-color:var(--designer-line)}.unitLabel{position:absolute;right:14px;bottom:16px;color:#8a7a6c}.optionalDetails{margin-top:26px;border-top:1px solid var(--designer-line);border-bottom:1px solid var(--designer-line);padding:16px 0}.optionalDetails summary{font-size:13px;font-weight:700;cursor:pointer}.optionalGrid{display:grid;grid-template-columns:repeat(2,1fr);gap:15px;margin-top:18px}.optionalGrid input{margin-top:6px;border-radius:0;background:#fff}.wizardPanel textarea{border-radius:0;margin-top:8px;min-height:180px;background:#fff;border-color:var(--designer-line)}.stylePicker{margin-top:22px}.stylePicker>span{font-size:12px;font-weight:700}.styleOptions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}.styleOption{border:1px solid var(--designer-line);background:transparent;padding:9px 13px;font-size:12px;cursor:pointer}.styleOption.selected{background:#28231e;color:#fff;border-color:#28231e}.materialToolbar{display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:16px}.materialToolbar select{margin-top:6px;border-radius:0;background:#fff;padding:10px 32px 10px 10px}.materialGrid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.materialCard{border:1px solid var(--designer-line);background:#fff;cursor:pointer;text-align:left;position:relative}.materialCard.selected{border-color:var(--designer-rust);box-shadow:inset 0 0 0 1px var(--designer-rust)}.materialCard img{width:100%;height:104px;object-fit:cover;display:block;filter:saturate(.78)}.materialCardBody{padding:11px}.materialCardBody strong{display:block;font:600 13px var(--sans)}.materialCardBody small{display:block;color:#81776d;font-size:10px;margin-top:5px}.materialCheck{position:absolute;top:8px;right:8px;border-radius:50%;width:20px;height:20px;background:#fff;border:1px solid #9f8d7e;font-size:12px;text-align:center;line-height:18px;color:transparent}.materialCard.selected .materialCheck{background:var(--designer-rust);color:#fff;border-color:var(--designer-rust)}.reviewGrid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.reviewItem{padding:16px;background:#fff;border:1px solid var(--designer-line)}.reviewItem span{display:block;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#8b7b6d;margin-bottom:7px}.reviewItem strong{font-size:14px;font-weight:600}.honestState{display:flex;gap:13px;background:#f0e4d7;border-left:3px solid var(--designer-rust);padding:16px;margin-top:20px}.honestState>span{font-size:23px;color:var(--designer-rust)}.honestState strong{font-size:13px}.honestState p{font-size:12px;line-height:1.5;color:#75695e;margin:4px 0 0} .workspace{background:#f5f1eb;padding-top:70px}.workspaceHeader{display:flex;justify-content:space-between;align-items:end;margin-bottom:22px}.workspaceHeader h2{font-size:42px;margin:10px 0 0}.workspaceStatus{font-size:12px;color:#73685c;display:flex;gap:8px;align-items:center}.statusDot{width:8px;height:8px;border-radius:50%;background:#bd6a3f}.workspaceShell{display:grid;grid-template-columns:180px minmax(0,1fr) 210px;min-height:590px;background:#1e1c19;color:#eee8df;box-shadow:0 22px 55px rgba(44,31,21,.18)}.workspaceSidebar{padding:20px 16px;border-right:1px solid rgba(255,255,255,.1);display:flex;flex-direction:column}.rightSidebar{border-right:0;border-left:1px solid rgba(255,255,255,.1)}.workspaceSidebarTitle{font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:#b69a80;margin-bottom:22px}.workspaceTabs{display:grid;gap:4px}.workspaceTabs button{border:0;background:transparent;color:#8d8880;text-align:left;padding:9px 7px;font:12px var(--sans);cursor:pointer}.workspaceTabs button.active,.workspaceTabs button:hover{background:rgba(255,255,255,.1);color:#f5eee5}.workspaceSidebarFooter{margin-top:auto;display:grid;gap:5px;font-size:10px;color:#78736c}.designViewport{min-width:0;display:flex;flex-direction:column;background:#464039}.viewportToolbar,.viewportFooter{display:flex;justify-content:space-between;gap:12px;padding:14px 17px;font-size:9px;letter-spacing:1px;color:#bdb1a1}.viewportToolbar button{margin-left:auto;border:0;background:transparent;color:#cdbda9}.viewportScene{position:relative;flex:1;min-height:400px;overflow:hidden;background:radial-gradient(circle at 50% 25%,rgba(197,171,143,.26),transparent 31%),linear-gradient(150deg,#49443d,#292824)}.viewportRoom{position:absolute;inset:13% 12%;transform:perspective(700px) rotateX(6deg) rotateY(-9deg)}.viewportBackWall{position:absolute;inset:0 10% 31%;background:linear-gradient(135deg,#aaa193,#ded3c5);border:1px solid rgba(255,255,255,.3)}.viewportSideWall{position:absolute;right:0;top:0;width:11%;height:69%;background:#918476;clip-path:polygon(0 0,100% 12%,100% 100%,0 87%)}.viewportFloor{position:absolute;inset:68% 0 0;background:repeating-linear-gradient(110deg,rgba(74,51,37,.25) 0 2px,transparent 2px 48px),linear-gradient(145deg,#896a53,#b89d80);clip-path:polygon(0 0,100% 12%,100% 100%,0 100%)}.viewportCounter{position:absolute;left:13%;top:49%;width:54%;height:10%;background:#ede6dc;border-bottom:12px solid #4c392c;transform:skewY(-7deg)}.viewportIsland{position:absolute;left:24%;top:65%;width:48%;height:17%;background:linear-gradient(#e4dbcf 0 20%,#6d4e3d 21%);transform:skewY(-7deg)}.viewportSlab{position:absolute;right:23%;top:18%;width:12%;height:48%;background:linear-gradient(100deg,#e7dfd5,#c6af9d,#efe8de)}.viewportCenterNote{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#f0e7dc;text-shadow:0 2px 18px #211d19}.viewportCenterNote span{font-size:10px;text-transform:uppercase;letter-spacing:2px;color:#d1a67f}.viewportCenterNote strong{font:27px var(--serif);margin:8px 0}.viewportCenterNote small{font-size:11px;color:#c8bdb1}.viewportAnnotation{position:absolute;font-size:9px;letter-spacing:1px;color:#e5c7a7}.annotationOne{top:23%;left:12%}.annotationTwo{bottom:19%;right:13%}.selectedObject{border-bottom:1px solid rgba(255,255,255,.12);padding-bottom:20px}.objectPreview{height:92px;background:linear-gradient(135deg,#ded6ca,#9a8573);margin-bottom:12px;position:relative}.objectPreview span{position:absolute;inset:20px 16px;background:rgba(255,255,255,.42);transform:skewY(-12deg)}.selectedObject strong{display:block;font-size:13px;font-weight:600}.selectedObject small{display:block;font-size:10px;color:#8e887f;margin-top:5px}.propertyList{display:grid;gap:15px;padding:20px 0}.propertyList label{font-size:10px;color:#918b83}.propertyList strong{display:block;color:#eae2d8;font-size:11px;font-weight:500;margin-top:4px}.workspaceActions{margin-top:auto;display:grid;gap:8px}.workspaceActions .btn{border-color:rgba(255,255,255,.18);background:transparent;color:#eee8df;box-shadow:none;padding:10px 8px;font-size:11px}.workspaceActions .btnPrimary{background:var(--designer-rust);border-color:var(--designer-rust)}.workspaceActions small{font-size:9px;color:#b7a99a}.workspaceNotice{margin-top:15px;padding:13px 16px;background:#e7dacb;color:#5f4e40;font-size:13px} @media(max-width:980px){.designerHeroGrid{grid-template-columns:1fr;gap:40px;padding:70px 0}.designerHero{min-height:auto}.designerHeroVisual{height:430px}.designerSectionHeading{grid-template-columns:1fr;gap:20px}.designerProcessGrid{margin-top:45px}.designerLayout{grid-template-columns:1fr}.wizardRail{padding:18px 20px}.stepList{display:flex;overflow:auto;padding:14px 0 0}.stepList li{flex:1;min-width:110px}.stepList button{display:grid;gap:4px;padding:10px 8px}.wizardRailNote{display:none}.wizardPanel{padding:35px 5%}.workspaceShell{grid-template-columns:150px minmax(0,1fr)}.rightSidebar{grid-column:1/-1;border-left:0;border-top:1px solid rgba(255,255,255,.1);display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px}.workspaceActions{margin-top:0}.workspaceSidebarFooter{display:none}} @media(max-width:640px){.designerHero h1{font-size:52px}.designerHeroCopy>p{font-size:16px}.designerHeroVisual{height:350px}.heroVisualCaption{display:grid;gap:4px}.designerSectionHeading h2,.designerAppHeader h2,.workspaceHeader h2{font-size:34px}.designerProcessGrid{display:block;background:none}.designerProcessGrid article,.designerProcessGrid article+article{padding:22px 0;border-bottom:1px solid var(--designer-line);min-height:0}.designerAppHeader,.workspaceHeader{display:block}.designerSaveState,.workspaceStatus{display:inline-flex;margin-top:15px}.projectTypeGrid{grid-template-columns:repeat(2,1fr)}.projectTypeCard{min-height:110px}.measurementGrid,.optionalGrid,.reviewGrid{grid-template-columns:1fr}.materialGrid{grid-template-columns:repeat(2,1fr)}.materialCard img{height:90px}.photoGrid{grid-template-columns:repeat(3,1fr)}.workspaceShell{display:block}.leftSidebar{display:none}.rightSidebar{display:block}.rightSidebar .propertyList{grid-template-columns:repeat(2,1fr)}.workspaceActions{grid-template-columns:1fr 1fr}.workspaceActions .btnPrimary{grid-column:1/-1}.viewportScene{min-height:330px}} #threeViewport{position:relative;min-height:440px;background:#312e2a;touch-action:none} #threeViewport canvas{display:block;width:100%;height:100%;min-height:440px;cursor:grab} #threeViewport canvas:active{cursor:grabbing}.viewportLoading{position:absolute;inset:0;display:grid;place-items:center;color:#d8cabc;font-size:12px;letter-spacing:.08em;text-align:center;padding:30px} .viewportToolbar button{cursor:pointer;padding:4px 7px;border:1px solid rgba(255,255,255,.16);border-radius:3px}.viewportToolbar button:hover{background:rgba(255,255,255,.08)} /** * Generate a structured DesignProject -> SceneSpecification result for the Prime Marble designer. */ import { createSceneSpecification } from "./scene-specification.js"; export async function generateDesign(project) { await new Promise((resolve) => window.setTimeout(resolve, 700)); const widthMm = toMillimetres(project.room.width, project.room.measurementUnit); const lengthMm = toMillimetres(project.room.length, project.room.measurementUnit); const heightMm = toMillimetres(project.room.height, project.room.measurementUnit); const defaultStone = project.materials[0] || { id: "calacatta-viola", name: "Calacatta Viola", baseColour: "#f7f2ee", finish: "Polished", thicknesses: [20, 30] }; const finish = detectFinish(project.designPrompt, defaultStone); const thicknessMm = detectThickness(project.designPrompt, defaultStone); const style = project.style || "Luxury"; const sceneSpecification = createSceneSpecification({ projectId: project.id, projectType: project.projectType || "kitchen-worktops", room: { widthMm, lengthMm, heightMm }, stoneId: defaultStone.id, materialName: defaultStone.name, finish, thicknessMm, designPrompt: project.designPrompt || "", style }); return { status: "ready", message: "Design concept generated from your measurements and material selection.", sceneSpecification, preview: { projectType: project.projectType || "kitchen-worktops", material: defaultStone.name, finish, size: `${widthMm ?? 0} x ${lengthMm ?? 0} x ${heightMm ?? 0} mm` } }; } function toMillimetres(value, unit) { const numericValue = Number(value); if (!Number.isFinite(numericValue)) return null; return Math.round(numericValue * ({ mm: 1, cm: 10, m: 1000 }[unit] || 1)); } function detectFinish(prompt, material) { const text = (prompt || "").toLowerCase(); if (text.includes("polished")) return "Polished"; if (text.includes("honed")) return "Honed"; if (text.includes("leathered")) return "Leathered"; if (material?.finish) return material.finish; return "Polished"; } function detectThickness(prompt, material) { const text = (prompt || "").toLowerCase(); if (text.includes("30mm") || text.includes("30 mm")) return 30; if (text.includes("20mm") || text.includes("20 mm")) return 20; if (Array.isArray(material?.thicknesses) && material.thicknesses.length) return material.thicknesses[0]; return 20; } /** * Shared data for the Prime Marble Specialists AI Stone Designer. * This is a plain JSON-friendly source for the browser designer and a future API. */ export const PROJECT_TYPES = [ { id: "kitchen-worktops", name: "Kitchen Worktops", icon: "▦", description: "Worktops, islands and splashbacks" }, { id: "kitchen-island", name: "Kitchen Island", icon: "▣", description: "Statement islands and waterfall ends" }, { id: "full-height-splashback", name: "Full-Height Splashback", icon: "▤", description: "Wall cladding and stone panels" }, { id: "bathroom", name: "Bathroom", icon: "◫", description: "Vanities, walls and shower areas" }, { id: "vanity-tops", name: "Vanity Tops", icon: "◬", description: "Hand basins and stone counters" }, { id: "feature-wall", name: "Feature Wall", icon: "▥", description: "Bookmatched statement walls" }, { id: "fireplace", name: "Fireplace", icon: "◇", description: "Hearths, surrounds and cladding" }, { id: "stone-flooring", name: "Stone Flooring", icon: "▨", description: "Floors, thresholds and pathways" }, { id: "staircase", name: "Staircase", icon: "⇲", description: "Treads, risers and landings" }, { id: "custom", name: "Custom Project", icon: "✦", description: "A stone idea of your own" } ]; export const STYLES = ["Modern", "Contemporary", "Traditional", "Minimal", "Luxury", "Warm Contemporary", "Industrial"]; export const MATERIALS = [ { id: "calacatta-viola", name: "Calacatta Viola", category: "Marble", colour: "Warm white with plum veining", finish: "Polished", thicknesses: [20, 30], image: "images/project-05.jpg", description: "Luxury marble with dramatic veining and a soft lilac accent.", baseColour: "#f3efe9", roughness: 0.28, texture: "vein", finishOptions: ["Polished", "Honed", "Leathered"], support: { veinDirection: true, bookmatch: true } }, { id: "calacatta-gold", name: "Calacatta Gold", category: "Marble", colour: "Cream with gold veining", finish: "Polished", thicknesses: [20, 30], image: "images/project-04.jpg", description: "A classic luxe marble that suits grand kitchens and bathrooms.", baseColour: "#f7f1e4", roughness: 0.25, texture: "vein", finishOptions: ["Polished", "Honed"], support: { veinDirection: true, bookmatch: true } }, { id: "statuario", name: "Statuario", category: "Marble", colour: "Bright white with grey veining", finish: "Polished", thicknesses: [20, 30], image: "images/project-01.jpg", description: "Crisp white stone that brings a refined architectural feel.", baseColour: "#f4f2ee", roughness: 0.22, texture: "vein", finishOptions: ["Polished", "Honed"], support: { veinDirection: true, bookmatch: true } }, { id: "nero-marquina", name: "Nero Marquina", category: "Marble", colour: "Black with white veining", finish: "Honed", thicknesses: [20, 30], image: "images/project-03.jpg", description: "High-contrast stone with a strong, graphic character.", baseColour: "#1c1b1a", roughness: 0.35, texture: "vein", finishOptions: ["Honed", "Polished"], support: { veinDirection: true, bookmatch: false } }, { id: "taj-mahal", name: "Taj Mahal", category: "Quartzite", colour: "Ivory with gold movement", finish: "Leathered", thicknesses: [20, 30], image: "images/project-07.jpg", description: "Warm quartzite with refined movement and excellent durability.", baseColour: "#e7d8b8", roughness: 0.45, texture: "micro", finishOptions: ["Leathered", "Honed", "Polished"], support: { veinDirection: true, bookmatch: true } }, { id: "patagonia", name: "Patagonia", category: "Quartzite", colour: "Soft beige and warm grey", finish: "Honed", thicknesses: [20, 30], image: "images/project-06.jpg", description: "A warm, contemporary quartzite suited to spacious kitchens.", baseColour: "#d8c9b4", roughness: 0.38, texture: "micro", finishOptions: ["Honed", "Leathered"], support: { veinDirection: true, bookmatch: false } }, { id: "absolute-black", name: "Absolute Black", category: "Granite", colour: "Jet black", finish: "Polished", thicknesses: [20, 30], image: "images/project-11.jpg", description: "Strong black granite for sleek interior work and bold detailing.", baseColour: "#121212", roughness: 0.24, texture: "granular", finishOptions: ["Polished", "Flamed", "Honed"], support: { veinDirection: false, bookmatch: false } }, { id: "steel-grey", name: "Steel Grey", category: "Granite", colour: "Gunmetal grey", finish: "Honed", thicknesses: [20, 30], image: "images/project-09.jpg", description: "Cool grey stone that works beautifully in minimal spaces.", baseColour: "#5a5d63", roughness: 0.31, texture: "granular", finishOptions: ["Honed", "Polished"], support: { veinDirection: false, bookmatch: false } }, { id: "classic-travertine", name: "Classic Beige", category: "Travertine", colour: "Warm beige and cream", finish: "Brushed", thicknesses: [20, 30], image: "images/project-10.jpg", description: "Soft tonal variation and an earthy warmth for relaxed luxury.", baseColour: "#d7c8a8", roughness: 0.52, texture: "travertine", finishOptions: ["Brushed", "Honed", "Filled"], support: { veinDirection: true, bookmatch: false } }, { id: "jura-beige", name: "Jura Beige", category: "Limestone", colour: "Stone beige", finish: "Honed", thicknesses: [20, 30], image: "images/project-08.jpg", description: "A muted limestone for textured, natural interior schemes.", baseColour: "#d1c5b1", roughness: 0.48, texture: "limestone", finishOptions: ["Honed", "Brushed"], support: { veinDirection: true, bookmatch: false } }, { id: "white-onyx", name: "White Onyx", category: "Onyx", colour: "Soft translucent white", finish: "Polished", thicknesses: [18, 20], image: "images/project-02.jpg", description: "A glowing stone that suits backlit statements and feature walls.", baseColour: "#f2f0ee", roughness: 0.18, texture: "onyx", finishOptions: ["Polished", "Backlit"], support: { veinDirection: true, bookmatch: true } }, { id: "green-onyx", name: "Green Onyx", category: "Onyx", colour: "Emerald green with white veining", finish: "Polished", thicknesses: [18, 20], image: "images/project-12.jpg", description: "Vivid, statement-making stone with a decorative edge.", baseColour: "#1d4a3e", roughness: 0.18, texture: "onyx", finishOptions: ["Polished"], support: { veinDirection: true, bookmatch: true } }, { id: "calacatta-porcelain", name: "Calacatta Vein Porcelain", category: "Porcelain / Sintered Stone", colour: "White with faint veining", finish: "Silk", thicknesses: [12, 20], image: "images/project-04.jpg", description: "A durable large-format porcelain with a marble-inspired look.", baseColour: "#f7f2ee", roughness: 0.28, texture: "porcelain", finishOptions: ["Silk", "Matt", "Polished"], support: { veinDirection: true, bookmatch: true } }, { id: "mont-blanc", name: "Mont Blanc", category: "Quartzite", colour: "Soft cream with subtle mineral movement", finish: "Polished", thicknesses: [20, 30], image: "images/project-05.jpg", description: "A quietly luxurious quartzite for balanced, elegant schemes.", baseColour: "#efe7df", roughness: 0.3, texture: "micro", finishOptions: ["Polished", "Honed"], support: { veinDirection: true, bookmatch: true } }, { id: "verde-alpi", name: "Verde Alpi", category: "Onyx", colour: "Deep green with white veining", finish: "Polished", thicknesses: [20, 30], image: "images/project-10.jpg", description: "Bold green stone for statement panels and luxury feature walls.", baseColour: "#1d453a", roughness: 0.16, texture: "onyx", finishOptions: ["Polished", "Honed"], support: { veinDirection: true, bookmatch: true } } ]; export const INSPIRATION_DESIGNS = [ { id: "luxury-viola-kitchen", title: "Luxury Viola Kitchen", projectType: "kitchen-worktops", style: "Luxury", prompt: "Large central island with Calacatta Viola marble, 20mm worktops, waterfall both ends and full-height splashback.", room: { width: 4500, length: 5400, height: 2400 }, stone: "calacatta-viola" }, { id: "taj-mahal-kitchen", title: "Taj Mahal Kitchen", projectType: "kitchen-worktops", style: "Warm Contemporary", prompt: "Warm contemporary kitchen with Taj Mahal quartzite worktops, a large island, 20mm stone, and full-height splashback.", room: { width: 4700, length: 5600, height: 2500 }, stone: "taj-mahal" }, { id: "patagonia-island", title: "Patagonia Statement Island", projectType: "kitchen-island", style: "Modern", prompt: "Luxury kitchen featuring a large Patagonia quartzite island with waterfall ends and matching feature splashback.", room: { width: 5000, length: 6000, height: 2600 }, stone: "patagonia" }, { id: "calacatta-gold-bathroom", title: "Calacatta Gold Bathroom", projectType: "bathroom", style: "Luxury", prompt: "Luxury bathroom with Calacatta Gold vanity top, matching shower wall panels and polished finish.", room: { width: 3600, length: 4200, height: 2400 }, stone: "calacatta-gold" }, { id: "bookmatched-feature-wall", title: "Bookmatched Feature Wall", projectType: "feature-wall", style: "Contemporary", prompt: "Floor-to-ceiling bookmatched Calacatta Viola marble feature wall with integrated lighting.", room: { width: 5200, length: 6200, height: 2800 }, stone: "calacatta-viola" }, { id: "nero-marquina-fireplace", title: "Nero Marquina Fireplace", projectType: "fireplace", style: "Minimal", prompt: "Modern floor-to-ceiling fireplace clad in Nero Marquina marble with matching hearth and integrated niche.", room: { width: 3600, length: 5000, height: 2600 }, stone: "nero-marquina" } ]; export function createEmptyProject() { const now = new Date().toISOString(); return { id: `designer-${Date.now()}`, userId: null, name: "Untitled stone project", projectType: "", status: "Draft", createdAt: now, updatedAt: now, room: { width: null, length: null, height: null, measurementUnit: "mm" }, photos: [], measurements: [], designPrompt: "", style: "", materials: [], sceneSpecification: null, sceneObjects: [], renders: [], modelFile: null, quoteRequestStatus: "Not requested" }; } /** * Future Blender worker boundary. * Blender should run as a separate service and consume SceneSpecification, * never raw customer language or browser-only state. */ export const blenderService = { async createScene(sceneSpecification) { return mockOperation("createScene", sceneSpecification); }, async updateScene(sceneSpecification) { return mockOperation("updateScene", sceneSpecification); }, async applyMaterial(materialAssignment) { return mockOperation("applyMaterial", materialAssignment); }, async addObject(sceneObject) { return mockOperation("addObject", sceneObject); }, async setCamera(camera) { return mockOperation("setCamera", camera); }, async setLighting(lighting) { return mockOperation("setLighting", lighting); }, async renderScene(sceneSpecification) { return mockOperation("renderScene", sceneSpecification); }, async exportGLB(sceneSpecification) { return mockOperation("exportGLB", sceneSpecification); } }; async function mockOperation(operation, payload) { return { status: "mock", operation, payload, message: "Blender worker integration is planned, not connected." }; } import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; export class PrimeMarbleViewer { constructor(container, onSelect = () => {}) { this.container = container; this.onSelect = onSelect; this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x312e2a); this.camera = new THREE.PerspectiveCamera(48, 1, 0.01, 100); this.renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.renderer.toneMapping = THREE.ACESFilmicToneMapping; this.renderer.toneMappingExposure = 1.15; container.replaceChildren(this.renderer.domElement); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.07; this.controls.minDistance = 1.2; this.controls.maxDistance = 18; this.controls.maxPolarAngle = Math.PI * 0.49; this.root = new THREE.Group(); this.scene.add(this.root); this.raycaster = new THREE.Raycaster(); this.pointer = new THREE.Vector2(); this.renderer.domElement.addEventListener('pointerdown', (e) => this.pick(e)); this.resizeObserver = new ResizeObserver(() => this.resize()); this.resizeObserver.observe(container); this.addLights(); this.resize(); this.animate(); } addLights() { this.scene.add(new THREE.HemisphereLight(0xfff7ed, 0x363b42, 2.2)); const key = new THREE.DirectionalLight(0xfff1df, 4.2); key.position.set(4, 7, 5); key.castShadow = true; key.shadow.mapSize.set(2048, 2048); this.scene.add(key); const fill = new THREE.DirectionalLight(0xdbe8ff, 1.2); fill.position.set(-4, 3, 2); this.scene.add(fill); } makeStoneMaterial(material = {}) { const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 512; const ctx = canvas.getContext('2d'); const base = material.baseColour || '#eee8df'; ctx.fillStyle = base; ctx.fillRect(0,0,512,512); const seed = [...(material.id || 'stone')].reduce((a,c)=>a+c.charCodeAt(0),0); const rand = (n) => { const x = Math.sin(n * 12.9898 + seed) * 43758.5453; return x - Math.floor(x); }; const dark = ['nero-marquina','absolute-black'].includes(material.id); for (let i=0;i<18;i++) { ctx.beginPath(); ctx.lineWidth = 0.7 + rand(i)*3.2; ctx.strokeStyle = dark ? `rgba(245,245,240,${0.10+rand(i+2)*0.32})` : `rgba(105,82,72,${0.08+rand(i+2)*0.22})`; let y = rand(i+10)*512; ctx.moveTo(-20,y); for(let x=0;x<=540;x+=35){ y += (rand(i*50+x)-0.5)*35; ctx.lineTo(x,y); } ctx.stroke(); } const texture = new THREE.CanvasTexture(canvas); texture.wrapS = texture.wrapT = THREE.RepeatWrapping; texture.repeat.set(1.6,1.6); texture.colorSpace = THREE.SRGBColorSpace; return new THREE.MeshStandardMaterial({ map:texture, color:0xffffff, roughness: material.roughness ?? .28, metalness:0.02 }); } load(spec, material) { while(this.root.children.length) this.root.remove(this.root.children[0]); const stone = this.makeStoneMaterial(material); const wallMat = new THREE.MeshStandardMaterial({color:0xe7e1d8, roughness:.82}); const cabinetMat = new THREE.MeshStandardMaterial({color:0x6a5548, roughness:.62}); const floorMat = new THREE.MeshStandardMaterial({color:0x9b816b, roughness:.75}); const metalMat = new THREE.MeshStandardMaterial({color:0x252525, roughness:.3, metalness:.5}); spec.objects.forEach(obj => { const d=obj.dimensions, p=obj.position; const geo = new THREE.BoxGeometry(Math.max(.02,d.x),Math.max(.02,d.y),Math.max(.02,d.z)); let mat = stone; if(obj.type==='wall') mat=wallMat; else if(obj.type==='cabinet'||obj.type==='island'||obj.type==='vanity') mat=cabinetMat; else if(obj.type==='floor') mat=floorMat; const mesh = new THREE.Mesh(geo,mat); mesh.position.set(p.x,p.y,p.z); mesh.castShadow=true; mesh.receiveShadow=true; mesh.userData.sceneObject=obj; this.root.add(mesh); }); // Simple sink and hob make a kitchen concept visually legible. if (/kitchen/.test(spec.projectType || '')) { const sink = new THREE.Mesh(new THREE.BoxGeometry(.52,.035,.38), metalMat); sink.position.set(-.45,1.005,-(spec.roomScale?.lengthM||5)/2+.55); sink.userData.sceneObject={id:'sink',type:'sink',dimensions:{x:.52,y:.035,z:.38},properties:{role:'sink cut-out'}}; this.root.add(sink); const hob = new THREE.Mesh(new THREE.BoxGeometry(.58,.025,.42), new THREE.MeshStandardMaterial({color:0x111111,roughness:.16})); hob.position.set(.55,1.01,-(spec.roomScale?.lengthM||5)/2+.55); hob.userData.sceneObject={id:'hob',type:'hob',dimensions:{x:.58,y:.025,z:.42},properties:{role:'hob cut-out'}}; this.root.add(hob); } const c=spec.camera?.position || [4,2.2,5]; this.camera.position.set(...c); const t=spec.camera?.target || [0,1,0]; this.controls.target.set(...t); this.controls.update(); } pick(e){ const r=this.renderer.domElement.getBoundingClientRect(); this.pointer.x=((e.clientX-r.left)/r.width)*2-1; this.pointer.y=-((e.clientY-r.top)/r.height)*2+1; this.raycaster.setFromCamera(this.pointer,this.camera); const hit=this.raycaster.intersectObjects(this.root.children,false)[0]; if(hit?.object?.userData?.sceneObject) this.onSelect(hit.object.userData.sceneObject); } resetCamera(spec){ const c=spec?.camera?.position||[4,2.2,5],t=spec?.camera?.target||[0,1,0]; this.camera.position.set(...c); this.controls.target.set(...t); this.controls.update(); } downloadPNG(filename='prime-marble-design.png'){ this.renderer.render(this.scene,this.camera); const a=document.createElement('a'); a.download=filename; a.href=this.renderer.domElement.toDataURL('image/png'); a.click(); return a.href; } resize(){ const w=this.container.clientWidth,h=this.container.clientHeight||440; this.renderer.setSize(w,h,false); this.camera.aspect=w/h; this.camera.updateProjectionMatrix(); } animate(){ this._raf=requestAnimationFrame(()=>this.animate()); this.controls.update(); this.renderer.render(this.scene,this.camera); } } /** * Neutral scene language shared by the browser designer and Blender worker. */ /** @typedef {{ x: number, y: number, z: number }} Vector3 */ /** @typedef {{ widthMm: number|null, lengthMm: number|null, heightMm: number|null, walls: Object[], doors: Object[], windows: Object[] }} RoomGeometry */ /** @typedef {{ id: string, type: string, position: Vector3, dimensions: Vector3, materialId?: string, properties?: Object }} SceneObject */ /** @typedef {{ version: string, sourceProjectId: string, room: RoomGeometry, objects: SceneObject[], materialAssignments: Object[], lighting: Object, camera: Object }} SceneSpecification */ export const SCENE_SPECIFICATION_VERSION = "1.0.0"; export function createSceneSpecification({ projectId, projectType, room, stoneId, materialName, finish, thicknessMm, designPrompt, style }) { const widthM = (room.widthMm ?? 4000) / 1000; const lengthM = (room.lengthMm ?? 5000) / 1000; const heightM = (room.heightMm ?? 2400) / 1000; const stoneName = materialName || "Calacatta Viola"; const selectedStone = stoneId || "calacatta-viola"; const thickness = thicknessMm ?? 20; const objectList = []; const materialAssignments = []; const pushObject = (entry) => { objectList.push(entry); materialAssignments.push({ objectId: entry.id, materialId: selectedStone, materialName: stoneName, finish, thicknessMm: thickness, stoneId: selectedStone, veinDirection: "various", bookmatch: /splashback|feature wall|bookmatch/i.test(designPrompt || "") }); }; pushObject({ id: "room-floor", type: "floor", position: { x: 0, y: -0.06, z: 0 }, dimensions: { x: widthM, y: 0.12, z: lengthM }, materialId: selectedStone, properties: { finish, stoneName } }); pushObject({ id: "room-back-wall", type: "wall", position: { x: 0, y: heightM / 2, z: -lengthM / 2 }, dimensions: { x: widthM, y: heightM, z: 0.12 }, materialId: selectedStone, properties: { finish, stoneName, role: "back-wall" } }); pushObject({ id: "room-side-wall-left", type: "wall", position: { x: -widthM / 2, y: heightM / 2, z: 0 }, dimensions: { x: 0.12, y: heightM, z: lengthM }, materialId: selectedStone, properties: { finish, stoneName, role: "side-wall" } }); pushObject({ id: "room-side-wall-right", type: "wall", position: { x: widthM / 2, y: heightM / 2, z: 0 }, dimensions: { x: 0.12, y: heightM, z: lengthM }, materialId: selectedStone, properties: { finish, stoneName, role: "side-wall" } }); const baseRunWidth = Math.min(widthM * 0.82, 2.6); const baseCabinetDepth = 0.61; const counterHeight = 0.92; const baseX = -baseRunWidth / 2 + 0.2; const baseZ = -lengthM / 2 + 0.55; for (let index = 0; index < 3; index += 1) { const cabinetX = baseX + index * 0.78; pushObject({ id: `base-cabinet-${index + 1}`, type: "cabinet", position: { x: cabinetX, y: counterHeight / 2, z: baseZ }, dimensions: { x: 0.72, y: counterHeight, z: baseCabinetDepth }, materialId: selectedStone, properties: { finish, stoneName, role: "base-cabinet" } }); } const worktopWidth = baseRunWidth - 0.18; pushObject({ id: "worktop-run", type: "worktop", position: { x: 0, y: counterHeight + 0.05, z: baseZ }, dimensions: { x: worktopWidth, y: 0.06, z: baseCabinetDepth + 0.08 }, materialId: selectedStone, properties: { finish, stoneName, thicknessMm: thickness, role: "worktop" } }); const splashbackHeight = Math.min(heightM * 0.7, 1.8); pushObject({ id: "splashback-panel", type: "splashback", position: { x: 0, y: splashbackHeight / 2 + 0.92, z: -lengthM / 2 + 0.12 }, dimensions: { x: worktopWidth, y: splashbackHeight, z: 0.12 }, materialId: selectedStone, properties: { finish, stoneName, fullHeight: /full-height|full height/i.test(designPrompt || ""), role: "splashback" } }); const islandWidth = /island\s*(\d+)\s*mm\s*x\s*(\d+)\s*mm|island\s*(\d+)\s*x\s*(\d+)/i.test(designPrompt || "") ? 2.4 : Math.min(widthM * 0.46, 2.4); const islandDepth = 1.1; const islandCenter = { x: 0.8, y: 0, z: 0.1 }; pushObject({ id: "kitchen-island-base", type: "island", position: { x: islandCenter.x, y: 0.45, z: islandCenter.z }, dimensions: { x: islandWidth, y: 0.9, z: islandDepth }, materialId: selectedStone, properties: { finish, stoneName, role: "island" } }); pushObject({ id: "kitchen-island-top", type: "worktop", position: { x: islandCenter.x, y: 0.95, z: islandCenter.z }, dimensions: { x: islandWidth + 0.12, y: 0.08, z: islandDepth + 0.12 }, materialId: selectedStone, properties: { finish, stoneName, waterfall: /waterfall/i.test(designPrompt || ""), role: "island-top" } }); if (/waterfall|waterfall both ends/i.test(designPrompt || "")) { pushObject({ id: "island-waterfall-left", type: "waterfall", position: { x: islandCenter.x - islandWidth / 2 - 0.06, y: 0.45, z: islandCenter.z }, dimensions: { x: 0.1, y: 0.9, z: islandDepth }, materialId: selectedStone, properties: { finish, stoneName, role: "waterfall-left" } }); pushObject({ id: "island-waterfall-right", type: "waterfall", position: { x: islandCenter.x + islandWidth / 2 + 0.06, y: 0.45, z: islandCenter.z }, dimensions: { x: 0.1, y: 0.9, z: islandDepth }, materialId: selectedStone, properties: { finish, stoneName, role: "waterfall-right" } }); } pushObject({ id: "room-vanity", type: "vanity", position: { x: -1.7, y: 0.5, z: 0.7 }, dimensions: { x: 1.2, y: 0.96, z: 0.56 }, materialId: selectedStone, properties: { finish, stoneName, projectType } }); const spec = { version: SCENE_SPECIFICATION_VERSION, sourceProjectId: projectId || "designer-project", room: { widthMm: room.widthMm ?? 4000, lengthMm: room.lengthMm ?? 5000, heightMm: room.heightMm ?? 2400, walls: [], doors: [], windows: [] }, objects: objectList, materialAssignments, lighting: { preset: "natural-daylight", ambientIntensity: 1.0, directionalIntensity: 1.5, direction: [1, 2, 1], color: "#f7f0e8" }, camera: { position: [widthM * 0.9, heightM * 0.72, lengthM * 0.95], target: [0, 1.1, 0], mode: "isometric" }, projectType, style, designPrompt, roomScale: { widthM, lengthM, heightM }, stoneId: selectedStone, stoneName }; return spec; } import { MATERIALS, PROJECT_TYPES, STYLES, createEmptyProject } from "./designer/data.js?v=3"; import { generateDesign } from "./designer/ai-design-service.js?v=3"; import { PrimeMarbleViewer } from "./designer/three-viewer.js?v=3"; const project = createEmptyProject(); let currentStep = 1; let selectedUnit = "mm"; let generatedResult = null; let viewer = null; const $ = (selector) => document.querySelector(selector); const $$ = (selector) => Array.from(document.querySelectorAll(selector)); initNavigation(); renderProjectTypes(); renderStyles(); renderMaterials(); renderMaterialSamples(); bindWizard(); restoreDraft(); function initNavigation() { const toggle = $(".navToggle"); const menu = $(".menuWrap"); const close = $(".navClose"); const setMenu = (open) => { if (!toggle || !menu) return; menu.classList.toggle("open", open); toggle.setAttribute("aria-expanded", String(open)); document.body.style.overflow = open ? "hidden" : ""; }; toggle?.addEventListener("click", () => setMenu(!menu.classList.contains("open"))); close?.addEventListener("click", () => setMenu(false)); menu?.querySelectorAll("a").forEach((link) => link.addEventListener("click", () => setMenu(false))); document.addEventListener("keydown", (event) => { if (event.key === "Escape") setMenu(false); }); } function renderProjectTypes() { $("#projectTypes").innerHTML = PROJECT_TYPES.map((type) => ` `).join(""); $$("[data-project-type]").forEach((button) => button.addEventListener("click", () => { project.projectType = button.dataset.projectType; $$("[data-project-type]").forEach((item) => item.classList.toggle("selected", item === button)); })); } function renderStyles() { $("#styleOptions").innerHTML = STYLES.map((style) => ``).join(""); $$("[data-style]").forEach((button) => button.addEventListener("click", () => { project.style = button.dataset.style; $$("[data-style]").forEach((item) => item.classList.toggle("selected", item === button)); })); } function renderMaterials() { const category = $("#materialCategory")?.value || "all"; const visibleMaterials = MATERIALS.filter((material) => category === "all" || material.category === category); $("#materials").innerHTML = visibleMaterials.map((material) => ` `).join(""); $$("[data-material-id]").forEach((button) => button.addEventListener("click", () => { const material = MATERIALS.find((item) => item.id === button.dataset.materialId); const selected = project.materials.some((item) => item.id === material.id); if (selected) project.materials = project.materials.filter((item) => item.id !== material.id); else if (project.materials.length < 3) project.materials.push(material); renderMaterials(); renderMaterialSamples(); })); } function renderMaterialSamples() { const grid = $("#materialsSampleGrid"); if (!grid) return; grid.innerHTML = MATERIALS.slice(0, 6).map((material) => `
${material.name} sample
${material.category}

${material.name}

${material.description}

${material.finish} · ${(Array.isArray(material.thicknesses) ? material.thicknesses : [material.thicknesses]).filter(Boolean).map((value) => `${value} mm`).join(" / ") || "20 mm"}
`).join(""); $$("[data-sample-material-id]").forEach((button) => button.addEventListener("click", () => { const material = MATERIALS.find((item) => item.id === button.dataset.sampleMaterialId); if (!material) return; project.materials = [material]; renderMaterials(); renderMaterialSamples(); showNotice(`${material.name} selected for this design brief.`); $("#designer-app")?.scrollIntoView({ behavior: "smooth", block: "start" }); })); } function bindWizard() { $("#materialCategory").addEventListener("change", renderMaterials); $("#spacePhotos").addEventListener("change", handlePhotoUpload); $$("[data-unit]").forEach((button) => button.addEventListener("click", () => setUnit(button.dataset.unit))); $$("[data-next]").forEach((button) => button.addEventListener("click", () => changeStep(1))); $$("[data-back]").forEach((button) => button.addEventListener("click", () => changeStep(-1))); $$("[data-step-button]").forEach((button) => button.addEventListener("click", () => { const target = Number(button.dataset.stepButton); if (target <= currentStep || validateStep(currentStep)) showStep(target); })); $("#generateDesign").addEventListener("click", handleGenerate); $("#saveProject").addEventListener("click", () => { saveDraft(); showNotice("Project saved locally on this device."); }); $("#generateRender").addEventListener("click", () => { if (!viewer || !generatedResult) return showNotice("Generate a 3D concept first."); viewer.downloadPNG(`prime-marble-${project.projectType || "design"}.png`); project.renders.push({ createdAt: new Date().toISOString(), type: "png" }); saveDraft(); showNotice("Render exported as a PNG from the current 3D camera view."); }); $("#resetCamera").addEventListener("click", () => viewer?.resetCamera(generatedResult?.sceneSpecification)); $("#requestQuote").addEventListener("click", () => { project.quoteRequestStatus = "Ready to package"; saveDraft(); showNotice("Your design brief is ready to take into a quote conversation. Connect with a specialist through the existing quote form."); window.setTimeout(() => { window.location.href = "contact.html"; }, 900); }); $$("[data-workspace-tab]").forEach((button) => button.addEventListener("click", () => { $$("[data-workspace-tab]").forEach((item) => item.classList.toggle("active", item === button)); showNotice(`${button.textContent} panel selected. Detailed controls will arrive with the interactive viewer.`); })); } function handlePhotoUpload(event) { const files = Array.from(event.target.files).slice(0, 10); project.photos = files.map((file) => ({ name: file.name, size: file.size, type: file.type, previewUrl: URL.createObjectURL(file) })); renderPhotoPreviews(); } function renderPhotoPreviews() { $("#photoPreview").innerHTML = project.photos.map((photo, index) => `
Uploaded space view ${index + 1}
`).join(""); $("#photoHint").textContent = project.photos.length ? `${project.photos.length} photograph${project.photos.length === 1 ? "" : "s"} ready for this local draft.` : "No photographs selected yet. You can continue without them while exploring the prototype."; $$('[data-remove-photo]').forEach((button) => button.addEventListener("click", () => { const [removed] = project.photos.splice(Number(button.dataset.removePhoto), 1); if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl); renderPhotoPreviews(); })); } function setUnit(unit) { selectedUnit = unit; project.room.measurementUnit = unit; $$("[data-unit]").forEach((button) => button.classList.toggle("active", button.dataset.unit === unit)); $$(".unitLabel").forEach((label) => { label.textContent = unit; }); } function collectForm() { const form = new FormData($("#designerWizard")); project.room.width = form.get("roomWidth") || null; project.room.length = form.get("roomLength") || null; project.room.height = form.get("roomHeight") || null; project.measurements = ["door", "window", "worktop", "island", "otherWall"].filter((name) => form.get(name)).map((name) => ({ name, value: form.get(name) })); project.designPrompt = form.get("designPrompt") || ""; project.updatedAt = new Date().toISOString(); } function validateStep(step) { collectForm(); if (step === 1 && !project.projectType) { showNotice("Choose a project type to continue."); return false; } if (step === 3 && !$("#designerWizard").reportValidity()) { showNotice("Add the room width, length and ceiling height to continue."); return false; } return true; } function changeStep(delta) { if (delta > 0 && !validateStep(currentStep)) return; showStep(Math.min(6, Math.max(1, currentStep + delta))); } function showStep(step) { currentStep = step; collectForm(); $$(".wizardStep").forEach((section) => { const active = Number(section.dataset.step) === step; section.classList.toggle("active", active); section.hidden = !active; }); $$(".stepList li").forEach((item, index) => { item.classList.toggle("active", index + 1 === step); item.classList.toggle("done", index + 1 < step); }); $("#stepCounter").textContent = `0${step} / 06`; if (step === 6) renderReview(); } function renderReview() { const type = PROJECT_TYPES.find((item) => item.id === project.projectType)?.name || "Not selected"; const materials = project.materials.map((item) => item.name).join(", ") || "No material selected"; const dimensions = [project.room.width, project.room.length, project.room.height].every(Boolean) ? `${project.room.width} × ${project.room.length} × ${project.room.height} ${selectedUnit}` : "Not added"; $("#reviewSummary").innerHTML = [["Project", type], ["Scale", dimensions], ["Style", project.style || "Not selected"], ["Material samples", materials], ["Space photographs", `${project.photos.length} local upload${project.photos.length === 1 ? "" : "s"}`], ["Brief", project.designPrompt || "No written brief yet"]].map(([label, value]) => `
${label}${value}
`).join(""); } async function handleGenerate() { if (!validateStep(6)) return; const button = $("#generateDesign"); button.disabled = true; button.innerHTML = "Preparing concept…"; try { generatedResult = await generateDesign(project); project.status = "Design Generated"; project.updatedAt = new Date().toISOString(); project.sceneSpecification = generatedResult.sceneSpecification; saveDraft(); updateWorkspace(); $("#workspace").hidden = false; render3DModel(); $("#workspace").scrollIntoView({ behavior: "smooth" }); } catch (error) { console.error("Prime Marble 3D generation failed", error); $("#workspace").hidden = false; showNotice(`3D generation failed: ${error?.message || "Unknown browser error"}. Please refresh and try again.`); $("#workspace").scrollIntoView({ behavior: "smooth" }); } finally { button.disabled = false; button.innerHTML = 'Generate concept '; } } function updateWorkspace() { const type = PROJECT_TYPES.find((item) => item.id === project.projectType)?.name || "Stone project"; const material = project.materials[0]; $("#workspaceTitle").textContent = `${type} concept`; $("#workspaceStatusText").textContent = generatedResult?.status === "ready" ? "Live 3D concept ready" : "Draft"; $("#selectedMaterial").textContent = material?.name || "Not selected"; $("#selectedDimensions").textContent = project.room.width ? `${project.room.width} × ${project.room.length} × ${project.room.height} ${selectedUnit}` : "Add room measurements"; } function render3DModel() { if (!generatedResult?.sceneSpecification) return; const container = $("#threeViewport"); if (!viewer) viewer = new PrimeMarbleViewer(container, updateSelectedObject); viewer.load(generatedResult.sceneSpecification, project.materials[0]); $("#sceneVersion").textContent = `v${generatedResult.sceneSpecification.version}`; } function updateSelectedObject(obj) { $("#selectedObjectName").textContent = obj.id.replaceAll("-", " ").replace(/\b\w/g, c => c.toUpperCase()); $("#selectedObjectMeta").textContent = obj.type || "Scene object"; const d = obj.dimensions; if (d) $("#selectedDimensions").textContent = `${Math.round(d.x*1000)} × ${Math.round(d.y*1000)} × ${Math.round(d.z*1000)} mm`; } function saveDraft() { localStorage.setItem("prime-marble-ai-designer-draft", JSON.stringify({ ...project, photos: project.photos.map(({ name, size, type }) => ({ name, size, type })) })); $("#saveState").textContent = "Saved locally · just now"; } function restoreDraft() { const saved = localStorage.getItem("prime-marble-ai-designer-draft"); if (!saved) return; try { const draft = JSON.parse(saved); Object.assign(project, draft, { photos: [] }); selectedUnit = project.room.measurementUnit || "mm"; setUnit(selectedUnit); if (project.projectType) $(`[data-project-type="${project.projectType}"]`)?.classList.add("selected"); if (project.style) $(`[data-style="${project.style}"]`)?.classList.add("selected"); renderMaterials(); $("[name=roomWidth]").value = project.room.width || ""; $("[name=roomLength]").value = project.room.length || ""; $("[name=roomHeight]").value = project.room.height || ""; $("#designPrompt").value = project.designPrompt || ""; $("#saveState").textContent = "Saved local draft restored"; } catch (error) { console.warn("Could not restore designer draft", error); } } function showNotice(message) { const notice = $("#workspaceNotice"); notice.textContent = message; notice.hidden = false; window.clearTimeout(showNotice.timeout); showNotice.timeout = window.setTimeout(() => { notice.hidden = true; }, 6000); }