Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3731eb3
Add roof surface placement support for items
sudhir9297 May 18, 2026
ed53bc2
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
fd8e02c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
7c1e383
fixed conflict
sudhir9297 May 20, 2026
b3377da
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
f177a65
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
9af7491
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
fd27524
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 27, 2026
b516298
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 28, 2026
ebfc8ce
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 3, 2026
27cd59b
feat(editor): 3D alignment guides for item/wall/fence move + placement
sudhir9297 Jun 3, 2026
dcafb74
fix(editor): align guides snap to nearest real anchor, drop bbox re-span
sudhir9297 Jun 3, 2026
20a221c
feat(editor): group move handle + shared group-transform core, move-t…
sudhir9297 Jun 3, 2026
00e9919
fix(editor): keep autosave alive across page unload
sudhir9297 Jun 3, 2026
1f9756e
feat(editor): paint eraser + reset-all, drop roof cross-role bleed
sudhir9297 Jun 3, 2026
606007e
feat(editor): alignment guides + drag bounding box across tools
sudhir9297 Jun 3, 2026
e5db589
fix(editor): axis-stable resize-arrow drag plane + slimmer gizmo handles
sudhir9297 Jun 3, 2026
59a5d38
fix(editor): fill-block wall opening highlight + selected frameless o…
sudhir9297 Jun 3, 2026
d26ccaa
fix(viewer): double-side slab hole side-walls
sudhir9297 Jun 3, 2026
8a64c10
fix(editor): box-select building-scoped nodes like elevators
sudhir9297 Jun 3, 2026
b7b313b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
6336556
Merge remote-tracking branch 'origin/main' into feat/wed-3-june
sudhir9297 Jun 4, 2026
1f9c0d4
feat(editor): shelf placement alignment + column placement ghost
sudhir9297 Jun 4, 2026
d6d20a5
feat(editor): floor-plan alignment, pivot moves & placement ghosts
sudhir9297 Jun 4, 2026
841b950
refactor(editor): address architecture review for floor-plan work
sudhir9297 Jun 4, 2026
05a89b9
fix(nodes): recreate window draft on wall:move when null after placement
open-pascal Jun 4, 2026
d3ca8e2
fix(nodes): recreate door draft on wall:move when null after placement
open-pascal Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/editor/components/scene-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
const handleLoad = useCallback(async () => initialScene, [initialScene])

const handleSave = useCallback(
async (graph: SceneGraph) => {
async (graph: SceneGraph, options?: { keepalive?: boolean }) => {
const graphJson = sceneGraphSignature(graph)
const isRecentRemoteApply = Date.now() < suppressRemoteSaveUntilRef.current
if (lastRemoteGraphJsonRef.current === graphJson) {
Expand All @@ -120,6 +120,11 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
'If-Match': String(versionRef.current),
},
body: JSON.stringify({ name: meta.name, graph }),
// `keepalive` lets the request outlive a page unload (the autosave
// flush on refresh/close). Browsers cap keepalive bodies at 64KB, so
// only the unload flush opts in — normal debounced saves omit it and
// can carry arbitrarily large scenes.
keepalive: options?.keepalive,
})

if (response.status === 409) {
Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/registry/handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export type EditorApi = {
* or curving state so the move starts from a clean slate.
*/
engageMove: (node: AnyNode) => void
/**
* Like {@link engageMove}, but for a press-drag gizmo: the move commits on
* pointer-release instead of waiting for a click, so the on-canvas move cross
* behaves as press-drag-release while still showing the placement preview.
*/
engageMoveDrag: (node: AnyNode) => void
/**
* Engage endpoint drag for kinds that own start / end anchors (walls,
* fences). No-ops for kinds without endpoints.
Expand Down Expand Up @@ -281,14 +287,25 @@ export type TapActionHandle<N = any> = {
* trigger the desired action.
*/
onActivate: (node: N, scene: SceneApi, editor: EditorApi) => void
/** Visual override; defaults to the standard chevron arrow. */
shape?: 'arrow' | 'corner-picker'
/**
* Visual override; defaults to the standard chevron arrow. `'move-cross'`
* reuses the 4-way move cross — a tap-to-engage grip that hands the node to
* its move tool (via `onActivate`) instead of running the generic translate
* drag, so the move tool's own preview / ticker feedback shows up.
*/
shape?: 'arrow' | 'corner-picker' | 'move-cross'
/**
* Required when `shape: 'corner-picker'` — controls the dashed leader's
* vertical extent. Pure callback so the descriptor doesn't need to
* import 3D libs.
*/
nodeHeight?: (node: N) => number
/**
* `shape: 'move-cross'` only — tilts the flat cross to lie in the right
* plane. `'horizontal'` (default) leaves it flat on the floor; `'node-normal'`
* stands it up against the node's facing plane (a wall face).
*/
plane?: 'horizontal' | 'node-normal'
portal?: HandlePortal
cursor?: Cursor
}
Expand Down
24 changes: 4 additions & 20 deletions packages/core/src/schema/nodes/roof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,25 +81,9 @@ export function getEffectiveRoofSurfaceMaterial(
}
}

if (role === 'edge') {
if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') {
return {
material: node.wallMaterial,
materialPreset:
typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined,
}
}
}

