editor: improve cabinet resizing and wall alignment (#503)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* fix wall treatment miter geometry

* fix cabinet group resizing and corner alignment

* fix cabinet corner depth resizing

* fix(cabinet): stabilize modular preset changes

* fix(cabinet): stabilize corner resizing and wall alignment

* fix(nodes): stabilize wall cabinet depth resizing

* fix(editor): hide cabinet arrows for module selection

* feat(cabinet): add individual width resize handles

* feat(cabinet): improve wall cabinet editing

* fix(cabinet): harden wall cabinet resizing

* fix(cabinet): correct wall drag and depth handles

* fix(cabinet): refine individual depth resizing

* fix(cabinet): preserve context-aware corner depth behavior

* fix(editor): respect snapping modes for resize handles

* fix(editor): harden cabinet resize interactions

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
This commit is contained in:
Sudhir Yadav
2026-07-19 17:33:57 +02:00
committed by GitHub
co-authored by Claude Opus 4.6 Aymeric Rabot
parent c0a5db935e
commit 6cc10c929e
64 changed files with 6481 additions and 402 deletions
+78 -6
View File
@@ -14,6 +14,12 @@ const ADJACENT_RUN_Z_TOLERANCE = 0.03
type ModuleLike = Pick<CabinetModuleNode, 'id' | 'position' | 'width'>
type ReflowRunModulesOptions = {
minimumWidth?: number
preserveExtent?: boolean
restorableWidthById?: ReadonlyMap<CabinetModuleNode['id'], number>
}
export function sortRunModules<T extends ModuleLike>(modules: readonly T[]): T[] {
return [...modules].sort((a, b) => a.position[0] - b.position[0])
}
@@ -71,7 +77,8 @@ export type RunSpan = {
/**
* Contiguous same-height module groups along the run — the units the
* countertop, plinth, and appliance-gap logic operate on. A gap, a
* base↔tall transition, or a top-height change starts a new span.
* base↔tall transition, a top-height change, or a depth-footprint change
* starts a new span.
*/
export function getRunSpans(
modules: readonly Pick<
@@ -98,7 +105,9 @@ export function getRunSpans(
!current ||
minX - current.maxX > RUN_ADJACENCY_EPSILON ||
current.hasCountertop !== hasCountertop ||
Math.abs(current.topY - topY) > RUN_ADJACENCY_EPSILON
Math.abs(current.topY - topY) > RUN_ADJACENCY_EPSILON ||
Math.abs(current.minZ - minZ) > RUN_ADJACENCY_EPSILON ||
Math.abs(current.maxZ - maxZ) > RUN_ADJACENCY_EPSILON
) {
spans.push({
minX,
@@ -263,12 +272,26 @@ export function getRunSpanEnds(
return spans.map((span, spanIndex) => {
const previousSpan = spans[spanIndex - 1]
const nextSpan = spans[spanIndex + 1]
const hasFlushCountertopLeftNeighbor =
!!previousSpan &&
previousSpan.hasCountertop &&
span.hasCountertop &&
Math.abs(previousSpan.topY - span.topY) <= RUN_ADJACENCY_EPSILON &&
span.minX - previousSpan.maxX <= RUN_ADJACENCY_EPSILON
const hasFlushCountertopRightNeighbor =
!!nextSpan &&
nextSpan.hasCountertop &&
span.hasCountertop &&
Math.abs(nextSpan.topY - span.topY) <= RUN_ADJACENCY_EPSILON &&
nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON
const hasInternalLeftNeighbor =
!!previousSpan &&
!previousSpan.hasCountertop &&
(!previousSpan.hasCountertop || hasFlushCountertopLeftNeighbor) &&
span.minX - previousSpan.maxX <= RUN_ADJACENCY_EPSILON
const hasInternalRightNeighbor =
!!nextSpan && !nextSpan.hasCountertop && nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON
!!nextSpan &&
(!nextSpan.hasCountertop || hasFlushCountertopRightNeighbor) &&
nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON
const hasExternalLeftNeighbor = hasAdjacentCabinetSpan({
depth: span.depth,
edgeX: span.minX,
@@ -374,13 +397,62 @@ export function reflowRunModules<T extends ModuleLike>(
modules: readonly T[],
selectedId: CabinetModuleNode['id'],
selectedWidth: number,
options: ReflowRunModulesOptions = {},
): Array<{ id: T['id']; position: T['position']; width: number }> {
const sorted = sortRunModules(modules)
if (!sorted.some((module) => module.id === selectedId)) return []
const selectedIndex = sorted.findIndex((module) => module.id === selectedId)
if (selectedIndex < 0) return []
const widths = new Map(sorted.map((module) => [module.id, module.width]))
widths.set(selectedId, selectedWidth)
const selected = sorted[selectedIndex]!
let remainingGrowth = selectedWidth - selected.width
if (options.preserveExtent && remainingGrowth > RUN_ADJACENCY_EPSILON) {
const minimumWidth = options.minimumWidth ?? 0.3
const left = sorted.slice(0, selectedIndex).reverse()
const right = sorted.slice(selectedIndex + 1)
const capacity = (candidates: readonly T[]) =>
candidates.reduce((total, module) => total + Math.max(0, module.width - minimumWidth), 0)
const candidates = capacity(left) > capacity(right) ? [...left, ...right] : [...right, ...left]
for (const module of candidates) {
if (remainingGrowth <= RUN_ADJACENCY_EPSILON) break
const available = Math.max(0, module.width - minimumWidth)
const reduction = Math.min(available, remainingGrowth)
widths.set(module.id, module.width - reduction)
remainingGrowth -= reduction
}
}
let remainingFreedWidth = selected.width - selectedWidth
if (
options.preserveExtent &&
remainingFreedWidth > RUN_ADJACENCY_EPSILON &&
options.restorableWidthById
) {
const left = sorted.slice(0, selectedIndex).reverse()
const right = sorted.slice(selectedIndex + 1)
const restorable = (candidates: readonly T[]) =>
candidates.reduce(
(total, module) => total + (options.restorableWidthById?.get(module.id) ?? 0),
0,
)
const candidates =
restorable(left) > restorable(right) ? [...left, ...right] : [...right, ...left]
for (const module of candidates) {
if (remainingFreedWidth <= RUN_ADJACENCY_EPSILON) break
const available = Math.max(0, options.restorableWidthById.get(module.id) ?? 0)
const restoration = Math.min(available, remainingFreedWidth)
widths.set(module.id, module.width + restoration)
remainingFreedWidth -= restoration
}
}
let nextLeft = runMinX(sorted)
return sorted.map((module) => {
const width = module.id === selectedId ? selectedWidth : module.width
const width = widths.get(module.id) ?? module.width
const position: T['position'] = [
nextLeft + width / 2,
module.position[1],