-
-
+
+
Scene
+
+
+
- )}
- {selected && (
-
-
-
+
+
+
Furniture Presets
+
-
-
-
-
-
-
Conditions:
- {(['walk_to_and_sit', 'around_open_area', 'near_entrance', 'near_object', 'accessible', 'not_blocking_path', 'open_circulation'] as ConditionKind[]).map(
- (k) => (
-
-
- {k === 'near_object' && selected.conditions.some((c) => c.kind === 'near_object') && (
-
- )}
+
+
+
+
Objects
+ {state.entrance && (
+
+
onSelect('__entrance__')}>
+
+ Entrance
+ fixed
+
+ {selectedId === '__entrance__' && (
+
- ),
- )}
-
+ )}
+
+ )}
+ {state.furniture.length === 0 && !state.entrance ? (
+
No objects yet — add a preset or create one.
+ ) : (
+
{state.furniture.map(renderObjectCard)}
+ )}
- )}
-
- )}
+ >
+ )}
- {mode === 'optimize' && (
-
-
Optimize
-
Runs random search (15s) then NN-informed placement heuristic.
-
- {optimizing &&
Randomizing layouts, keeping best...
}
-
- )}
-
+ {mode === 'optimize' && (
+
+
Optimization
+
+ Placed objects are locked in position. Unplaced objects are randomized across the room for 15
+ seconds; the best-scoring layout is kept.
+
+
+ {optimizing && (
+ <>
+
+ >
+ )}
+
+ )}
+
+
);
}
diff --git a/src/components/RoomCanvas.tsx b/src/components/RoomCanvas.tsx
index 9192a1c..e02338f 100644
--- a/src/components/RoomCanvas.tsx
+++ b/src/components/RoomCanvas.tsx
@@ -34,7 +34,27 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
ctx.save();
ctx.scale(zoom, zoom);
- ctx.strokeStyle = '#666';
+ ctx.fillStyle = '#12151b';
+ ctx.fillRect(0, 0, state.room.width, state.room.height);
+
+ // subtle grid
+ ctx.strokeStyle = 'rgba(255,255,255,0.035)';
+ ctx.lineWidth = 1;
+ const step = 25;
+ for (let x = 0; x <= state.room.width; x += step) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, state.room.height);
+ ctx.stroke();
+ }
+ for (let y = 0; y <= state.room.height; y += step) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(state.room.width, y);
+ ctx.stroke();
+ }
+
+ ctx.strokeStyle = '#2c3342';
ctx.lineWidth = 2;
ctx.strokeRect(0, 0, state.room.width, state.room.height);
@@ -42,30 +62,58 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
const e = state.entrance;
const w = e.width ?? 20;
const h = e.height ?? 20;
- ctx.fillStyle = '#4caf50';
+ const selected = selectedId === '__entrance__';
+ ctx.fillStyle = '#3fbf7f';
ctx.fillRect(e.x, e.y, w, h);
- ctx.strokeStyle = selectedId === '__entrance__' ? '#ff5722' : '#333';
- ctx.lineWidth = selectedId === '__entrance__' ? 3 : 1;
+ ctx.strokeStyle = selected ? '#4f8cff' : 'rgba(0, 0, 0, 0.4)';
+ ctx.lineWidth = selected ? 2.5 : 1;
ctx.strokeRect(e.x, e.y, w, h);
- ctx.fillStyle = '#000';
- ctx.font = '10px sans-serif';
- ctx.fillText('ENTRANCE', e.x + 2, e.y + 12);
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
+ ctx.font = '600 10px Inter, sans-serif';
+ ctx.textBaseline = 'middle';
+ if (w > 50) ctx.fillText('ENTRANCE', e.x + 6, e.y + h / 2);
// resize handle (SE)
- ctx.fillStyle = selectedId === '__entrance__' ? '#ff5722' : '#666';
+ ctx.fillStyle = selected ? '#4f8cff' : 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(e.x + w - 4, e.y + h - 4, 8, 8);
}
for (const f of state.furniture) {
- ctx.fillStyle = f.color || '#9aa0a6';
const b = { x: f.shape.x, y: f.shape.y, w: f.shape.width, h: f.shape.height };
- ctx.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.5 : 0.9;
- ctx.fillRect(b.x, b.y, b.w, b.h);
- ctx.strokeStyle = selectedId === f.id ? '#ff5722' : '#333';
- ctx.lineWidth = selectedId === f.id ? 3 : 1;
- ctx.strokeRect(b.x, b.y, b.w, b.h);
- ctx.fillStyle = '#000';
- ctx.font = '10px sans-serif';
- ctx.fillText(f.name.substring(0, 12), b.x + 2, b.y + 12);
+ const r = Math.min(8, b.w / 4, b.h / 4);
+ const selected = selectedId === f.id;
+
+ ctx.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.55 : 1;
+
+ ctx.beginPath();
+ roundRect(ctx, b.x, b.y, b.w, b.h, r);
+ ctx.fillStyle = f.color || '#9aa0a6';
+ ctx.fill();
+
+ if (selected) {
+ ctx.shadowColor = '#4f8cff';
+ ctx.shadowBlur = 14;
+ ctx.strokeStyle = '#4f8cff';
+ ctx.lineWidth = 2.5;
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ } else {
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ }
+
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.72)';
+ ctx.font = '600 11px Inter, sans-serif';
+ ctx.textBaseline = 'middle';
+ const label = b.w > 44 ? f.name.substring(0, 14) : '';
+ if (label) ctx.fillText(label, b.x + 7, b.y + b.h / 2);
+
+ if (f.lockedForOptimization) {
+ ctx.font = '10px Inter, sans-serif';
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
+ ctx.fillText('🔒', b.x + b.w - 15, b.y + 9);
+ }
+
ctx.globalAlpha = 1;
}
@@ -156,7 +204,7 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
ref={canvasRef}
width={Math.max(state.room.width, 400)}
height={Math.max(state.room.height, 300)}
- style={{ border: '1px solid #ccc', maxWidth: '100%', height: 'auto' }}
+ style={{ maxWidth: '100%', height: 'auto' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
@@ -167,3 +215,19 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
function pointInRect(p: Point, r: { x: number; y: number; w: number; h: number }): boolean {
return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
}
+
+function roundRect(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ r: number,
+): void {
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+}
diff --git a/src/index.css b/src/index.css
index 45df9f9..3e1b681 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,5 +1,476 @@
-html, body, #root {
+:root {
+ --bg: #0f1115;
+ --bg-elev: #171a21;
+ --bg-panel: #1b1f27;
+ --bg-input: #232833;
+ --bg-hover: #262c38;
+ --border: #2c3342;
+ --border-strong: #3a4356;
+ --text: #e6e9ef;
+ --text-dim: #9aa3b2;
+ --text-mute: #6b7486;
+ --accent: #4f8cff;
+ --accent-soft: rgba(79, 140, 255, 0.15);
+ --accent-strong: #3b78e8;
+ --green: #3fbf7f;
+ --amber: #e0a83e;
+ --red: #e05555;
+ --radius: 10px;
+ --radius-sm: 7px;
+ --shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+ color-scheme: dark;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
margin: 0;
padding: 0;
height: 100%;
}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ font-size: 13px;
+ -webkit-font-smoothing: antialiased;
+}
+
+button {
+ font: inherit;
+ color: var(--text);
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 7px 12px;
+ cursor: pointer;
+ transition: background 0.12s ease, border-color 0.12s ease, transform 0.05s ease;
+}
+
+button:hover:not(:disabled) {
+ background: var(--bg-hover);
+ border-color: var(--border-strong);
+}
+
+button:active:not(:disabled) {
+ transform: translateY(1px);
+}
+
+button:disabled {
+ opacity: 0.45;
+ cursor: default;
+}
+
+button.primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+ font-weight: 600;
+}
+
+button.primary:hover:not(:disabled) {
+ background: var(--accent-strong);
+ border-color: var(--accent-strong);
+}
+
+input,
+select {
+ font: inherit;
+ color: var(--text);
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 6px 8px;
+ outline: none;
+ transition: border-color 0.12s ease, box-shadow 0.12s ease;
+}
+
+input:focus,
+select:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+
+input[type='number'] {
+ width: 68px;
+}
+
+input[type='checkbox'] {
+ accent-color: var(--accent);
+ width: 15px;
+ height: 15px;
+ vertical-align: middle;
+}
+
+label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--text-dim);
+}
+
+/* ---------- App shell ---------- */
+.app {
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.sidebar {
+ width: 320px;
+ min-width: 320px;
+ height: 100%;
+ background: var(--bg-panel);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+}
+
+.sidebar-header {
+ padding: 16px 16px 12px;
+ border-bottom: 1px solid var(--border);
+}
+
+.sidebar-title {
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: 0.2px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.sidebar-title .dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 10px var(--accent);
+}
+
+.sidebar-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 14px 16px 24px;
+}
+
+.sidebar-body::-webkit-scrollbar {
+ width: 9px;
+}
+
+.sidebar-body::-webkit-scrollbar-thumb {
+ background: var(--border-strong);
+ border-radius: 6px;
+ border: 2px solid var(--bg-panel);
+}
+
+/* ---------- Segmented tabs ---------- */
+.segmented {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 4px;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 3px;
+}
+
+.segmented button {
+ border: none;
+ background: transparent;
+ padding: 7px 10px;
+ border-radius: 5px;
+ color: var(--text-dim);
+ font-weight: 600;
+}
+
+.segmented button:hover:not(:disabled) {
+ background: var(--bg-hover);
+}
+
+.segmented button.active {
+ background: var(--accent);
+ color: #fff;
+}
+
+.segmented button.active:hover {
+ background: var(--accent);
+}
+
+/* ---------- Sections ---------- */
+.section {
+ margin-top: 18px;
+}
+
+.section-title {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.9px;
+ color: var(--text-mute);
+ margin: 0 0 10px;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.row.between {
+ justify-content: space-between;
+}
+
+.grid-2 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.field > span {
+ font-size: 11px;
+ color: var(--text-mute);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.field input,
+.field select {
+ width: 100%;
+}
+
+.counter {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 5px 6px 5px 12px;
+}
+
+.counter .counter-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--text);
+ font-weight: 500;
+}
+
+.counter .swatch {
+ width: 12px;
+ height: 12px;
+ border-radius: 3px;
+}
+
+.stepper {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.stepper button {
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ display: grid;
+ place-items: center;
+ font-size: 15px;
+ line-height: 1;
+}
+
+.stepper input {
+ width: 40px;
+ text-align: center;
+ border: none;
+ background: transparent;
+ padding: 2px;
+}
+
+.stepper input:focus {
+ box-shadow: none;
+}
+
+/* ---------- Accordion object cards ---------- */
+.obj-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.obj-card {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-input);
+ overflow: hidden;
+ transition: border-color 0.12s ease;
+}
+
+.obj-card.selected {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-soft);
+}
+
+.obj-head {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 9px 10px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.obj-head:hover {
+ background: var(--bg-hover);
+}
+
+.obj-caret {
+ width: 12px;
+ color: var(--text-mute);
+ transition: transform 0.15s ease;
+ flex: none;
+}
+
+.obj-caret.open {
+ transform: rotate(90deg);
+}
+
+.obj-swatch {
+ width: 14px;
+ height: 14px;
+ border-radius: 4px;
+ flex: none;
+ border: 1px solid rgba(0, 0, 0, 0.3);
+}
+
+.obj-name {
+ flex: 1;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.obj-badge {
+ font-size: 10px;
+ padding: 2px 6px;
+ border-radius: 20px;
+ background: var(--bg-hover);
+ color: var(--text-mute);
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+}
+
+.obj-badge.locked {
+ color: var(--amber);
+ background: rgba(224, 168, 62, 0.14);
+}
+
+.obj-body {
+ padding: 4px 10px 12px;
+ border-top: 1px solid var(--border);
+ animation: reveal 0.15s ease;
+}
+
+@keyframes reveal {
+ from {
+ opacity: 0;
+ transform: translateY(-3px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.cond-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.cond {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.cond label {
+ color: var(--text);
+ font-size: 12px;
+}
+
+.cond select {
+ width: 100%;
+}
+
+.hint {
+ color: var(--text-mute);
+ font-size: 12px;
+ line-height: 1.5;
+ margin: 0 0 10px;
+}
+
+/* ---------- Canvas area ---------- */
+.stage {
+ flex: 1;
+ overflow: auto;
+ padding: 24px;
+ background:
+ radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.04) 1px, transparent 0) 0 0 / 22px 22px,
+ var(--bg);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+}
+
+.canvas-frame {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ padding: 14px;
+ display: inline-block;
+}
+
+.canvas-frame canvas {
+ display: block;
+ border-radius: 6px;
+ cursor: crosshair;
+}
+
+.progress {
+ height: 6px;
+ border-radius: 20px;
+ background: var(--bg-input);
+ overflow: hidden;
+ margin-top: 10px;
+}
+
+.progress > div {
+ height: 100%;
+ background: linear-gradient(90deg, var(--accent), #7db0ff);
+ transition: width 0.9s linear;
+}
+
+.empty {
+ color: var(--text-mute);
+ font-size: 12px;
+ text-align: center;
+ padding: 14px;
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-sm);
+}