if (role === 'wall') {
if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') {
return {
material: node.edgeMaterial,
materialPreset:
typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined,
}
}
}

// No cross-role fallback: an unset role resolves only to the legacy
// catch-all (which covers all three roles for back-compat) and otherwise
// to the caller's theme default. Painting one surface must never bleed
// onto the others.
return getLegacyRoofSurfaceMaterial(node)
}
217 changes: 217 additions & 0 deletions packages/core/src/services/alignment-anchors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../registry'
import type { AnyNodeDefinition } from '../registry/types'
import type { AnyNode } from '../schema/types'
import {
collectAlignmentAnchors,
footprintAABB,
footprintAABBFrom,
movingFootprintAnchors,
polygonAnchors,
wallSegmentAnchors,
} from './alignment-anchors'

// Minimal floor-placed def whose footprint reads `dimensions` / `rotation`
// straight off the node, so tests can drive the AABB math directly.
function floorPlacedDef(kind: string, applies?: (n: AnyNode) => boolean): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as any,
category: 'utility',
defaults: () => ({}) as any,
capabilities: {
floorPlaced: {
footprint: (n: AnyNode) => ({
dimensions: (n as { dimensions?: [number, number, number] }).dimensions ?? [1, 1, 1],
rotation: (n as { rotation?: [number, number, number] }).rotation ?? [0, 0, 0],
}),
...(applies ? { applies } : {}),
},
},
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
} as AnyNodeDefinition
}

function plainDef(kind: string): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as any,
category: 'utility',
defaults: () => ({}) as any,
capabilities: {},
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
} as AnyNodeDefinition
}

const node = (over: Record<string, unknown>): AnyNode => over as unknown as AnyNode

describe('footprintAABBFrom', () => {
test('unrotated box is centred at position', () => {
const aabb = footprintAABBFrom([10, 0, 20], [2, 1, 4], 0)
expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 })
})

test('90° rotation swaps width and depth extents', () => {
const aabb = footprintAABBFrom([0, 0, 0], [2, 1, 4], Math.PI / 2)
expect(aabb.minX).toBeCloseTo(-2, 10)
expect(aabb.maxX).toBeCloseTo(2, 10)
expect(aabb.minZ).toBeCloseTo(-1, 10)
expect(aabb.maxZ).toBeCloseTo(1, 10)
})
})

describe('footprintAABB', () => {
beforeEach(() => nodeRegistry._reset())

test('reads dimensions + rotation from a floor-placed kind', () => {
registerNode(floorPlacedDef('box'))
const aabb = footprintAABB(
node({ id: 'b1', type: 'box', position: [10, 0, 20], dimensions: [2, 1, 4] }),
)
expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 })
})

test('returns null for a kind without a footprint', () => {
registerNode(plainDef('wall'))
expect(footprintAABB(node({ id: 'w1', type: 'wall', position: [0, 0, 0] }))).toBeNull()
})

test('derives an elevator footprint from its width / depth (no floorPlaced needed)', () => {
const aabb = footprintAABB(
node({ id: 'e1', type: 'elevator', position: [10, 0, 20], width: 2, depth: 4, rotation: 0 }),
)
expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 })
})

test('returns null when the kind predicate excludes the node', () => {
registerNode(floorPlacedDef('lamp', (n) => !(n as { attached?: boolean }).attached))
expect(
footprintAABB(node({ id: 'l1', type: 'lamp', position: [0, 0, 0], attached: true })),
).toBeNull()
expect(
footprintAABB(node({ id: 'l2', type: 'lamp', position: [0, 0, 0], attached: false })),
).not.toBeNull()
})
})

