From 964be0370e8f6ec027f092a5a13b426a6218e2c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:00:42 +0000 Subject: [PATCH] Make diorama and sample share the plan code path --- ventures/dollhouse/app.js | 1044 +++++++++++++++++++++++-------------- 1 file changed, 655 insertions(+), 389 deletions(-) diff --git a/ventures/dollhouse/app.js b/ventures/dollhouse/app.js index 20779d6..d9b554f 100644 --- a/ventures/dollhouse/app.js +++ b/ventures/dollhouse/app.js @@ -1,4 +1,6 @@ // Dollhouse Application Logic +// Every rendered result — including the preloaded sample — comes from the same +// plan object through the same render path. Nothing is drawn from fixed points. class DollhouseApp { constructor() { @@ -10,30 +12,111 @@ class DollhouseApp { this.selectedTool = 'wall'; this.isDrawing = false; this.startPoint = null; + this.previewWall = null; this.furnitureLibrary = { couch: { name: 'Couch', width: 6, height: 3, color: '#8B4513' }, bed: { name: 'Bed', width: 6, height: 4, color: '#4682B4' }, desk: { name: 'Desk', width: 4, height: 2, color: '#696969' }, plant: { name: 'Plant', width: 1, height: 1, color: '#228B22' }, - tv: { name: 'TV', width: 3, height: 1, color: '#000000' } + tv: { name: 'TV', width: 3, height: 1, color: '#1a1a1a' } }; - + this.init(); } init() { this.createGrid(); - this.setupEventListeners(); this.setupToolbar(); + this.setupEventListeners(); this.loadFromURL(); this.render(); } + // ---- sample is real input, not a canned picture ---- + + samplePlan() { + // A one-bedroom studio: outer walls, a short partition, and a realistic set + // of furniture. This object is applied with applyPlan(), the same function + // that loads a shared URL and that user edits flow through. + return { + walls: [ + { x1: 2, y1: 2, x2: 22, y2: 2 }, + { x1: 22, y1: 2, x2: 22, y2: 16 }, + { x1: 22, y1: 16, x2: 2, y2: 16 }, + { x1: 2, y1: 16, x2: 2, y2: 2 }, + { x1: 14, y1: 2, x2: 14, y2: 9 } + ], + furniture: [ + { type: 'bed', x: 4, y: 4 }, + { type: 'desk', x: 16, y: 4 }, + { type: 'couch', x: 4, y: 11 }, + { type: 'tv', x: 12, y: 14 }, + { type: 'plant', x: 19, y: 13 }, + { type: 'plant', x: 3, y: 3 } + ] + }; + } + + applyPlan(plan) { + const walls = Array.isArray(plan && plan.walls) ? plan.walls : []; + const furniture = Array.isArray(plan && plan.furniture) ? plan.furniture : []; + this.walls = walls + .map((w) => this.normalizeWall(w)) + .filter(Boolean); + this.furniture = furniture + .map((item) => this.normalizeFurniture(item)) + .filter(Boolean); + } + + normalizeWall(w) { + if (!w) return null; + const x1 = this.clamp(this.num(w.x1 != null ? w.x1 : w.startX), 0, this.gridWidth - 1); + const y1 = this.clamp(this.num(w.y1 != null ? w.y1 : w.startY), 0, this.gridHeight - 1); + const x2 = this.clamp(this.num(w.x2 != null ? w.x2 : w.endX), 0, this.gridWidth - 1); + const y2 = this.clamp(this.num(w.y2 != null ? w.y2 : w.endY), 0, this.gridHeight - 1); + if (x1 === x2 && y1 === y2) return null; + // Walls are axis-aligned segments. A diagonal drag snaps to the longer axis + // so the diorama always reflects a real wall the user drew. + if (x1 !== x2 && y1 !== y2) { + if (Math.abs(x2 - x1) >= Math.abs(y2 - y1)) return { x1, y1, x2, y2: y1 }; + return { x1, y1, x2: x1, y2 }; + } + return { x1, y1, x2, y2 }; + } + + normalizeFurniture(item) { + if (!item || !this.furnitureLibrary[item.type]) return null; + const spec = this.furnitureLibrary[item.type]; + const x = this.clamp(this.num(item.x), 0, this.gridWidth - spec.width); + const y = this.clamp(this.num(item.y), 0, this.gridHeight - spec.height); + return { + type: item.type, + x, + y, + name: spec.name, + width: spec.width, + height: spec.height, + color: spec.color + }; + } + + num(v) { + const n = Number(v); + return Number.isFinite(n) ? Math.round(n) : 0; + } + + clamp(n, min, max) { + return Math.max(min, Math.min(max, n)); + } + + // ---- grid ---- + createGrid() { const gridContainer = document.getElementById('grid-container'); gridContainer.innerHTML = ''; - - // Create grid cells + gridContainer.style.width = `${this.gridWidth * this.gridSize}px`; + gridContainer.style.height = `${this.gridHeight * this.gridSize}px`; + for (let y = 0; y < this.gridHeight; y++) { for (let x = 0; x < this.gridWidth; x++) { const cell = document.createElement('div'); @@ -47,136 +130,143 @@ class DollhouseApp { gridContainer.appendChild(cell); } } - - // Set container size - gridContainer.style.width = `${this.gridWidth * this.gridSize}px`; - gridContainer.style.height = `${this.gridHeight * this.gridSize}px`; + } + + cellFromEvent(e) { + const grid = document.getElementById('grid-container'); + const rect = grid.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / this.gridSize); + const y = Math.floor((e.clientY - rect.top) / this.gridSize); + if (x < 0 || y < 0 || x >= this.gridWidth || y >= this.gridHeight) return null; + return { x, y }; } setupEventListeners() { const gridContainer = document.getElementById('grid-container'); - + gridContainer.addEventListener('mousedown', (e) => { - if (e.target.classList.contains('grid-cell')) { + if (e.button !== 0) return; + const cell = this.cellFromEvent(e); + if (!cell) return; + e.preventDefault(); + if (this.selectedTool === 'wall') { this.isDrawing = true; - const x = parseInt(e.target.dataset.x); - const y = parseInt(e.target.dataset.y); - - if (this.selectedTool === 'wall') { - this.startPoint = { x, y }; - } else if (this.selectedTool.startsWith('furniture-')) { - const furnitureType = this.selectedTool.split('-')[1]; - this.addFurniture(x, y, furnitureType); - } + this.startPoint = cell; + this.previewWall = null; + } else if (this.selectedTool.indexOf('furniture-') === 0) { + const furnitureType = this.selectedTool.slice('furniture-'.length); + this.addFurniture(cell.x, cell.y, furnitureType); } }); - - gridContainer.addEventListener('mousemove', (e) => { - if (this.isDrawing && e.target.classList.contains('grid-cell')) { - // Visual feedback during drawing - } - }); - - gridContainer.addEventListener('mouseup', (e) => { - if (this.isDrawing && e.target.classList.contains('grid-cell') && this.selectedTool === 'wall') { - this.isDrawing = false; - const x = parseInt(e.target.dataset.x); - const y = parseInt(e.target.dataset.y); - - if (this.startPoint) { - this.addWall(this.startPoint.x, this.startPoint.y, x, y); - this.startPoint = null; - } - } - }); - - gridContainer.addEventListener('mouseleave', () => { - this.isDrawing = false; - this.startPoint = null; - }); - - // Toolbar buttons - document.querySelectorAll('[data-tool]').forEach(button => { - button.addEventListener('click', (e) => { - this.selectTool(e.target.dataset.tool); + + // Listen on the document so a drag that leaves the grid still finishes + // on the last cell the pointer was over, which is how "drag to draw" works. + document.addEventListener('mousemove', (e) => { + if (!this.isDrawing || this.selectedTool !== 'wall' || !this.startPoint) return; + const cell = this.cellFromEvent(e); + if (!cell) return; + const next = this.normalizeWall({ + x1: this.startPoint.x, + y1: this.startPoint.y, + x2: cell.x, + y2: cell.y }); + this.previewWall = next; + this.renderWalls(); }); - - // Clear button + + document.addEventListener('mouseup', (e) => { + if (!this.isDrawing || this.selectedTool !== 'wall') return; + this.isDrawing = false; + const cell = this.cellFromEvent(e) || this.previewEnd(); + const start = this.startPoint; + this.startPoint = null; + this.previewWall = null; + if (start && cell) this.addWall(start.x, start.y, cell.x, cell.y); + else this.renderWalls(); + }); + document.getElementById('clear-btn').addEventListener('click', () => { this.clearAll(); }); - - // Download button + document.getElementById('download-btn').addEventListener('click', () => { this.downloadDiorama(); }); + + document.getElementById('copy-link-btn').addEventListener('click', () => { + this.copyShareLink(); + }); + } + + previewEnd() { + if (!this.previewWall) return null; + return { x: this.previewWall.x2, y: this.previewWall.y2 }; } setupToolbar() { const toolbar = document.getElementById('toolbar'); - - // Wall tool + toolbar.innerHTML = ''; + const wallBtn = document.createElement('button'); + wallBtn.type = 'button'; wallBtn.className = 'tool-btn'; wallBtn.dataset.tool = 'wall'; wallBtn.textContent = 'Wall'; - wallBtn.title = 'Draw walls'; + wallBtn.title = 'Drag on the grid to draw a wall'; toolbar.appendChild(wallBtn); - - // Furniture tools - Object.keys(this.furnitureLibrary).forEach(key => { + + Object.keys(this.furnitureLibrary).forEach((key) => { const btn = document.createElement('button'); + btn.type = 'button'; btn.className = 'tool-btn'; btn.dataset.tool = `furniture-${key}`; btn.textContent = this.furnitureLibrary[key].name; - btn.title = `Place ${this.furnitureLibrary[key].name}`; + btn.title = `Drop a ${this.furnitureLibrary[key].name} on the grid`; toolbar.appendChild(btn); }); - - // Select wall tool by default + + toolbar.addEventListener('click', (e) => { + const button = e.target.closest('[data-tool]'); + if (!button) return; + this.selectTool(button.dataset.tool); + }); + this.selectTool('wall'); } selectTool(tool) { this.selectedTool = tool; - document.querySelectorAll('.tool-btn').forEach(btn => { - btn.classList.remove('active'); - if (btn.dataset.tool === tool) { - btn.classList.add('active'); - } + this.isDrawing = false; + this.startPoint = null; + this.previewWall = null; + document.querySelectorAll('.tool-btn').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.tool === tool); }); } - addWall(startX, startY, endX, endY) { - // Normalize coordinates - const minX = Math.min(startX, endX); - const maxX = Math.max(startX, endX); - const minY = Math.min(startY, endY); - const maxY = Math.max(startY, endY); - - // Create wall object - const wall = { - startX: minX, - startY: minY, - endX: maxX, - endY: maxY - }; - + addWall(x1, y1, x2, y2) { + const wall = this.normalizeWall({ x1, y1, x2, y2 }); + if (!wall) return; + const key = this.wallKey(wall); + if (this.walls.some((w) => this.wallKey(w) === key)) return; this.walls.push(wall); this.render(); this.updateURL(); } + wallKey(w) { + const ax = Math.min(w.x1, w.x2); + const ay = Math.min(w.y1, w.y2); + const bx = Math.max(w.x1, w.x2); + const by = Math.max(w.y1, w.y2); + return `${ax},${ay},${bx},${by}`; + } + addFurniture(x, y, type) { - const furnitureItem = { - x, - y, - type, - ...this.furnitureLibrary[type] - }; - - this.furniture.push(furnitureItem); + const item = this.normalizeFurniture({ type, x, y }); + if (!item) return; + this.furniture.push(item); this.render(); this.updateURL(); } @@ -184,268 +274,474 @@ class DollhouseApp { clearAll() { this.walls = []; this.furniture = []; + this.previewWall = null; this.render(); this.updateURL(); } + // ---- render: grid overlay and diorama both read this.walls / this.furniture ---- + render() { this.renderWalls(); this.renderFurniture(); this.renderDiorama(); + this.renderShare(); } renderWalls() { const wallContainer = document.getElementById('walls-container'); wallContainer.innerHTML = ''; - - this.walls.forEach(wall => { - const wallElement = document.createElement('div'); - wallElement.className = 'wall'; - - // Calculate wall dimensions - const width = (wall.endX - wall.startX + 1) * this.gridSize; - const height = (wall.endY - wall.startY + 1) * this.gridSize; - const left = wall.startX * this.gridSize; - const top = wall.startY * this.gridSize; - - wallElement.style.width = `${width}px`; - wallElement.style.height = `${height}px`; - wallElement.style.left = `${left}px`; - wallElement.style.top = `${top}px`; - - wallContainer.appendChild(wallElement); - }); + const draw = (wall, preview) => { + const el = document.createElement('div'); + el.className = preview ? 'wall wall-preview' : 'wall'; + const horizontal = wall.y1 === wall.y2; + const x = Math.min(wall.x1, wall.x2); + const y = Math.min(wall.y1, wall.y2); + const length = horizontal + ? Math.abs(wall.x2 - wall.x1) + : Math.abs(wall.y2 - wall.y1); + const thickness = 0.35; + if (horizontal) { + el.style.left = `${x * this.gridSize}px`; + el.style.top = `${(y - thickness / 2) * this.gridSize}px`; + el.style.width = `${length * this.gridSize}px`; + el.style.height = `${Math.max(4, thickness * this.gridSize)}px`; + } else { + el.style.left = `${(x - thickness / 2) * this.gridSize}px`; + el.style.top = `${y * this.gridSize}px`; + el.style.width = `${Math.max(4, thickness * this.gridSize)}px`; + el.style.height = `${length * this.gridSize}px`; + } + wallContainer.appendChild(el); + }; + this.walls.forEach((wall) => draw(wall, false)); + if (this.previewWall) draw(this.previewWall, true); } renderFurniture() { const furnitureContainer = document.getElementById('furniture-container'); furnitureContainer.innerHTML = ''; - - this.furniture.forEach(item => { - const furnitureElement = document.createElement('div'); - furnitureElement.className = 'furniture'; - furnitureElement.textContent = item.name.charAt(0); - furnitureElement.title = item.name; - - // Position furniture - const left = item.x * this.gridSize; - const top = item.y * this.gridSize; - const width = item.width * this.gridSize; - const height = item.height * this.gridSize; - - furnitureElement.style.width = `${width}px`; - furnitureElement.style.height = `${height}px`; - furnitureElement.style.left = `${left}px`; - furnitureElement.style.top = `${top}px`; - furnitureElement.style.backgroundColor = item.color; - - furnitureContainer.appendChild(furnitureElement); + this.furniture.forEach((item, index) => { + const el = document.createElement('div'); + el.className = 'furniture'; + el.textContent = item.name; + el.title = item.name; + el.style.left = `${item.x * this.gridSize}px`; + el.style.top = `${item.y * this.gridSize}px`; + el.style.width = `${item.width * this.gridSize}px`; + el.style.height = `${item.height * this.gridSize}px`; + el.style.backgroundColor = item.color; + el.addEventListener('mousedown', (e) => { + if (this.selectedTool === 'wall') return; + e.stopPropagation(); + e.preventDefault(); + this.dragFurniture(index, e); + }); + furnitureContainer.appendChild(el); }); } + dragFurniture(index, downEvent) { + const item = this.furniture[index]; + if (!item) return; + const origin = this.cellFromEvent(downEvent); + if (!origin) return; + const dx = item.x - origin.x; + const dy = item.y - origin.y; + const move = (e) => { + const cell = this.cellFromEvent(e); + if (!cell) return; + const next = this.normalizeFurniture({ + type: item.type, + x: cell.x + dx, + y: cell.y + dy + }); + if (!next) return; + this.furniture[index] = next; + this.render(); + }; + const up = () => { + document.removeEventListener('mousemove', move); + document.removeEventListener('mouseup', up); + this.updateURL(); + }; + document.addEventListener('mousemove', move); + document.addEventListener('mouseup', up); + } + + // ---- isometric diorama: geometry is derived from the plan, never fixed ---- + + planBounds() { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + const touch = (x, y) => { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + }; + this.walls.forEach((w) => { + touch(w.x1, w.y1); + touch(w.x2, w.y2); + }); + this.furniture.forEach((item) => { + touch(item.x, item.y); + touch(item.x + item.width, item.y + item.height); + }); + if (!Number.isFinite(minX)) { + return { minX: 0, minY: 0, maxX: 12, maxY: 10 }; + } + const pad = 1; + return { + minX: minX - pad, + minY: minY - pad, + maxX: maxX + pad, + maxY: maxY + pad + }; + } + renderDiorama() { - const diorama = document.getElementById('diorama-svg'); - diorama.innerHTML = ''; - - // Set viewBox for consistent sizing - diorama.setAttribute('viewBox', '0 0 800 600'); - diorama.setAttribute('preserveAspectRatio', 'xMidYMid meet'); - - // Isometric projection settings - const isoScale = 20; - const offsetX = 400; - const offsetY = 100; - - // Draw floor (isometric projection) - this.drawIsometricFloor(diorama, offsetX, offsetY, isoScale); - - // Draw walls with isometric projection - this.walls.forEach(wall => { - this.drawIsometricWall(diorama, wall, offsetX, offsetY, isoScale); - }); - - // Draw furniture with isometric projection - this.furniture.forEach(item => { - this.drawIsometricFurniture(diorama, item, offsetX, offsetY, isoScale); - }); - } + const svg = document.getElementById('diorama-svg'); + const bounds = this.planBounds(); + const spanX = Math.max(4, bounds.maxX - bounds.minX); + const spanY = Math.max(4, bounds.maxY - bounds.minY); + // Classic 2:1 isometric. Scale so the room, its walls and furniture fit. + const tile = Math.min(28, Math.floor(Math.min(520 / spanX, 340 / spanY))); + const wallH = Math.round(tile * 1.35); + const project = (gx, gy, gz) => { + const x = (gx - bounds.minX) * tile; + const y = (gy - bounds.minY) * tile; + const z = (gz || 0); + return { + x: (x - y) * 0.5, + y: (x + y) * 0.25 - z + }; + }; - // Draw isometric floor - drawIsometricFloor(svg, offsetX, offsetY, scale) { - // Define isometric floor points - const points = [ - {x: offsetX, y: offsetY + 10 * scale}, // Bottom left - {x: offsetX + 15 * scale, y: offsetY}, // Top center - {x: offsetX + 30 * scale, y: offsetY + 10 * scale}, // Bottom right - {x: offsetX + 15 * scale, y: offsetY + 20 * scale} // Bottom center + const corners = [ + project(bounds.minX, bounds.minY, 0), + project(bounds.maxX, bounds.minY, 0), + project(bounds.maxX, bounds.maxY, 0), + project(bounds.minX, bounds.maxY, 0), + project(bounds.minX, bounds.minY, wallH), + project(bounds.maxX, bounds.minY, wallH), + project(bounds.maxX, bounds.maxY, wallH), + project(bounds.minX, bounds.maxY, wallH) ]; - - // Create floor polygon - const floor = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); - const pointsStr = points.map(p => `${p.x},${p.y}`).join(' '); - floor.setAttribute('points', pointsStr); - floor.setAttribute('fill', '#f0f0f0'); - floor.setAttribute('stroke', '#ccc'); - floor.setAttribute('stroke-width', '1'); - svg.appendChild(floor); + const xs = corners.map((p) => p.x); + const ys = corners.map((p) => p.y); + const pad = 28; + const minPX = Math.min.apply(null, xs) - pad; + const maxPX = Math.max.apply(null, xs) + pad; + const minPY = Math.min.apply(null, ys) - pad; + const maxPY = Math.max.apply(null, ys) + pad; + const width = Math.ceil(maxPX - minPX); + const height = Math.ceil(maxPY - minPY); + const ox = -minPX; + const oy = -minPY; + const P = (gx, gy, gz) => { + const p = project(gx, gy, gz); + return { x: p.x + ox, y: p.y + oy }; + }; + + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + svg.setAttribute('width', '100%'); + svg.setAttribute('height', '400'); + + const ns = 'http://www.w3.org/2000/svg'; + const poly = (points, attrs) => { + const el = document.createElementNS(ns, 'polygon'); + el.setAttribute('points', points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')); + Object.keys(attrs).forEach((k) => el.setAttribute(k, attrs[k])); + return el; + }; + const line = (a, b, attrs) => { + const el = document.createElementNS(ns, 'line'); + el.setAttribute('x1', a.x.toFixed(1)); + el.setAttribute('y1', a.y.toFixed(1)); + el.setAttribute('x2', b.x.toFixed(1)); + el.setAttribute('y2', b.y.toFixed(1)); + Object.keys(attrs).forEach((k) => el.setAttribute(k, attrs[k])); + return el; + }; + + const root = document.createElementNS(ns, 'g'); + root.setAttribute('id', 'diorama-scene'); + + // Floor is the bounding diamond of the actual plan, not a fixed rectangle. + const floor = poly( + [ + P(bounds.minX, bounds.minY, 0), + P(bounds.maxX, bounds.minY, 0), + P(bounds.maxX, bounds.maxY, 0), + P(bounds.minX, bounds.maxY, 0) + ], + { fill: '#f4efe6', stroke: '#d9d0c1', 'stroke-width': '1' } + ); + root.appendChild(floor); + + // Faint grid so the diorama reads as the same plan the user drew. + for (let gx = Math.ceil(bounds.minX); gx <= bounds.maxX; gx++) { + root.appendChild(line( + P(gx, bounds.minY, 0), + P(gx, bounds.maxY, 0), + { stroke: '#e6dfd2', 'stroke-width': '0.6' } + )); + } + for (let gy = Math.ceil(bounds.minY); gy <= bounds.maxY; gy++) { + root.appendChild(line( + P(bounds.minX, gy, 0), + P(bounds.maxX, gy, 0), + { stroke: '#e6dfd2', 'stroke-width': '0.6' } + )); + } + + // Furniture sits on the floor, painted before walls so walls can occlude it + // only where a wall is actually in front. Sort back-to-front by depth. + const pieces = this.furniture + .map((item, index) => ({ item, index, depth: item.x + item.y })) + .sort((a, b) => a.depth - b.depth); + pieces.forEach(({ item }) => { + root.appendChild(this.furnitureGroup(item, P, wallH, ns, poly)); + }); + + // Walls are extruded segments of the walls the user (or the sample) drew. + const wallPieces = this.walls + .map((wall, index) => ({ wall, index, depth: wall.x1 + wall.y1 + wall.x2 + wall.y2 })) + .sort((a, b) => a.depth - b.depth); + wallPieces.forEach(({ wall }) => { + root.appendChild(this.wallGroup(wall, P, wallH, ns, poly)); + }); + + if (this.walls.length === 0 && this.furniture.length === 0) { + const note = document.createElementNS(ns, 'text'); + const c = P((bounds.minX + bounds.maxX) / 2, (bounds.minY + bounds.maxY) / 2, 0); + note.setAttribute('x', c.x.toFixed(1)); + note.setAttribute('y', c.y.toFixed(1)); + note.setAttribute('text-anchor', 'middle'); + note.setAttribute('fill', '#8a8175'); + note.setAttribute('font-size', '14'); + note.setAttribute('font-family', 'sans-serif'); + note.textContent = 'Drag walls on the grid to start your room'; + root.appendChild(note); + } + + svg.innerHTML = ''; + svg.appendChild(root); + this._lastScene = { width, height }; } - // Draw isometric wall - drawIsometricWall(svg, wall, offsetX, offsetY, scale) { - // Convert grid coordinates to isometric - const startX = offsetX + wall.startX * scale; - const startY = offsetY + wall.startY * scale; - const endX = offsetX + wall.endX * scale; - const endY = offsetY + wall.endY * scale; - - // Create isometric wall representation (simple approach) - const wallGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - - // Front face - if (wall.endX > wall.startX) { - const frontFace = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); - const points = [ - {x: startX, y: startY + 10 * scale}, - {x: endX, y: endY + 10 * scale}, - {x: endX, y: endY}, - {x: startX, y: startY} - ]; - const pointsStr = points.map(p => `${p.x},${p.y}`).join(' '); - frontFace.setAttribute('points', pointsStr); - frontFace.setAttribute('fill', '#8B4513'); - frontFace.setAttribute('stroke', '#5D2906'); - frontFace.setAttribute('stroke-width', '0.5'); - wallGroup.appendChild(frontFace); + wallGroup(wall, P, wallH, ns, poly) { + const g = document.createElementNS(ns, 'g'); + g.setAttribute('class', 'iso-wall'); + const thickness = 0.28; + const horizontal = wall.y1 === wall.y2; + const x1 = Math.min(wall.x1, wall.x2); + const x2 = Math.max(wall.x1, wall.x2); + const y1 = Math.min(wall.y1, wall.y2); + const y2 = Math.max(wall.y1, wall.y2); + + // A wall is a thin box standing on the segment between its two endpoints. + let a, b, c, d; + if (horizontal) { + a = { x: x1, y: y1 - thickness / 2 }; + b = { x: x2, y: y1 - thickness / 2 }; + c = { x: x2, y: y1 + thickness / 2 }; + d = { x: x1, y: y1 + thickness / 2 }; + } else { + a = { x: x1 - thickness / 2, y: y1 }; + b = { x: x1 + thickness / 2, y: y1 }; + c = { x: x1 + thickness / 2, y: y2 }; + d = { x: x1 - thickness / 2, y: y2 }; } - - // Side face - if (wall.endY > wall.startY) { - const sideFace = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); - const points = [ - {x: endX, y: endY + 10 * scale}, - {x: endX + 5 * scale, y: endY + 5 * scale}, - {x: endX + 5 * scale, y: endY - 5 * scale}, - {x: endX, y: endY} - ]; - const pointsStr = points.map(p => `${p.x},${p.y}`).join(' '); - sideFace.setAttribute('points', pointsStr); - sideFace.setAttribute('fill', '#6B3503'); - sideFace.setAttribute('stroke', '#5D2906'); - sideFace.setAttribute('stroke-width', '0.5'); - wallGroup.appendChild(sideFace); - } - - svg.appendChild(wallGroup); + const top = [P(a.x, a.y, wallH), P(b.x, b.y, wallH), P(c.x, c.y, wallH), P(d.x, d.y, wallH)]; + const faceA = [P(a.x, a.y, 0), P(b.x, b.y, 0), P(b.x, b.y, wallH), P(a.x, a.y, wallH)]; + const faceB = [P(b.x, b.y, 0), P(c.x, c.y, 0), P(c.x, c.y, wallH), P(b.x, b.y, wallH)]; + const faceC = [P(d.x, d.y, 0), P(c.x, c.y, 0), P(c.x, c.y, wallH), P(d.x, d.y, wallH)]; + const faceD = [P(a.x, a.y, 0), P(d.x, d.y, 0), P(d.x, d.y, wallH), P(a.x, a.y, wallH)]; + + // Painter's order: the two faces the camera can see, then the top. + g.appendChild(poly(faceC, { fill: '#c4b29a', stroke: '#8d7b66', 'stroke-width': '0.6' })); + g.appendChild(poly(faceB, { fill: '#b7a48c', stroke: '#8d7b66', 'stroke-width': '0.6' })); + g.appendChild(poly(faceA, { fill: '#d8c7b0', stroke: '#8d7b66', 'stroke-width': '0.6' })); + g.appendChild(poly(faceD, { fill: '#cbbba4', stroke: '#8d7b66', 'stroke-width': '0.6' })); + g.appendChild(poly(top, { fill: '#efe4d4', stroke: '#8d7b66', 'stroke-width': '0.8' })); + return g; } - // Draw isometric furniture - drawIsometricFurniture(svg, item, offsetX, offsetY, scale) { - // Convert grid coordinates to isometric - const x = offsetX + item.x * scale; - const y = offsetY + item.y * scale; - - // Create isometric furniture representation - const furnitureGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - - // Base of furniture (top-down view in isometric) - const base = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); - const widthScaled = item.width * scale; - const heightScaled = item.height * scale; - - // Isometric base points - const points = [ - {x: x, y: y}, - {x: x + widthScaled * 0.8, y: y - widthScaled * 0.3}, - {x: x + widthScaled * 0.8 + heightScaled * 0.5, y: y - widthScaled * 0.3 + heightScaled * 0.3}, - {x: x + heightScaled * 0.5, y: y + heightScaled * 0.3} - ]; - - const pointsStr = points.map(p => `${p.x},${p.y}`).join(' '); - base.setAttribute('points', pointsStr); - base.setAttribute('fill', item.color); - base.setAttribute('stroke', '#000'); - base.setAttribute('stroke-width', '0.5'); - furnitureGroup.appendChild(base); - - // Add 3D effect for some furniture - if (item.type === 'couch' || item.type === 'bed') { - // Backrest - const back = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); - const backPoints = [ - {x: x, y: y}, - {x: x, y: y - heightScaled * 0.7}, - {x: x + widthScaled * 0.8, y: y - widthScaled * 0.3 - heightScaled * 0.7}, - {x: x + widthScaled * 0.8, y: y - widthScaled * 0.3} - ]; - const backPointsStr = backPoints.map(p => `${p.x},${p.y}`).join(' '); - back.setAttribute('points', backPointsStr); - back.setAttribute('fill', this.darkenColor(item.color, 20)); - back.setAttribute('stroke', '#000'); - back.setAttribute('stroke-width', '0.5'); - furnitureGroup.appendChild(back); + furnitureGroup(item, P, wallH, ns, poly) { + const g = document.createElementNS(ns, 'g'); + g.setAttribute('class', `iso-furniture iso-${item.type}`); + const heights = { couch: 0.55, bed: 0.42, desk: 0.7, plant: 0.9, tv: 0.85 }; + const h = (heights[item.type] || 0.5) * wallH; + const x = item.x; + const y = item.y; + const w = item.width; + const d = item.height; + const box = (x0, y0, w0, d0, z0, z1, top, left, right) => { + const p = (px, py, pz) => P(px, py, pz); + g.appendChild(poly( + [p(x0, y0 + d0, z0), p(x0 + w0, y0 + d0, z0), p(x0 + w0, y0 + d0, z1), p(x0, y0 + d0, z1)], + { fill: left, stroke: 'rgba(0,0,0,0.25)', 'stroke-width': '0.5' } + )); + g.appendChild(poly( + [p(x0 + w0, y0, z0), p(x0 + w0, y0 + d0, z0), p(x0 + w0, y0 + d0, z1), p(x0 + w0, y0, z1)], + { fill: right, stroke: 'rgba(0,0,0,0.25)', 'stroke-width': '0.5' } + )); + g.appendChild(poly( + [p(x0, y0, z1), p(x0 + w0, y0, z1), p(x0 + w0, y0 + d0, z1), p(x0, y0 + d0, z1)], + { fill: top, stroke: 'rgba(0,0,0,0.3)', 'stroke-width': '0.6' } + )); + }; + + const top = item.color; + const left = this.darkenColor(item.color, 18); + const right = this.darkenColor(item.color, 32); + + if (item.type === 'couch') { + box(x + 0.15, y + 0.7, w - 0.3, d - 0.85, 0, h * 0.55, top, left, right); + box(x + 0.15, y + 0.15, w - 0.3, 0.55, 0, h, this.darkenColor(item.color, 8), left, right); + box(x, y + 0.3, 0.28, d - 0.5, 0, h * 0.75, left, right, right); + box(x + w - 0.28, y + 0.3, 0.28, d - 0.5, 0, h * 0.75, left, right, right); + } else if (item.type === 'bed') { + box(x + 0.15, y + 0.9, w - 0.3, d - 1.05, 0, h * 0.7, '#f7f3ee', '#e4ddd4', '#d5cdc3'); + box(x + 0.2, y + 0.15, w - 0.4, 0.7, 0, h, top, left, right); + box(x + 0.45, y + 1.15, w * 0.38, 0.7, h * 0.7, h * 0.95, '#ffffff', '#eeeae4', '#e0dbd4'); + box(x + w * 0.55, y + 1.15, w * 0.35, 0.7, h * 0.7, h * 0.95, '#f3efe8', '#e7e1d8', '#d9d2c8'); + } else if (item.type === 'desk') { + box(x + 0.15, y + 0.15, 0.28, d - 0.3, 0, h * 0.92, left, right, right); + box(x + w - 0.43, y + 0.15, 0.28, d - 0.3, 0, h * 0.92, left, right, right); + box(x, y, w, d, h * 0.92, h, top, left, right); + } else if (item.type === 'plant') { + const cx = x + w / 2; + const cy = y + d / 2; + box(cx - 0.28, cy - 0.28, 0.56, 0.56, 0, h * 0.38, '#c4a574', '#a8885c', '#8c7048'); + const leaf = (ox, oy, s, color) => { + g.appendChild(poly( + [ + P(cx + ox, cy + oy, h * 0.35), + P(cx + ox + s, cy + oy - s * 0.2, h * 0.85), + P(cx + ox, cy + oy, h), + P(cx + ox - s * 0.3, cy + oy + s * 0.4, h * 0.8) + ], + { fill: color, stroke: '#145214', 'stroke-width': '0.4' } + )); + }; + leaf(-0.05, 0.05, 0.55, '#2e8b2e'); + leaf(0.1, -0.05, 0.5, '#228B22'); + leaf(-0.15, -0.1, 0.42, '#3cb043'); + } else if (item.type === 'tv') { + box(x + w * 0.4, y + d * 0.25, w * 0.2, d * 0.5, 0, h * 0.28, '#4a4a4a', '#333', '#222'); + box(x, y + d * 0.15, w, d * 0.35, h * 0.28, h, '#1c1c1c', '#111', '#000'); + g.appendChild(poly( + [ + P(x + 0.18, y + d * 0.15, h * 0.4), + P(x + w - 0.18, y + d * 0.15, h * 0.4), + P(x + w - 0.18, y + d * 0.15, h * 0.9), + P(x + 0.18, y + d * 0.15, h * 0.9) + ], + { fill: '#3a6ea5', stroke: '#0e0e0e', 'stroke-width': '0.4' } + )); + } else { + box(x, y, w, d, 0, h, top, left, right); } - - // Add label - const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - text.setAttribute('x', x + widthScaled * 0.4); - text.setAttribute('y', y - heightScaled * 0.2); - text.setAttribute('text-anchor', 'middle'); - text.setAttribute('dominant-baseline', 'middle'); - text.setAttribute('font-size', '12'); - text.setAttribute('fill', '#fff'); - text.setAttribute('font-weight', 'bold'); - text.textContent = item.name.charAt(0); - furnitureGroup.appendChild(text); - - svg.appendChild(furnitureGroup); + + const label = document.createElementNS(ns, 'text'); + const c = P(x + w / 2, y + d / 2, h + 2); + label.setAttribute('x', c.x.toFixed(1)); + label.setAttribute('y', c.y.toFixed(1)); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('fill', '#3d342b'); + label.setAttribute('font-size', '11'); + label.setAttribute('font-family', 'sans-serif'); + label.textContent = item.name; + g.appendChild(label); + return g; } - // Helper function to darken a color darkenColor(color, percent) { - let R = parseInt(color.substring(1, 3), 16); - let G = parseInt(color.substring(3, 5), 16); - let B = parseInt(color.substring(5, 7), 16); - - R = Math.floor(R * (100 - percent) / 100); - G = Math.floor(G * (100 - percent) / 100); - B = Math.floor(B * (100 - percent) / 100); - - R = (R < 0) ? 0 : R; - G = (G < 0) ? 0 : G; - B = (B < 0) ? 0 : B; - - const RR = ((R.toString(16).length === 1) ? "0" + R.toString(16) : R.toString(16)); - const GG = ((G.toString(16).length === 1) ? "0" + G.toString(16) : G.toString(16)); - const BB = ((B.toString(16).length === 1) ? "0" + B.toString(16) : B.toString(16)); - - return "#" + RR + GG + BB; + const hex = String(color || '#888888').replace('#', ''); + const full = hex.length === 3 + ? hex.split('').map((c) => c + c).join('') + : hex.padEnd(6, '0').slice(0, 6); + let R = parseInt(full.substring(0, 2), 16); + let G = parseInt(full.substring(2, 4), 16); + let B = parseInt(full.substring(4, 6), 16); + R = Math.max(0, Math.floor(R * (100 - percent) / 100)); + G = Math.max(0, Math.floor(G * (100 - percent) / 100)); + B = Math.max(0, Math.floor(B * (100 - percent) / 100)); + const h = (n) => n.toString(16).padStart(2, '0'); + return `#${h(R)}${h(G)}${h(B)}`; + } + + // ---- URL: the whole plan, nothing else ---- + + planState() { + return { + walls: this.walls.map((w) => ({ x1: w.x1, y1: w.y1, x2: w.x2, y2: w.y2 })), + furniture: this.furniture.map((item) => ({ type: item.type, x: item.x, y: item.y })) + }; } updateURL() { try { - const state = { - walls: this.walls, - furniture: this.furniture - }; - - const jsonString = JSON.stringify(state); - const compressed = this.compressString(jsonString); - const url = new URL(window.location); + const compressed = this.compressString(JSON.stringify(this.planState())); + const url = new URL(window.location.href); url.searchParams.set('plan', compressed); window.history.replaceState({}, '', url); + this.renderShare(); } catch (e) { console.warn('Could not update URL:', e); } } + renderShare() { + const el = document.getElementById('share-url'); + if (!el) return; + el.textContent = window.location.href; + } + + copyShareLink() { + const url = window.location.href; + const done = () => { + const btn = document.getElementById('copy-link-btn'); + if (!btn) return; + const previous = btn.textContent; + btn.textContent = 'Copied'; + setTimeout(() => { btn.textContent = previous; }, 1200); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url).then(done).catch(() => this.fallbackCopy(url, done)); + } else { + this.fallbackCopy(url, done); + } + } + + fallbackCopy(url, done) { + const input = document.createElement('input'); + input.value = url; + document.body.appendChild(input); + input.select(); + try { document.execCommand('copy'); } catch (e) { /* ignore */ } + document.body.removeChild(input); + if (done) done(); + } + compressString(str) { - // Simple base64 encoding for now - return btoa(encodeURIComponent(str)); + return btoa(unescape(encodeURIComponent(str))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); } decompressString(compressed) { try { - return decodeURIComponent(atob(compressed)); + let b64 = String(compressed).replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4) b64 += '='; + return decodeURIComponent(escape(atob(b64))); } catch (e) { return null; } @@ -454,108 +750,78 @@ class DollhouseApp { loadFromURL() { const urlParams = new URLSearchParams(window.location.search); const planParam = urlParams.get('plan'); - if (planParam) { - try { - const jsonString = this.decompressString(planParam); - if (jsonString) { - const state = JSON.parse(jsonString); - this.walls = state.walls || []; - this.furniture = state.furniture || []; + const jsonString = this.decompressString(planParam); + if (jsonString) { + try { + this.applyPlan(JSON.parse(jsonString)); return; + } catch (e) { + console.warn('Could not load plan from URL:', e); } - } catch (e) { - console.warn('Could not load plan from URL:', e); } } - - // Load sample plan if none provided - this.loadSamplePlan(); - } - - loadSamplePlan() { - // Sample room: 2 walls forming a corner and some furniture - this.walls = [ - { startX: 5, startY: 5, endX: 15, endY: 5 }, // Horizontal wall - { startX: 15, startY: 5, endX: 15, endY: 12 } // Vertical wall - ]; - - this.furniture = [ - { - x: 7, - y: 7, - type: 'couch', - name: 'Couch', - width: 6, - height: 3, - color: '#8B4513' - }, - { - x: 9, - y: 10, - type: 'desk', - name: 'Desk', - width: 4, - height: 2, - color: '#696969' - }, - { - x: 12, - y: 6, - type: 'plant', - name: 'Plant', - width: 1, - height: 1, - color: '#228B22' - } - ]; - - // Update URL with sample plan + // First load: the sample is input, applied through the same code path. + this.applyPlan(this.samplePlan()); this.updateURL(); } downloadDiorama() { const svg = document.getElementById('diorama-svg'); - - // Create PNG version - const svgData = new XMLSerializer().serializeToString(svg); + const clone = svg.cloneNode(true); + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + const vb = (svg.getAttribute('viewBox') || '0 0 800 600').split(/[\s,]+/).map(Number); + const width = vb[2] || 800; + const height = vb[3] || 600; + clone.setAttribute('width', String(width)); + clone.setAttribute('height', String(height)); + if (!clone.querySelector('rect.poster-bg')) { + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('class', 'poster-bg'); + bg.setAttribute('x', '0'); + bg.setAttribute('y', '0'); + bg.setAttribute('width', String(width)); + bg.setAttribute('height', String(height)); + bg.setAttribute('fill', '#f7f4ef'); + clone.insertBefore(bg, clone.firstChild); + } + const svgData = new XMLSerializer().serializeToString(clone); + + const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); + const svgUrl = URL.createObjectURL(svgBlob); + this.triggerDownload(svgUrl, 'dollhouse-diorama.svg'); + setTimeout(() => URL.revokeObjectURL(svgUrl), 1000); + const canvas = document.createElement('canvas'); + const scale = 2; + canvas.width = Math.ceil(width * scale); + canvas.height = Math.ceil(height * scale); const ctx = canvas.getContext('2d'); const img = new Image(); - - // Set canvas size - canvas.width = 800; - canvas.height = 600; - - img.onload = function() { - ctx.drawImage(img, 0, 0); - - // Download PNG - const pngUrl = canvas.toDataURL('image/png'); - const pngLink = document.createElement('a'); - pngLink.href = pngUrl; - pngLink.download = 'dollhouse-diorama.png'; - document.body.appendChild(pngLink); - pngLink.click(); - document.body.removeChild(pngLink); + img.onload = () => { + ctx.fillStyle = '#f7f4ef'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + canvas.toBlob((blob) => { + if (!blob) return; + const pngUrl = URL.createObjectURL(blob); + this.triggerDownload(pngUrl, 'dollhouse-diorama.png'); + setTimeout(() => URL.revokeObjectURL(pngUrl), 1000); + }, 'image/png'); }; - - img.src = 'data:image/svg+xml;base64,' + btoa(svgData); - - // Also download SVG - const svgBlob = new Blob([svgData], { type: 'image/svg+xml' }); - const svgUrl = URL.createObjectURL(svgBlob); - const svgLink = document.createElement('a'); - svgLink.href = svgUrl; - svgLink.download = 'dollhouse-diorama.svg'; - document.body.appendChild(svgLink); - svgLink.click(); - document.body.removeChild(svgLink); - URL.revokeObjectURL(svgUrl); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgData); + } + + triggerDownload(url, filename) { + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); } } -// Initialize app when DOM is loaded document.addEventListener('DOMContentLoaded', () => { window.dollhouseApp = new DollhouseApp(); -}); \ No newline at end of file +});