feat: improve thumbnails, placement, and editor responsiveness (#258)

* wip: backport monorepo PRs 278 280 281 283 284

* refactor(editor): align floorplan panel with monorepo split version
This commit is contained in:
Pascal
2026-04-17 15:14:40 -04:00
committed by GitHub
parent d948f09b2c
commit 5c16aa4225
53 changed files with 1744 additions and 1409 deletions
+2 -2
View File
@@ -30,7 +30,7 @@
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
"three": "^0.182"
"three": "^0.184"
},
"dependencies": {
"dedent": "^1.7.1",
@@ -47,7 +47,7 @@
"@pascal/typescript-config": "*",
"@types/react": "^19.2.2",
"typescript": "5.9.3",
"@types/three": "^0.183.0"
"@types/three": "^0.184.0"
},
"keywords": [
"3d",
+27 -4
View File
@@ -24,9 +24,10 @@ export interface GridEvent {
/** World-space intersection point on the grid plane. */
position: [number, number, number]
/**
* Building-local intersection point relative to the currently selected building.
* Building-local intersection point, relative to the currently selected building.
* Equals `position` when no building is selected.
* Use this for placing/committing anything that lives inside a building (walls, slabs, items, etc.).
* Use this for placing or committing anything that lives inside a building
* (walls, slabs, items, etc.).
*/
localPosition: [number, number, number]
nativeEvent: ThreeEvent<PointerEvent>
@@ -57,7 +58,7 @@ export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
// Event suffixes - exported for use in hooks
// Event suffixes, exported for use in hooks
export const eventSuffixes = [
'click',
'move',
@@ -85,6 +86,15 @@ export interface CameraControlEvent {
export interface ThumbnailGenerateEvent {
projectId: string
captureMode?: 'standard' | 'viewport' | 'area'
cropRegion?: { x: number; y: number; width: number; height: number }
/**
* When true, snap levels to their true positions before capturing (for a
* consistent auto-thumbnail angle) and defer the capture if the tab is
* hidden, the background auto-save path. Omit for user-driven captures
* that should fire immediately from the current camera pose.
*/
snapLevels?: boolean
}
type CameraControlEvents = {
@@ -111,6 +121,17 @@ type ThumbnailEvents = {
'thumbnail:after-capture': undefined
}
type SnapshotEvents = {
'snapshot:saved': undefined
'camera:go-to-position': { position: [number, number, number]; target: [number, number, number] }
}
type AIChatEvents = {
'ai-chat:attach-images': {
images: { url: string; name: string; kind: 'snapshot' | 'render' }[]
}
}
type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> &
NodeEvents<'fence', FenceEvent> &
@@ -130,6 +151,8 @@ type EditorEvents = GridEvents &
CameraControlEvents &
ToolEvents &
PresetEvents &
ThumbnailEvents
ThumbnailEvents &
SnapshotEvents &
AIChatEvents
export const emitter = mitt<EditorEvents>()
@@ -4,6 +4,13 @@ interface GridCell {
itemIds: Set<string>
}
interface ItemBounds {
minX: number
maxX: number
minZ: number
maxZ: number
}
interface SpatialGridConfig {
cellSize: number // e.g., 0.5 meters = Sims-style half-tile
}
@@ -11,6 +18,7 @@ interface SpatialGridConfig {
export class SpatialGrid {
private readonly cells = new Map<CellKey, GridCell>()
private readonly itemCells = new Map<string, Set<CellKey>>() // reverse lookup
private readonly itemBounds = new Map<string, ItemBounds>() // actual AABB for narrow-phase
private readonly config: SpatialGridConfig
@@ -26,34 +34,35 @@ export class SpatialGrid {
return `${cx},${cz}`
}
// Get all cells an item occupies based on its AABB
private getItemCells(
// Compute the axis-aligned bounding box for a rotated item
private getAABB(
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): CellKey[] {
// Simplified: axis-aligned bounding box
// For full rotation support, compute rotated corners
): ItemBounds {
const [x, , z] = position
const [w, , d] = dimensions
const yRot = rotation[1] // Y-axis rotation
const yRot = rotation[1]
// Compute rotated footprint (simplified for 90° increments)
const cos = Math.abs(Math.cos(yRot))
const sin = Math.abs(Math.sin(yRot))
const rotatedW = w * cos + d * sin
const rotatedD = w * sin + d * cos
const minX = x - rotatedW / 2
const maxX = x + rotatedW / 2
const minZ = z - rotatedD / 2
const maxZ = z + rotatedD / 2
return {
minX: x - rotatedW / 2,
maxX: x + rotatedW / 2,
minZ: z - rotatedD / 2,
maxZ: z + rotatedD / 2,
}
}
// Get all cells an item occupies based on its AABB
private getItemCells(bounds: ItemBounds): CellKey[] {
const { minX, maxX, minZ, maxZ } = bounds
const [minCx, minCz] = this.posToCell(minX, minZ)
// Use exclusive upper bound: subtract epsilon so exact boundaries don't overlap
// This allows adjacent items (touching but not overlapping) to not conflict
const epsilon = 1e-6
const [maxCx, maxCz] = this.posToCell(maxX - epsilon, maxZ - epsilon)
const [maxCx, maxCz] = this.posToCell(maxX, maxZ)
const keys: CellKey[] = []
for (let cx = minCx; cx <= maxCx; cx++) {
@@ -71,9 +80,11 @@ export class SpatialGrid {
dimensions: [number, number, number],
rotation: [number, number, number],
) {
const cellKeys = this.getItemCells(position, dimensions, rotation)
const bounds = this.getAABB(position, dimensions, rotation)
const cellKeys = this.getItemCells(bounds)
this.itemCells.set(itemId, new Set(cellKeys))
this.itemBounds.set(itemId, bounds)
for (const key of cellKeys) {
if (!this.cells.has(key)) {
@@ -98,6 +109,7 @@ export class SpatialGrid {
}
}
this.itemCells.delete(itemId)
this.itemBounds.delete(itemId)
}
// Update = remove + insert
@@ -112,30 +124,51 @@ export class SpatialGrid {
}
// Query: is this placement valid?
// Uses cells as broad-phase, then checks actual AABB overlap (narrow-phase)
// to avoid false positives when adjacent items share a cell but don't overlap.
canPlace(
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
ignoreIds: string[] = [],
): { valid: boolean; conflictIds: string[] } {
const cellKeys = this.getItemCells(position, dimensions, rotation)
const bounds = this.getAABB(position, dimensions, rotation)
const cellKeys = this.getItemCells(bounds)
const ignoreSet = new Set(ignoreIds)
const conflicts = new Set<string>()
// Broad phase: collect candidate items from overlapping cells
const candidates = new Set<string>()
for (const key of cellKeys) {
const cell = this.cells.get(key)
if (cell) {
for (const id of cell.itemIds) {
if (!ignoreSet.has(id)) {
conflicts.add(id)
candidates.add(id)
}
}
}
}
// Narrow phase: check actual AABB overlap
// Items that merely touch (share an edge) are allowed; only true overlap conflicts.
const EPSILON = 1e-4 // tolerance to allow touching
const conflicts: string[] = []
for (const id of candidates) {
const other = this.itemBounds.get(id)
if (!other) continue
if (
bounds.minX < other.maxX - EPSILON &&
bounds.maxX > other.minX + EPSILON &&
bounds.minZ < other.maxZ - EPSILON &&
bounds.maxZ > other.minZ + EPSILON
) {
conflicts.push(id)
}
}
return {
valid: conflicts.size === 0,
conflictIds: [...conflicts],
valid: conflicts.length === 0,
conflictIds: conflicts,
}
}