describe('movingFootprintAnchors', () => {
beforeEach(() => nodeRegistry._reset())

test('relocates the footprint corners around the proposed centre (edges only, no centre anchor)', () => {
registerNode(floorPlacedDef('box'))
const anchors = movingFootprintAnchors(
node({ id: 'm', type: 'box', position: [0, 0, 0], dimensions: [2, 1, 4] }),
10,
20,
)
// 2×4 box centred at (10, 20): corners at x∈{9,11}, z∈{18,22}.
expect(anchors).toHaveLength(4)
expect(anchors.every((a) => a.kind === 'corner')).toBe(true)
expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9, 11]))
expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([18, 22]))
})

test('rotationY override drives the AABB regardless of node rotation', () => {
registerNode(floorPlacedDef('box'))
const anchors = movingFootprintAnchors(
node({
id: 'm',
type: 'box',
position: [0, 0, 0],
dimensions: [2, 1, 4],
rotation: [0, 0, 0],
}),
0,
0,
Math.PI / 2,
)
const xs = anchors.map((a) => a.x)
// Rotated 90°, the 2×4 box spans ±2 in X (its depth) rather than ±1.
expect(Math.max(...xs)).toBeCloseTo(2, 10)
expect(Math.min(...xs)).toBeCloseTo(-2, 10)
})

test('returns empty for a footprintless kind', () => {
registerNode(plainDef('wall'))
expect(
movingFootprintAnchors(node({ id: 'w', type: 'wall', position: [0, 0, 0] }), 1, 1),
).toEqual([])
})
})

describe('wallSegmentAnchors', () => {
test('returns both endpoints as corners and the chord midpoint as center', () => {
const anchors = wallSegmentAnchors('w', [0, 0], [4, 2])
expect(anchors).toEqual([
{ nodeId: 'w', kind: 'corner', x: 0, z: 0 },
{ nodeId: 'w', kind: 'corner', x: 4, z: 2 },
{ nodeId: 'w', kind: 'center', x: 2, z: 1 },
])
})

test('adds ±thickness/2 face corners on each endpoint when thickness is given', () => {
// Horizontal wall along +X: perpendicular is ±Z, so faces sit at z = ±0.1.
const anchors = wallSegmentAnchors('w', [0, 0], [4, 0], 0.2)
expect(anchors).toEqual([
{ nodeId: 'w', kind: 'corner', x: 0, z: 0 },
{ nodeId: 'w', kind: 'corner', x: 4, z: 0 },
{ nodeId: 'w', kind: 'center', x: 2, z: 0 },
{ nodeId: 'w', kind: 'corner', x: 0, z: 0.1 },
{ nodeId: 'w', kind: 'corner', x: 0, z: -0.1 },
{ nodeId: 'w', kind: 'corner', x: 4, z: 0.1 },
{ nodeId: 'w', kind: 'corner', x: 4, z: -0.1 },
])
})

test('skips face corners for zero/degenerate input', () => {
expect(wallSegmentAnchors('w', [0, 0], [4, 0], 0)).toHaveLength(3)
expect(wallSegmentAnchors('w', [1, 1], [1, 1], 0.2)).toHaveLength(3)
})
})

describe('polygonAnchors', () => {
test('returns each vertex as a corner anchor', () => {
expect(
polygonAnchors('s', [
[0, 0],
[2, 0],
[2, 3],
]),
).toEqual([
{ nodeId: 's', kind: 'corner', x: 0, z: 0 },
{ nodeId: 's', kind: 'corner', x: 2, z: 0 },
{ nodeId: 's', kind: 'corner', x: 2, z: 3 },
])
})
})

describe('collectAlignmentAnchors', () => {
beforeEach(() => nodeRegistry._reset())

test('unions footprint corners, segment anchors and polygon vertices, excluding the moving node', () => {
registerNode(floorPlacedDef('box'))
const nodes = {
moving: node({ id: 'moving', type: 'box', position: [0, 0, 0], dimensions: [1, 1, 1] }),
box: node({ id: 'box', type: 'box', position: [5, 0, 5], dimensions: [2, 1, 2] }),
wall: node({ id: 'wall', type: 'wall', start: [0, 0], end: [4, 0] }),
slab: node({
id: 'slab',
type: 'slab',
polygon: [
[0, 0],
[2, 0],
[2, 2],
],
}),
}
const anchors = collectAlignmentAnchors(nodes, 'moving')
const ids = anchors.map((a) => a.nodeId)
expect(ids).not.toContain('moving')
expect(ids.filter((id) => id === 'box')).toHaveLength(4) // corner anchors
expect(ids.filter((id) => id === 'wall')).toHaveLength(7) // endpoints + midpoint + 4 face corners
expect(ids.filter((id) => id === 'slab')).toHaveLength(3) // polygon vertices
})
})
Loading
Loading