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
+16 -1
View File
@@ -129,6 +129,17 @@ export type LinearResizeHandle<N> = {
anchor: HandleAnchor
currentValue: (node: N) => number
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
/**
* Additional live-only patches for geometry owned by related nodes. The
* editor publishes these during the drag and clears them on release or
* cancellation; committed scene writes remain the responsibility of
* `commit` (or the generic selected-node update).
*/
previewOverrides?: (
node: N,
newValue: number,
sceneApi: SceneApi,
) => ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>
/** Optional live-scene visibility gate for context-dependent arrows. */
visible?: (node: N, sceneApi: SceneApi) => boolean
/**
@@ -163,6 +174,8 @@ export type LinearResizeHandle<N> = {
max?: number | ((node: N, sceneApi: SceneApi) => number)
/** Snap the resized scalar to the editor's active grid step before apply. */
gridSnap?: boolean
/** Kind-owned magnetic snap for the resized scalar, gated by the active snapping mode. */
magneticSnap?: (node: N, newValue: number, sceneApi: SceneApi) => number
placement: HandlePlacement<N>
/**
* Dimension this handle steers (e.g. `'height'`). When set, the editor
@@ -444,4 +457,6 @@ export type HandleDescriptor<N = any> =
* Static array, or a function for shape-dependent cases (column
* crossSection / supportStyle, stair-segment segmentType, etc.).
*/
export type HandleList<N> = HandleDescriptor<N>[] | ((node: N) => HandleDescriptor<N>[])
export type HandleList<N> =
| HandleDescriptor<N>[]
| ((node: N, sceneApi?: SceneApi) => HandleDescriptor<N>[])
+1
View File
@@ -109,6 +109,7 @@ export type {
NodePort,
NodeQuickAction,
NodeQuickActionIcon,
NodeQuickActionNodeScope,
NodeQuickActionProvider,
NodeQuickActionResult,
NodeRegistry,
+6
View File
@@ -1070,6 +1070,8 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* and runs through `SceneApi`.
*/
quickActions?: NodeQuickActionProvider<z.infer<S>>
/** Scene-graph scope the quick-action provider needs for derived availability. */
quickActionNodeScope?: NodeQuickActionNodeScope
/**
* Sidebar-tree presentation hooks. Lets a kind reshape how the generic
* scene tree walks its subtree — hiding derived/managed nodes and
@@ -1655,6 +1657,8 @@ export type NodeQuickActionResult = {
selectedIds?: AnyNodeId[]
}
export type NodeQuickActionNodeScope = 'family' | 'level'
export type NodeQuickAction = {
id: string
label: string
@@ -1667,6 +1671,8 @@ export type NodeQuickAction = {
*/
icon?: NodeQuickActionIcon | IconRef
disabled?: boolean
/** Whether pressing a disabled action should acknowledge its blocked state. */
blockedFeedback?: boolean
history?: 'single'
run: (args: { node: AnyNode; sceneApi: SceneApi }) => NodeQuickActionResult | undefined
}
+2 -2
View File
@@ -80,8 +80,8 @@ export type CabinetCompartmentSchema = z.infer<typeof CabinetCompartment>
const cabinetBoxFields = {
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),
width: z.number().min(0.05).max(3).default(0.6),
depth: z.number().min(0.3).max(1.2).default(0.58),
width: z.number().min(0.05).max(3).default(0.5),
depth: z.number().min(0.3).max(1.2).default(0.5),
carcassHeight: z.number().min(0.4).max(2.4).default(0.72),
operationState: z.number().min(0).max(1).default(0),
plinthHeight: z.number().min(0).max(0.3).default(0.1),
@@ -64,3 +64,17 @@ describe('wall mitering miter limit', () => {
expect(startSideX).toBeGreaterThan(-0.5)
})
})
describe('wall miter boundary sides', () => {
test('keeps left and right on the same physical face at both free endpoints', () => {
const node = wall('A', [0, 0], [3, 0])
const boundary = getWallMiterBoundaryPoints(node, calculateLevelMiters([node]))
expect(boundary).not.toBeNull()
if (!boundary) throw new Error('expected miter boundary points')
expect(boundary.startLeft.y).toBeCloseTo(0.05)
expect(boundary.endLeft.y).toBeCloseTo(0.05)
expect(boundary.startRight.y).toBeCloseTo(-0.05)
expect(boundary.endRight.y).toBeCloseTo(-0.05)
})
})
@@ -178,11 +178,8 @@ function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
endType === 'start'
? { x: wall.start[0], y: wall.start[1] }
: { x: wall.end[0], y: wall.end[1] }
const vector =
endType === 'start'
? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
const length = Math.hypot(vector.x, vector.y)
const direction = { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
const length = Math.hypot(direction.x, direction.y)
if (length < 1e-9) {
return {
@@ -194,8 +191,11 @@ function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
return {
point,
tangent: { x: vector.x / length, y: vector.y / length },
normal: { x: -vector.y / length, y: vector.x / length },
tangent:
endType === 'start'
? { x: direction.x / length, y: direction.y / length }
: { x: -direction.x / length, y: -direction.y / length },
normal: { x: -direction.y / length, y: direction.x / length },
}
}