Treat object count as optimizer spec; drag cards onto room to pin/lock, optimizer fills the rest

This commit is contained in:
2026-10-17 21:50:00 -04:00
parent 75b391bad1
commit 8f8211c9b7
5 changed files with 125 additions and 75 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>furniture-app</title>
<script type="module" crossorigin src="/assets/index-Be3LQNz7.js"></script>
<script type="module" crossorigin src="/assets/index-CCxBSCEa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Gl7EQEpA.css">
</head>
<body>
+14 -72
View File
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { RoomCanvas } from './components/RoomCanvas';
import { ControlPanel } from './components/ControlPanel';
import type { FurnitureConfig, FurniturePreset, LayoutState } from './types';
import type { LayoutState } from './types';
import { runRandomSearch } from './optimization/randomSearch';
import { optimizeWithNN } from './ai/nn';
import { prepareForOptimize } from './optimization/prepare';
import { DEFAULT_PRESETS } from './furniture/presets';
import { clampRectToRoom, clampStateToRoom } from './geometry/bounds';
const initialState: LayoutState = {
room: { width: 600, height: 400, gridSize: 10 },
@@ -14,70 +14,6 @@ const initialState: LayoutState = {
presets: DEFAULT_PRESETS,
};
function makeFurniture(preset: FurniturePreset, index: number, room: LayoutState['room']): FurnitureConfig {
const spawn = clampRectToRoom(
{
x: 80 + (index % 5) * 60,
y: 80 + Math.floor(index / 5) * 60,
width: preset.shape.width,
height: preset.shape.height,
},
room,
);
return {
id: `${preset.id}_${Date.now()}_${index}`,
name: `${preset.name} ${index + 1}`,
type: preset.id,
shape: {
...preset.shape,
x: spawn.x,
y: spawn.y,
width: spawn.width,
height: spawn.height,
},
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
color: preset.color,
};
}
function syncFurniture(state: LayoutState): LayoutState {
const presets = state.presets || [];
const validIds = new Set(presets.map((p) => p.id));
let furniture = state.furniture.filter((f) => !f.type || f.type === 'custom' || validIds.has(f.type));
for (const preset of presets) {
const desired = preset.amount ?? 0;
const existing = furniture.filter((f) => f.type === preset.id);
if (existing.length > desired) {
const removeIds = new Set(
[...existing]
.sort((a, b) => Number(a.placed) - Number(b.placed))
.slice(0, existing.length - desired)
.map((f) => f.id),
);
furniture = furniture.filter((f) => !removeIds.has(f.id));
} else if (existing.length < desired) {
for (let i = existing.length; i < desired; i++) {
furniture.push(makeFurniture(preset, i, state.room));
}
}
furniture = furniture.map((f) =>
f.type === preset.id
? {
...f,
shape: { ...f.shape, width: preset.shape.width, height: preset.shape.height },
color: preset.color,
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
}
: f,
);
}
return { ...state, furniture };
}
function App() {
const [state, setState] = useState<LayoutState>(initialState);
const [mode, setMode] = useState<'layout' | 'optimize'>('layout');
@@ -86,8 +22,17 @@ function App() {
const [timeLeft, setTimeLeft] = useState(15);
useEffect(() => {
setState((s) => clampStateToRoom(syncFurniture(s)));
}, [state.presets]);
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
const target = e.target as HTMLElement;
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
if (!selectedId || selectedId === '__entrance__') return;
setState((s) => ({ ...s, furniture: s.furniture.filter((f) => f.id !== selectedId) }));
setSelectedId(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selectedId]);
useEffect(() => {
if (!optimizing) return;
@@ -110,10 +55,7 @@ function App() {
}, [optimizing, timeLeft]);
const handleOptimize = () => {
setState((s) => ({
...s,
furniture: s.furniture.map((f) => ({ ...f, lockedForOptimization: f.placed ? true : false })),
}));
setState((s) => prepareForOptimize(s));
setOptimizing(true);
setTimeLeft(15);
};
+15 -1
View File
@@ -140,12 +140,22 @@ export function ControlPanel({
const renderObjectCard = (p: FurniturePreset) => {
const open = openId === p.id;
const placedCount = state.furniture.filter((f) => f.type === p.id && f.placed).length;
return (
<div key={p.id} className={`obj-card ${open ? 'open' : ''}`}>
<div className="obj-head" onClick={() => toggleOpen(p.id)}>
<div
className="obj-head"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/x-preset-id', p.id);
e.dataTransfer.effectAllowed = 'copy';
}}
onClick={() => toggleOpen(p.id)}
>
<span className={`obj-caret ${open ? 'open' : ''}`}>▶</span>
<span className="obj-swatch" style={{ background: p.color }} />
<span className="obj-name">{p.name}</span>
{placedCount > 0 && <span className="obj-badge">{placedCount} placed</span>}
<span className="obj-count" onClick={(e) => e.stopPropagation()}>
<NumberField value={p.amount ?? 0} min={0} width={44} onChange={(v) => setAmount(p.id, v)} />
</span>
@@ -340,6 +350,10 @@ export function ControlPanel({
<div className="section">
<div className="section-title">Objects</div>
<div className="hint">
Count tells the optimizer how many are needed. Drag a card onto the room to pin one in place
(locked); the optimizer arranges the rest.
</div>
<div className="obj-list">{objects.map(renderObjectCard)}</div>
</div>
</>
+21 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import type { LayoutState, Point } from '../types';
import { computePaths } from '../pathfinding/paths';
import { clampRectToRoom } from '../geometry/bounds';
import { makePlaced } from '../optimization/prepare';
interface RoomCanvasProps {
state: LayoutState;
@@ -156,7 +157,7 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
ctx.restore();
};
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>): Point => {
const getMousePos = (e: { clientX: number; clientY: number }): Point => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
@@ -250,6 +251,23 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
setDrag(null);
};
const handleDragOver = (e: React.DragEvent<HTMLCanvasElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
};
const handleDrop = (e: React.DragEvent<HTMLCanvasElement>) => {
e.preventDefault();
const presetId = e.dataTransfer.getData('application/x-preset-id');
if (!presetId) return;
const preset = (state.presets || []).find((p) => p.id === presetId);
if (!preset) return;
const pos = getMousePos(e);
const f = makePlaced(preset, pos.x - preset.shape.width / 2, pos.y - preset.shape.height / 2, state.room);
onStateChange({ ...state, furniture: [...state.furniture, f] });
onSelect(f.id);
};
return (
<canvas
ref={canvasRef}
@@ -259,6 +277,8 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDragOver={handleDragOver}
onDrop={handleDrop}
/>
);
}
+74
View File
@@ -0,0 +1,74 @@
import type { FurnitureConfig, FurniturePreset, LayoutState, RoomConfig } from '../types';
import { clampRectToRoom, clampStateToRoom } from '../geometry/bounds';
export function makeGenerated(preset: FurniturePreset, index: number, room: RoomConfig): FurnitureConfig {
const b = clampRectToRoom(
{
x: Math.random() * Math.max(0, room.width - preset.shape.width),
y: Math.random() * Math.max(0, room.height - preset.shape.height),
width: preset.shape.width,
height: preset.shape.height,
},
room,
);
return {
id: `${preset.id}_gen_${Date.now()}_${index}`,
name: preset.name,
type: preset.id,
shape: { ...preset.shape, ...b },
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
color: preset.color,
placed: false,
};
}
export function makePlaced(preset: FurniturePreset, x: number, y: number, room: RoomConfig): FurnitureConfig {
const b = clampRectToRoom(
{ x, y, width: preset.shape.width, height: preset.shape.height },
room,
);
return {
id: `${preset.id}_placed_${Date.now()}_${Math.round(Math.random() * 1e6)}`,
name: preset.name,
type: preset.id,
shape: { ...preset.shape, ...b },
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
color: preset.color,
placed: true,
lockedForOptimization: true,
};
}
export function prepareForOptimize(state: LayoutState): LayoutState {
const presets = state.presets || [];
let furniture = [...state.furniture];
for (const preset of presets) {
const wanted = preset.amount ?? 0;
const ofType = furniture.filter((f) => f.type === preset.id);
const placed = ofType.filter((f) => f.placed);
let generated = ofType.filter((f) => !f.placed);
const need = Math.max(0, wanted - placed.length);
if (generated.length > need) {
generated = generated.slice(0, need);
}
while (generated.length < need) {
generated.push(makeGenerated(preset, generated.length, state.room));
}
const synced = [...placed, ...generated].map((f) => ({
...f,
name: preset.name,
color: preset.color,
shape: { ...f.shape, width: preset.shape.width, height: preset.shape.height },
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
lockedForOptimization: !!f.placed,
}));
furniture = furniture.filter((f) => f.type !== preset.id);
furniture = [...furniture, ...synced];
}
return clampStateToRoom({ ...state, furniture });
}