diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 15ba572b..86dae30e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -59,8 +59,18 @@ export { isOperationDoorType, SECTIONAL_GARAGE_RENDER_OPEN_SCALE, } from './lib/door-operation' +export { + type Point2D as PolygonPoint2D, + pointInPolygon as pointInPolygon2D, + pointOnSegment, + polygonContainsPolygon, + polygonsIntersect, + polygonsOverlap, + segmentsIntersect, +} from './lib/polygon-relations' export { getRenderableSlabPolygon } from './lib/slab-polygon' export { + type AutoCeilingPlanningContext, type AutoCeilingSyncPlan, type AutoSlabSyncPlan, detectSpacesForLevel, @@ -69,6 +79,7 @@ export { pauseSpaceDetection, planAutoCeilingsForLevel, planAutoSlabsForLevel, + projectAutoSlabsForPlan, resumeSpaceDetection, type Space, wallTouchesOthers, @@ -159,6 +170,7 @@ export { resolveElevatorServiceLevels, } from './systems/elevator/elevator-service' export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' +export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { StairOpeningSystem } from './systems/stair/stair-opening-system' export { diff --git a/packages/core/src/lib/polygon-relations.ts b/packages/core/src/lib/polygon-relations.ts new file mode 100644 index 00000000..72bf2108 --- /dev/null +++ b/packages/core/src/lib/polygon-relations.ts @@ -0,0 +1,102 @@ +export type Point2D = [number, number] + +export function pointOnSegment(point: Point2D, start: Point2D, end: Point2D, tolerance = 1e-6) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const cross = (point[0] - start[0]) * dz - (point[1] - start[1]) * dx + if (Math.abs(cross) > tolerance) return false + + const dot = + (point[0] - start[0]) * (point[0] - end[0]) + (point[1] - start[1]) * (point[1] - end[1]) + return dot <= tolerance +} + +export function pointInPolygon( + point: Point2D, + polygon: Point2D[], + options?: { includeBoundary?: boolean }, +) { + if (polygon.length < 3) return false + const includeBoundary = options?.includeBoundary ?? true + if ( + polygon.some((start, index) => + pointOnSegment(point, start, polygon[(index + 1) % polygon.length]!), + ) + ) { + return includeBoundary + } + + let inside = false + const [x, z] = point + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const current = polygon[i]! + const previous = polygon[j]! + const intersects = + current[1] > z !== previous[1] > z && + x < ((previous[0] - current[0]) * (z - current[1])) / (previous[1] - current[1]) + current[0] + if (intersects) inside = !inside + } + return inside +} + +export function segmentsIntersect(a: Point2D, b: Point2D, c: Point2D, d: Point2D) { + const cross = (ux: number, uz: number, vx: number, vz: number) => ux * vz - uz * vx + const abx = b[0] - a[0] + const abz = b[1] - a[1] + const acx = c[0] - a[0] + const acz = c[1] - a[1] + const adx = d[0] - a[0] + const adz = d[1] - a[1] + const cdx = d[0] - c[0] + const cdz = d[1] - c[1] + const cax = a[0] - c[0] + const caz = a[1] - c[1] + const cbx = b[0] - c[0] + const cbz = b[1] - c[1] + + const o1 = cross(abx, abz, acx, acz) + const o2 = cross(abx, abz, adx, adz) + const o3 = cross(cdx, cdz, cax, caz) + const o4 = cross(cdx, cdz, cbx, cbz) + + if (Math.sign(o1) !== Math.sign(o2) && Math.sign(o3) !== Math.sign(o4)) return true + return ( + pointOnSegment(c, a, b) || + pointOnSegment(d, a, b) || + pointOnSegment(a, c, d) || + pointOnSegment(b, c, d) + ) +} + +export function polygonsIntersect(left: Point2D[], right: Point2D[]) { + for (let leftIndex = 0; leftIndex < left.length; leftIndex++) { + const leftStart = left[leftIndex]! + const leftEnd = left[(leftIndex + 1) % left.length]! + for (let rightIndex = 0; rightIndex < right.length; rightIndex++) { + if ( + segmentsIntersect( + leftStart, + leftEnd, + right[rightIndex]!, + right[(rightIndex + 1) % right.length]!, + ) + ) { + return true + } + } + } + + return false +} + +export function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) { + return inner.every((point) => pointInPolygon(point, outer)) +} + +export function polygonsOverlap(left: Point2D[], right: Point2D[]) { + return ( + polygonsIntersect(left, right) || + left.some((point) => pointInPolygon(point, right)) || + right.some((point) => pointInPolygon(point, left)) + ) +} diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts new file mode 100644 index 00000000..22a5fa2c --- /dev/null +++ b/packages/core/src/lib/space-detection.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test' +import { CeilingNode, SlabNode, WallNode } from '../schema' +import { planAutoCeilingsForLevel } from './space-detection' + +const square: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + +function roomPolygon() { + return square.map(([x, y]) => ({ x, y })) +} + +function squareWalls(height = 2.5) { + return [ + WallNode.parse({ start: [0, 0], end: [4, 0], height }), + WallNode.parse({ start: [4, 0], end: [4, 3], height }), + WallNode.parse({ start: [4, 3], end: [0, 3], height }), + WallNode.parse({ start: [0, 3], end: [0, 0], height }), + ] +} + +function slab(elevation: number) { + return SlabNode.parse({ + polygon: square, + elevation, + autoFromWalls: true, + }) +} + +describe('planAutoCeilingsForLevel', () => { + test('creates auto ceilings at the top of the room walls', () => { + const created = planAutoCeilingsForLevel([roomPolygon()], [], { + walls: squareWalls(), + slabs: [slab(0.05)], + }).create[0] + + expect(created?.height).toBeCloseTo(2.55) + }) + + test('updates existing auto ceiling height when the slab elevation changes', () => { + const ceiling = CeilingNode.parse({ + polygon: square, + height: 2.55, + autoFromWalls: true, + }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { + walls: squareWalls(), + slabs: [slab(0.4)], + }) + + expect(plan.update).toHaveLength(1) + expect(plan.update[0]?.id).toBe(ceiling.id) + expect(plan.update[0]?.data.polygon).toBeUndefined() + expect(plan.update[0]?.data.height).toBeCloseTo(2.9) + }) + + test('updates existing auto ceiling height when wall height changes', () => { + const ceiling = CeilingNode.parse({ + polygon: square, + height: 2.55, + autoFromWalls: true, + }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { + walls: squareWalls(3), + slabs: [slab(0.05)], + }) + + expect(plan.update).toHaveLength(1) + expect(plan.update[0]?.data.height).toBeCloseTo(3.05) + }) + + test('does not replace a manual ceiling with an auto ceiling', () => { + const manualCeiling = CeilingNode.parse({ + polygon: square, + height: 2.5, + autoFromWalls: false, + }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manualCeiling], { + walls: squareWalls(), + slabs: [slab(0.4)], + }) + + expect(plan.create).toHaveLength(0) + expect(plan.update).toHaveLength(0) + }) +}) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 5001d9ac..79e69c86 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -41,6 +41,10 @@ type DetectedRoom = { bbox: ReturnType } +type DetectedCeilingRoom = DetectedRoom & { + ceilingHeight: number +} + export type AutoSlabSyncPlan = { create: SlabNodeType[] update: Array<{ id: SlabNodeType['id']; data: Partial }> @@ -55,9 +59,16 @@ export type AutoCeilingSyncPlan = { const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 +const CEILING_HEIGHT_EPSILON = 1e-6 const ROOM_CURVE_TOLERANCE = 0.04 const MAX_CURVE_SUBDIVISION_DEPTH = 6 const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08 +const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08 + +export type AutoCeilingPlanningContext = { + walls?: WallNode[] + slabs?: SlabNodeType[] +} function pointFromTuple(point: [number, number]): Point2D { return { x: point[0], y: point[1] } @@ -186,6 +197,100 @@ function bboxOverlapArea(a: ReturnType, b: ReturnType pointDistanceToPolygonBoundary(point, roomPolygon) <= WALL_ROOM_BOUNDARY_TOLERANCE, + ) + + return matchingPoints.length >= 2 +} + +function pointIsOnSlab(point: Point2D, slab: SlabNodeType) { + if (slab.polygon.length < 3) return false + const slabPolygon = slab.polygon.map(pointFromTuple) + if (!pointInPolygon(point, slabPolygon)) return false + + for (const hole of slab.holes ?? []) { + if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) { + return false + } + } + + return true +} + +function slabSupportsRoom(roomPolygon: Point2D[], slab: SlabNodeType) { + if (slab.polygon.length < 3) return false + if (polygonSignature(slab.polygon.map(pointFromTuple)) === polygonSignature(roomPolygon)) { + return true + } + return pointIsOnSlab(polygonCentroid(roomPolygon), slab) +} + +function resolveRoomSlabElevation(roomPolygon: Point2D[], slabs: SlabNodeType[] = []) { + let maxElevation = 0 + + for (const slab of slabs) { + if (!slabSupportsRoom(roomPolygon, slab)) continue + maxElevation = Math.max(maxElevation, slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION) + } + + return maxElevation +} + +function resolveRoomWallHeight(roomPolygon: Point2D[], walls: WallNode[] = []) { + let maxHeight = 0 + + for (const wall of walls) { + if (!wallBoundsRoom(wall, roomPolygon)) continue + const height = wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT + if (Number.isFinite(height)) { + maxHeight = Math.max(maxHeight, height) + } + } + + return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT +} + +function resolveAutoCeilingHeight( + roomPolygon: Point2D[], + context: AutoCeilingPlanningContext = {}, +) { + return ( + resolveRoomSlabElevation(roomPolygon, context.slabs) + + resolveRoomWallHeight(roomPolygon, context.walls) + ) +} + function getWallDirection(wall: Pick) { const dx = wall.end[0] - wall.start[0] const dy = wall.end[1] - wall.start[1] @@ -481,6 +586,7 @@ function wallGeometrySignature(wall: WallNode) { wall.end[0].toFixed(4), wall.end[1].toFixed(4), (wall.thickness ?? 0.2).toFixed(4), + (wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4), getClampedWallCurveOffset(wall).toFixed(4), ].join('|') } @@ -489,6 +595,48 @@ function levelWallSnapshot(walls: WallNode[]) { return walls.map(wallGeometrySignature).sort().join('||') } +function slabGeometrySignature(slab: SlabNodeType) { + const polygon = slab.polygon + .map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`) + .join(';') + const holes = (slab.holes ?? []) + .map((hole) => hole.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`).join(';')) + .join('/') + + return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join( + '|', + ) +} + +function levelSlabSnapshot(slabs: SlabNodeType[]) { + return slabs.map(slabGeometrySignature).sort().join('||') +} + +function levelStructureSnapshots(nodes: Record) { + const byLevel = new Map() + const getEntry = (levelId: string) => { + const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] } + byLevel.set(levelId, entry) + return entry + } + + for (const node of Object.values(nodes)) { + if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue + if ((node as any).type === 'wall') { + getEntry((node as any).parentId).walls.push(node as WallNode) + } else if ((node as any).type === 'slab') { + getEntry((node as any).parentId).slabs.push(SlabNode.parse(node)) + } + } + + const snapshots = new Map() + for (const [levelId, entry] of byLevel.entries()) { + snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`) + } + + return snapshots +} + function buildSpace(levelId: string, polygon: Point2D[]): Space { const signature = polygonSignature(polygon) return { @@ -654,18 +802,44 @@ function syncAutoSlabsForLevel( if (plan.create.length > 0) { sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) } + + return plan +} + +export function projectAutoSlabsForPlan( + existingSlabs: SlabNodeType[], + plan: AutoSlabSyncPlan, +): SlabNodeType[] { + const slabsById = new Map(existingSlabs.map((slab) => [slab.id, slab])) + + for (const id of plan.delete) { + slabsById.delete(id) + } + + for (const update of plan.update) { + const slab = slabsById.get(update.id) + if (!slab) continue + slabsById.set(update.id, SlabNode.parse({ ...slab, ...update.data })) + } + + for (const slab of plan.create) { + slabsById.set(slab.id, slab) + } + + return [...slabsById.values()] } export function planAutoCeilingsForLevel( roomPolygons: Point2D[][], existingCeilings: CeilingNodeType[], + context: AutoCeilingPlanningContext = {}, ): AutoCeilingSyncPlan { const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls) const manualSignatures = new Set( manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))), ) - const detected: DetectedRoom[] = roomPolygons + const detected: DetectedCeilingRoom[] = roomPolygons .map((poly) => ({ poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( pointFromTuple, @@ -681,6 +855,7 @@ export function planAutoCeilingsForLevel( centroid: polygonCentroid(room.poly), area: Math.abs(polygonArea(room.poly)), bbox: bboxOf(room.poly), + ceilingHeight: resolveAutoCeilingHeight(room.poly, context), })) .filter(({ sig }) => !manualSignatures.has(sig)) @@ -698,7 +873,7 @@ export function planAutoCeilingsForLevel( const matchedCeilingIds = new Set() const matchedDetectedIdx = new Set() - const updatesById = new Map() + const updatesById = new Map() const autoBySignature = new Map() for (const entry of existingAutoMeta) { @@ -711,7 +886,10 @@ export function planAutoCeilingsForLevel( matchedDetectedIdx.add(index) matchedCeilingIds.add(existing.ceiling.id) - updatesById.set(existing.ceiling.id, room.poly.map(pointToTuple)) + updatesById.set(existing.ceiling.id, { + polygon: room.poly.map(pointToTuple), + height: room.ceilingHeight, + }) }) const remainingDetected = detected @@ -746,7 +924,10 @@ export function planAutoCeilingsForLevel( matchedDetectedIdx.add(index) matchedCeilingIds.add(bestMatch.entry.ceiling.id) - updatesById.set(bestMatch.entry.ceiling.id, room.poly.map(pointToTuple)) + updatesById.set(bestMatch.entry.ceiling.id, { + polygon: room.poly.map(pointToTuple), + height: room.ceilingHeight, + }) } const ceilingsToDelete = existingAuto @@ -756,12 +937,21 @@ export function planAutoCeilingsForLevel( const ceilingsToUpdate = existingAuto .filter((ceiling) => updatesById.has(ceiling.id)) .flatMap((ceiling) => { - const polygon = updatesById.get(ceiling.id) - if (!polygon) return [] + const update = updatesById.get(ceiling.id) + if (!update) return [] - return sameTuplePolygon(ceiling.polygon, polygon) - ? [] - : [{ id: ceiling.id, data: { polygon } }] + const data: Partial = {} + if (!sameTuplePolygon(ceiling.polygon, update.polygon)) { + data.polygon = update.polygon + } + if ( + Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) > + CEILING_HEIGHT_EPSILON + ) { + data.height = update.height + } + + return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }] }) const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] @@ -780,7 +970,7 @@ export function planAutoCeilingsForLevel( name, polygon: room.poly.map(pointToTuple), holes: [], - height: DEFAULT_AUTO_CEILING_HEIGHT, + height: room.ceilingHeight, autoFromWalls: true, }), ) @@ -798,8 +988,9 @@ function syncAutoCeilingsForLevel( roomPolygons: Point2D[][], existingCeilings: CeilingNodeType[], sceneStore: any, + context: AutoCeilingPlanningContext = {}, ) { - const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings) + const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context) if (plan.delete.length > 0) { sceneStore.getState().deleteNodes(plan.delete) @@ -882,17 +1073,15 @@ function runSpaceDetection( ) } - syncAutoSlabsForLevel( - levelId, - roomPolygons, - slabs.map((slab: any) => SlabNode.parse(slab)), - sceneStore, - ) + const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab)) + const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore) + const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan) syncAutoCeilingsForLevel( levelId, roomPolygons, ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)), sceneStore, + { walls, slabs: projectedSlabs }, ) for (const space of spaces) { @@ -935,21 +1124,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => if (getSceneHistoryPauseDepth() > 0) return const nodes = state.nodes - const wallsByLevel = new Map() - - for (const node of Object.values(nodes)) { - if (node && (node as any).type === 'wall' && (node as any).parentId) { - const levelId = (node as any).parentId as string - const levelWalls = wallsByLevel.get(levelId) ?? [] - levelWalls.push(node as WallNode) - wallsByLevel.set(levelId, levelWalls) - } - } - - const currentSnapshots = new Map() - for (const [levelId, walls] of wallsByLevel.entries()) { - currentSnapshots.set(levelId, levelWallSnapshot(walls)) - } + const currentSnapshots = levelStructureSnapshots(nodes) // Paused: roll the snapshot forward so we don't backfill (and re-duplicate) // every paused change once detection resumes. Whatever the AI built while @@ -984,7 +1159,8 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => } finally { resumeSceneHistory(sceneStore) previousSnapshots.clear() - for (const [levelId, snapshot] of currentSnapshots.entries()) { + const postRunSnapshots = levelStructureSnapshots(sceneStore.getState().nodes) + for (const [levelId, snapshot] of postRunSnapshots.entries()) { previousSnapshots.set(levelId, snapshot) } isProcessing = false diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 154e7b4c..2d69058b 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -130,6 +130,8 @@ export type LinearResizeHandle = { overrideTarget?: (node: N, sceneApi: SceneApi) => AnyNodeId | undefined min?: number | ((node: N, sceneApi: SceneApi) => number) max?: number | ((node: N, sceneApi: SceneApi) => number) + /** Snap the resized scalar to the editor's active grid step before apply. */ + gridSnap?: boolean placement: HandlePlacement /** * Dimension this handle steers (e.g. `'height'`). When set, the editor @@ -316,7 +318,9 @@ export type TapActionHandle = { * the hit into the node's parent-local frame, and reports the new local XZ * (optionally grid-snapped via `snapExtents`) to `apply`. Press-drag-release * with the same live-override → commit-on-release flow as the resize / rotate - * handles. Rendered as a 4-way cross of double-headed arrows. + * handles. Rendered as a 4-way cross of double-headed arrows. Pure translation + * does not require geometry dirtying; renderers consume the live position + * override directly. */ export type TranslateHandle = { kind: 'translate' diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index c1ce0856..6013f5fd 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -541,6 +541,8 @@ export type FloorplanAffordance = { nodes: Record /** Initial pointer position in plan coordinates. */ initialPlanPoint: FloorplanAffordancePoint + /** Active editor grid step in meters. */ + gridSnapStep: number }): FloorplanAffordanceSession } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index c7ec64b5..777f3456 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -101,6 +101,7 @@ export { getActiveRoofHeight, getEffectiveSegmentSurfaceMaterial, getPitchFromActiveRoofHeight, + getRoofSegmentSurfaceY, getSegmentSlopeFrame, hasSegmentMaterialOverride, ROOF_SHAPE_DEFAULTS, diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index d6e8f56b..201f5ed0 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -181,7 +181,6 @@ function getPrimarySlopeRun(input: PitchInputs & ShapeRatios): number { return min * input.mansardSteepWidthRatio case 'dutch': return min * input.dutchHipWidthRatio - case 'hip': default: return min / 2 } @@ -253,6 +252,42 @@ export function getActiveRoofHeight(node: Parameters & + Parameters[0], + localX: number, + localZ: number, +): number { + const activeRh = getActiveRoofHeight(node) + const peakY = node.wallHeight + activeRh + if (activeRh === 0) return node.wallHeight + + if ( + node.roofType === 'gable' || + node.roofType === 'gambrel' || + node.roofType === 'mansard' || + node.roofType === 'dutch' + ) { + const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0 + return peakY - t * activeRh + } + + if (node.roofType === 'shed') { + const t = (localZ + node.depth / 2) / (node.depth || 1) + return peakY - t * activeRh + } + + if (node.roofType === 'hip') { + const fx = node.width > 0 ? Math.abs(localX) / (node.width / 2) : 0 + const fz = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0 + return peakY - Math.max(fx, fz) * activeRh + } + + const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0 + return peakY - t * activeRh +} + /** * Inverse of `getActiveRoofHeight` — recover the pitch a legacy * `roofHeight` value would correspond to. Used by the scene migration. diff --git a/packages/core/src/store/use-live-node-overrides.ts b/packages/core/src/store/use-live-node-overrides.ts index aedb2293..50e6c98b 100644 --- a/packages/core/src/store/use-live-node-overrides.ts +++ b/packages/core/src/store/use-live-node-overrides.ts @@ -8,6 +8,7 @@ type LiveNodeOverrideState = { setMany(entries: ReadonlyArray): void get(nodeId: string): LiveNodeOverrides | undefined clear(nodeId: string): void + clearFields(nodeId: string, keys: readonly string[]): void clearAll(): void } @@ -39,6 +40,24 @@ const useLiveNodeOverrides = create((set, get) => ({ next.delete(nodeId) return { overrides: next } }), + clearFields: (nodeId, keys) => + set((state) => { + const current = state.overrides.get(nodeId) + if (!current) return state + + const nextValues = { ...current } + for (const key of keys) { + delete nextValues[key] + } + + const next = new Map(state.overrides) + if (Object.keys(nextValues).length === 0) { + next.delete(nodeId) + } else { + next.set(nodeId, nextValues) + } + return { overrides: next } + }), clearAll: () => set({ overrides: new Map() }), })) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index b253fcaf..90b6efff 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -43,7 +43,7 @@ function getEnumValue( } function getNullableString(value: unknown) { - return typeof value === 'string' ? value : null + return typeof value === 'string' && value.length > 0 ? value : null } function getStringArray(value: unknown) { diff --git a/packages/core/src/systems/stair/stair-opening-preview.test.ts b/packages/core/src/systems/stair/stair-opening-preview.test.ts new file mode 100644 index 00000000..94aa55dd --- /dev/null +++ b/packages/core/src/systems/stair/stair-opening-preview.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../../schema' +import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +import { + getNodesWithLiveStairOpeningInputs, + hasLiveStairOpeningInputs, +} from './stair-opening-preview' +import { syncAutoStairOpenings } from './stair-opening-sync' + +describe('stair opening previews', () => { + test('computes auto openings from live stair transforms', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const slab = SlabNode.parse({ + name: 'Upper Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_live', + width: 1, + length: 3, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_live', + name: 'Live Stair', + parentId: ground.id, + position: [1, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, slab, stair, { ...segment, parentId: stair.id }].map((node) => [ + node.id, + node, + ]), + ) as Record + const liveTransforms = new Map([ + [stair.id, { position: [3, 0, 0.2] as [number, number, number], rotation: 0 }], + ]) + const liveOverrides = new Map>() + + expect(hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, new Set())).toBe(true) + + const previewNodes = getNodesWithLiveStairOpeningInputs( + nodes, + liveTransforms, + liveOverrides, + new Set(), + ) + const updates = syncAutoStairOpenings(previewNodes) + const hole = updates.find((update) => update.id === slab.id)?.data.holes?.[0] + + expect(hole).toBeDefined() + expect(Math.max(...hole!.map(([x]) => x))).toBeGreaterThan(3.4) + }) + + test('ignores its own live surface overrides as preview inputs', () => { + const slab = SlabNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + }) + const nodes = { [slab.id]: slab } as Record + const liveOverrides = new Map>([ + [ + slab.id, + { + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + ], + }, + ], + ]) + + expect(hasLiveStairOpeningInputs(nodes, new Map(), liveOverrides, new Set([slab.id]))).toBe( + false, + ) + }) +}) diff --git a/packages/core/src/systems/stair/stair-opening-preview.ts b/packages/core/src/systems/stair/stair-opening-preview.ts new file mode 100644 index 00000000..24b46418 --- /dev/null +++ b/packages/core/src/systems/stair/stair-opening-preview.ts @@ -0,0 +1,120 @@ +import type { AnyNode, AnyNodeId, CeilingNode, SlabNode } from '../../schema' +import useLiveNodeOverrides, { type LiveNodeOverrides } from '../../store/use-live-node-overrides' +import type { LiveTransform } from '../../store/use-live-transforms' +import useScene from '../../store/use-scene' + +type SurfaceOpeningUpdate = { + id: AnyNodeId + data: Partial +} + +const SURFACE_OPENING_FIELDS = ['holes', 'holeMetadata'] as const + +function isSurface(node: AnyNode | undefined): node is SlabNode | CeilingNode { + return node?.type === 'slab' || node?.type === 'ceiling' +} + +function isStairOpeningInputNode(node: AnyNode | undefined) { + return node?.type === 'stair' || node?.type === 'stair-segment' +} + +function omitPreviewSurfaceFields(override: LiveNodeOverrides) { + const next = { ...override } + for (const field of SURFACE_OPENING_FIELDS) { + delete next[field] + } + return next +} + +export function hasLiveStairOpeningInputs( + nodes: Record, + liveTransforms: ReadonlyMap, + liveOverrides: ReadonlyMap, + previewSurfaceIds: ReadonlySet, +) { + for (const nodeId of liveTransforms.keys()) { + if (nodes[nodeId]?.type === 'stair') return true + } + + for (const [nodeId, override] of liveOverrides) { + if (previewSurfaceIds.has(nodeId)) continue + if (Object.keys(override).length > 0 && isStairOpeningInputNode(nodes[nodeId])) return true + } + + return false +} + +export function getNodesWithLiveStairOpeningInputs( + nodes: Record, + liveTransforms: ReadonlyMap, + liveOverrides: ReadonlyMap, + previewSurfaceIds: ReadonlySet, +) { + const nextNodes: Record = { ...nodes } + + for (const [nodeId, override] of liveOverrides) { + const node = nextNodes[nodeId] + if (!node) continue + + const values = previewSurfaceIds.has(nodeId) ? omitPreviewSurfaceFields(override) : override + if (Object.keys(values).length === 0) continue + nextNodes[nodeId] = { ...node, ...values } as AnyNode + } + + for (const [nodeId, transform] of liveTransforms) { + const node = nextNodes[nodeId] + if (node?.type !== 'stair') continue + nextNodes[nodeId] = { + ...node, + position: transform.position, + rotation: transform.rotation, + } + } + + return nextNodes +} + +export function createSurfaceOpeningPreviewController() { + const previewSurfaceIds = new Set() + + const clearSurface = (id: AnyNodeId) => { + useLiveNodeOverrides.getState().clearFields(id, SURFACE_OPENING_FIELDS) + useScene.getState().markDirty(id) + } + + return { + previewSurfaceIds, + apply(updates: SurfaceOpeningUpdate[]) { + const scene = useScene.getState() + const nextSurfaceIds = new Set() + + for (const update of updates) { + const node = scene.nodes[update.id] + if (!isSurface(node)) continue + if (!('holes' in update.data || 'holeMetadata' in update.data)) continue + + nextSurfaceIds.add(update.id) + useLiveNodeOverrides.getState().set(update.id, { + holes: update.data.holes ?? [], + holeMetadata: update.data.holeMetadata ?? [], + }) + scene.markDirty(update.id) + } + + for (const id of previewSurfaceIds) { + if (!nextSurfaceIds.has(id)) clearSurface(id) + } + + previewSurfaceIds.clear() + for (const id of nextSurfaceIds) { + previewSurfaceIds.add(id) + } + }, + clear() { + for (const id of previewSurfaceIds) { + clearSurface(id) + } + previewSurfaceIds.clear() + }, + } +} diff --git a/packages/core/src/systems/stair/stair-opening-sync.test.ts b/packages/core/src/systems/stair/stair-opening-sync.test.ts index 006a99a7..2ba5169c 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.test.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.test.ts @@ -11,7 +11,7 @@ import { import { syncAutoStairOpenings } from './stair-opening-sync' describe('syncAutoStairOpenings', () => { - test('only applies stair holes to destination slabs that contain the opening', () => { + test('only applies stair holes to destination slabs that overlap the opening', () => { const building = BuildingNode.parse({ name: 'Building' }) const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) @@ -74,6 +74,255 @@ describe('syncAutoStairOpenings', () => { expect(bedroomUpdate).toBeUndefined() }) + test('applies stair holes to a later destination slab when the configured offset overhangs the slab edge', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_edge', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_edge', + name: 'Edge Stair', + parentId: ground.id, + position: [2, 0, 0], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + openingOffset: 0.08, + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + const hole = landingUpdate?.data.holes?.[0] + + expect(hole).toBeDefined() + expect(Math.min(...hole!.map(([, z]) => z))).toBeCloseTo(-0.08) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + + test('does not apply stair holes to slabs on another building with a matching level number', () => { + const buildingA = BuildingNode.parse({ name: 'Building A' }) + const groundA = LevelNode.parse({ name: 'Ground A', level: 0, parentId: buildingA.id }) + const upperA = LevelNode.parse({ name: 'Upper A', level: 1, parentId: buildingA.id }) + const buildingB = BuildingNode.parse({ name: 'Building B' }) + const upperB = LevelNode.parse({ name: 'Upper B', level: 1, parentId: buildingB.id }) + const slabA = SlabNode.parse({ + name: 'Upper A Slab', + parentId: upperA.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const slabB = SlabNode.parse({ + name: 'Upper B Slab', + parentId: upperB.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_scoped', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_scoped', + name: 'Scoped Stair', + parentId: groundA.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: groundA.id, + toLevelId: upperA.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [ + buildingA, + groundA, + upperA, + buildingB, + upperB, + slabA, + slabB, + stair, + { ...segment, parentId: stair.id }, + ].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + + expect(updates.find((update) => update.id === slabA.id)?.data.holes).toHaveLength(1) + expect(updates.find((update) => update.id === slabB.id)).toBeUndefined() + }) + + test('uses the parent level when a stair has stale from-level data', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 8], + [0, 8], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_stale_from', + width: 1, + length: 6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_stale_from', + name: 'Stale From Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: 'default', + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + const hole = landingUpdate?.data.holes?.[0] + + expect(hole).toBeDefined() + expect(Math.min(...hole!.map(([, z]) => z))).toBeGreaterThan(0.9) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + + test('infers the destination level when a destination stair has blank level fields', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 8], + [0, 8], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_blank_levels', + width: 1, + length: 6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_blank_levels', + name: 'Blank Level Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: '', + toLevelId: '', + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toHaveLength(1) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + + test('infers the destination level when a destination stair targets its source level', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 8], + [0, 8], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_self_target', + width: 1, + length: 6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_self_target', + name: 'Self Target Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: ground.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map( + (node) => [node.id, node], + ), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + + expect(landingUpdate?.data.holes).toHaveLength(1) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) + test('does not add stair holes when a manual surface hole already covers them', () => { const building = BuildingNode.parse({ name: 'Building' }) const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index bced29db..def28ce6 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -1,4 +1,5 @@ -import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import { resolveBuildingForLevel, resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import { type Point2D, polygonContainsPolygon, polygonsOverlap } from '../../lib/polygon-relations' import type { AnyNode, AnyNodeId, @@ -11,8 +12,6 @@ import type { import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' import { computeSegmentTransforms, rotateXZ } from './stair-footprint' -type Point2D = [number, number] - type SegmentTransform = { position: [number, number, number] rotation: number @@ -85,10 +84,106 @@ function getLevelNumber(levelId: string | null, nodes: Record) return node?.type === 'level' ? node.level : undefined } +function getLevelBuildingId(levelId: string | null, nodes: Record) { + if (!levelId) return null + return resolveBuildingForLevel(levelId as AnyNodeId, nodes as Record) +} + +function normalizeLevelId(levelId: string | null | undefined, nodes: Record) { + if (!levelId) return null + return nodes[levelId as AnyNodeId]?.type === 'level' ? levelId : null +} + +function getBuildingLevels(buildingId: string | null, nodes: Record) { + const building = buildingId ? nodes[buildingId as AnyNodeId] : null + if (building?.type !== 'building') return [] + + const levels = new Map>() + for (const childId of building.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'level') levels.set(child.id, child) + } + for (const candidate of Object.values(nodes)) { + if (candidate?.type === 'level' && candidate.parentId === building.id) { + levels.set(candidate.id, candidate) + } + } + + return Array.from(levels.values()).sort((left, right) => left.level - right.level) +} + +function inferSourceLevelForDestination( + destinationLevelId: string | null, + nodes: Record, +) { + if (!destinationLevelId) return null + const destination = nodes[destinationLevelId as AnyNodeId] + if (destination?.type !== 'level') return null + + const buildingId = getLevelBuildingId(destinationLevelId, nodes) + return ( + getBuildingLevels(buildingId, nodes) + .filter((level) => level.level < destination.level) + .at(-1)?.id ?? null + ) +} + +function inferDestinationLevelForSource( + sourceLevelId: string | null, + nodes: Record, +) { + if (!sourceLevelId) return null + const source = nodes[sourceLevelId as AnyNodeId] + if (source?.type !== 'level') return null + + const buildingId = getLevelBuildingId(sourceLevelId, nodes) + return ( + getBuildingLevels(buildingId, nodes).find((level) => level.level > source.level)?.id ?? null + ) +} + +function levelsShareBuilding( + leftLevelId: string | null, + rightLevelId: string | null, + nodes: Record, +) { + if (!(leftLevelId && rightLevelId)) return true + const leftBuildingId = getLevelBuildingId(leftLevelId, nodes) + const rightBuildingId = getLevelBuildingId(rightLevelId, nodes) + return !(leftBuildingId && rightBuildingId && leftBuildingId !== rightBuildingId) +} + +function isInStairBuildingScope( + stair: StairNode, + surfaceLevelId: string, + nodes: Record, +) { + const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) + const fromBuildingId = getLevelBuildingId(fromLevelId, nodes) + const toBuildingId = getLevelBuildingId(toLevelId, nodes) + const surfaceBuildingId = getLevelBuildingId(surfaceLevelId, nodes) + + if (fromBuildingId && toBuildingId && fromBuildingId !== toBuildingId) return false + if (fromBuildingId && surfaceBuildingId && fromBuildingId !== surfaceBuildingId) return false + if (toBuildingId && surfaceBuildingId && toBuildingId !== surfaceBuildingId) return false + + return true +} + function getResolvedStairLevelIds(stair: StairNode, nodes: Record) { - const parentLevelId = resolveLevelId(stair, nodes) - const fromLevelId = stair.fromLevelId ?? parentLevelId - const toLevelId = stair.toLevelId ?? fromLevelId + const parentLevelId = normalizeLevelId(resolveLevelId(stair, nodes), nodes) + const explicitToLevelId = normalizeLevelId(stair.toLevelId, nodes) + const fromLevelId = + normalizeLevelId(stair.fromLevelId, nodes) ?? + parentLevelId ?? + inferSourceLevelForDestination(explicitToLevelId, nodes) + const explicitToLevelIsUsable = + explicitToLevelId && + explicitToLevelId !== fromLevelId && + levelsShareBuilding(fromLevelId, explicitToLevelId, nodes) + const toLevelId = explicitToLevelIsUsable + ? explicitToLevelId + : inferDestinationLevelForSource(fromLevelId, nodes) return { fromLevelId, toLevelId } } @@ -192,36 +287,6 @@ function polygonArea(points: Point2D[]) { return area / 2 } -function pointOnSegment(point: Point2D, a: Point2D, b: Point2D, tolerance = 1e-6) { - const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1]) - if (Math.abs(cross) > tolerance) return false - const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1]) - if (dot < -tolerance) return false - const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 - return dot <= lenSq + tolerance -} - -function pointInPolygon(point: Point2D, polygon: Point2D[]) { - if (polygon.length < 3) return false - let inside = false - const [x, z] = point - - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const a = polygon[i]! - const b = polygon[j]! - if (pointOnSegment(point, a, b)) return true - const intersects = - a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0] - if (intersects) inside = !inside - } - - return inside -} - -function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) { - return inner.every((point) => pointInPolygon(point, outer)) -} - function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) { return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole)) } @@ -409,13 +474,14 @@ function getStraightOpeningPolygonsForSurface( stair: StairNode, nodes: Record, targetElevation: number, + openingOffsetOverride?: number, ) { const layouts = getStraightStairLayouts(stair, nodes) if (layouts.length === 0) return [] const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1) const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN) - const openingOffset = Math.max(stair.openingOffset ?? 0, 0.15) + const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0) const openingRects: AxisAlignedRect[] = [] for (let index = 0; index < layouts.length; index += 1) { @@ -493,22 +559,22 @@ function getStairOpeningPolygons( stair: StairNode, nodes: Record, targetElevation?: number, + openingOffsetOverride?: number, ) { if ((stair.slabOpeningMode ?? 'none') !== 'destination') { return [] } + const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0) + if (stair.stairType === 'curved') { return [ - getCurvedOpeningPolygon( - stair, - Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15), - ), + getCurvedOpeningPolygon(stair, Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0)), ] } if (stair.stairType === 'spiral') { - const offset = Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15) + const offset = Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0) const polygons = [getSpiralOpeningPolygon(stair, offset)] if (stair.topLandingMode === 'integrated') { polygons.push(getSpiralLandingPolygon(stair, offset)) @@ -517,16 +583,41 @@ function getStairOpeningPolygons( } if (typeof targetElevation === 'number') { - return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation) + return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation, openingOffset) } return getStraightOpeningPolygonsForSurface( stair, nodes, Math.max(...getStraightStairLayouts(stair, nodes).map((layout) => layout.topElevation), 0), + openingOffset, ) } +function getApplicableStairOpeningPolygons( + stair: StairNode, + nodes: Record, + targetElevation: number, + surfacePolygon: Point2D[], +) { + const configuredOffset = Math.max(stair.openingOffset ?? 0, 0) + const polygons = getStairOpeningPolygons(stair, nodes, targetElevation, configuredOffset) + const overlappingPolygons = polygons.filter((polygon) => polygonsOverlap(surfacePolygon, polygon)) + + if (overlappingPolygons.length === polygons.length || configuredOffset <= 1e-6) { + return overlappingPolygons + } + + const fallbackPolygons = getStairOpeningPolygons(stair, nodes, targetElevation, 0) + const overlappingFallbackPolygons = fallbackPolygons.filter((polygon) => + polygonsOverlap(surfacePolygon, polygon), + ) + + return overlappingFallbackPolygons.length === fallbackPolygons.length + ? overlappingFallbackPolygons + : overlappingPolygons +} + function getTargetSlabElevationForStair( stair: StairNode, slab: SlabNode, @@ -579,6 +670,8 @@ function shouldApplyStairToSlab( const toLevel = getLevelNumber(toLevelId, nodes) const slabLevel = getLevelNumber(slabLevelId, nodes) + if (!isInStairBuildingScope(stair, slabLevelId, nodes)) return false + if (slabLevel === undefined) { return toLevelId === slabLevelId } @@ -602,6 +695,8 @@ function shouldApplyStairToCeiling( const toLevel = getLevelNumber(toLevelId, nodes) const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) + if (!isInStairBuildingScope(stair, ceilingLevelId, nodes)) return false + if (ceilingLevel === undefined) { return fromLevelId === ceilingLevelId } @@ -637,10 +732,11 @@ export function syncAutoStairOpenings(nodes: Record) { const stairHoles = stairs .filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes)) .flatMap((stair) => - getStairOpeningPolygons( + getApplicableStairOpeningPolygons( stair, nodes, getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes), + slab.polygon, ).map((polygon) => ({ polygon, metadata: { @@ -649,7 +745,6 @@ export function syncAutoStairOpenings(nodes: Record) { }, })), ) - .filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon)) .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) const nextHoles = [ @@ -686,10 +781,11 @@ export function syncAutoStairOpenings(nodes: Record) { const stairHoles = stairs .filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes)) .flatMap((stair) => - getStairOpeningPolygons( + getApplicableStairOpeningPolygons( stair, nodes, getTargetCeilingElevationForStair(stair, ceiling, ceilingLevelId, nodes), + ceiling.polygon, ).map((polygon) => ({ polygon, metadata: { @@ -698,7 +794,6 @@ export function syncAutoStairOpenings(nodes: Record) { }, })), ) - .filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon)) .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) const nextHoles = [ diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index 2558fcc5..b78224cf 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -2,7 +2,15 @@ import { useEffect, useRef } from 'react' import type { AnyNode } from '../../schema' +import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' +import useLiveNodeOverrides from '../../store/use-live-node-overrides' +import useLiveTransforms from '../../store/use-live-transforms' import useScene from '../../store/use-scene' +import { + createSurfaceOpeningPreviewController, + getNodesWithLiveStairOpeningInputs, + hasLiveStairOpeningInputs, +} from './stair-opening-preview' import { syncAutoStairOpenings } from './stair-opening-sync' function isOpeningRelevantNode(node: AnyNode | undefined) { @@ -35,24 +43,90 @@ function hasOpeningRelevantNodeChange( export const StairOpeningSystem = () => { const syncingAutoOpeningsRef = useRef(false) + const syncingPreviewOpeningsRef = useRef(false) + const previewControllerRef = useRef(createSurfaceOpeningPreviewController()) useEffect(() => { const applyUpdates = (updates: ReturnType) => { if (updates.length === 0) return syncingAutoOpeningsRef.current = true - useScene.getState().updateNodes(updates) + pauseSceneHistory(useScene) + try { + useScene.getState().updateNodes(updates) + } finally { + resumeSceneHistory(useScene) + } queueMicrotask(() => { syncingAutoOpeningsRef.current = false }) } - applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) + const applyPreviewUpdates = (updates: ReturnType) => { + syncingPreviewOpeningsRef.current = true + previewControllerRef.current.apply(updates) + queueMicrotask(() => { + syncingPreviewOpeningsRef.current = false + }) + } - return useScene.subscribe((state, prevState) => { + const clearPreviewUpdates = () => { + if (previewControllerRef.current.previewSurfaceIds.size === 0) return + syncingPreviewOpeningsRef.current = true + previewControllerRef.current.clear() + queueMicrotask(() => { + syncingPreviewOpeningsRef.current = false + }) + } + + const refreshLivePreview = () => { + if (syncingPreviewOpeningsRef.current) return + + const nodes = useScene.getState().nodes + const liveTransforms = useLiveTransforms.getState().transforms + const liveOverrides = useLiveNodeOverrides.getState().overrides + const previewSurfaceIds = previewControllerRef.current.previewSurfaceIds + + if (!hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, previewSurfaceIds)) { + clearPreviewUpdates() + return + } + + applyPreviewUpdates( + syncAutoStairOpenings( + getNodesWithLiveStairOpeningInputs( + nodes, + liveTransforms, + liveOverrides, + previewSurfaceIds, + ), + ), + ) + } + + applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) + refreshLivePreview() + + const unsubscribeScene = useScene.subscribe((state, prevState) => { if (syncingAutoOpeningsRef.current) return if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return applyUpdates(syncAutoStairOpenings(state.nodes)) + refreshLivePreview() }) + + const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => { + refreshLivePreview() + }) + + const unsubscribeLiveOverrides = useLiveNodeOverrides.subscribe(() => { + refreshLivePreview() + }) + + return () => { + unsubscribeScene() + unsubscribeLiveTransforms() + unsubscribeLiveOverrides() + previewControllerRef.current.clear() + } }, []) return null diff --git a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx index 6feb31e7..441eaa4f 100644 --- a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx @@ -1,7 +1,9 @@ 'use client' import { useAlignmentGuides } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { memo } from 'react' +import { formatMeasurement } from '../editor/measurement-pill' import { useFloorplanRender } from './floorplan-render-context' /** @@ -23,6 +25,7 @@ import { useFloorplanRender } from './floorplan-render-context' */ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() { const guides = useAlignmentGuides((s) => s.guides) + const unit = useViewer((s) => s.unit) const ctx = useFloorplanRender() if (guides.length === 0) return null @@ -56,7 +59,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid // offset along X. const pillX = axis === 'x' ? midX + pillOffset : midX const pillZ = axis === 'z' ? midZ + pillOffset : midZ - const distLabel = formatMeters(distMeters) + const distLabel = formatMeasurement(distMeters, unit) const charWidth = pillFontSize * 0.55 const pillWidth = distLabel.length * charWidth + pillPadX * 2 const pillHeight = pillFontSize + pillPadY * 2 @@ -135,10 +138,3 @@ function XCap({ ) } - -function formatMeters(meters: number): string { - // Sub-centimetre = "0". Otherwise show with up to 2 decimals, trimmed. - if (meters < 0.005) return '0' - const fixed = meters.toFixed(2) - return `${fixed.replace(/\.?0+$/, '')}m` -} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 716729ef..f27326f2 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -471,6 +471,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { payload, nodes: sceneNodes, initialPlanPoint, + gridSnapStep: useEditor.getState().gridSnapStep, }) const snapshots: NodeSnapshot[] = [] diff --git a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx index 7d48bc1b..75574dc5 100644 --- a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx +++ b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx @@ -8,6 +8,7 @@ import { memo, useMemo, useRef } from 'react' import { BoxGeometry, CircleGeometry, type Group } from 'three' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' +import { formatMeasurement } from './measurement-pill' /** * Figma-style alignment guides for the 3D editor — the spatial twin of @@ -53,6 +54,7 @@ type Vec3 = [number, number, number] export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() { const guides = useAlignmentGuides((s) => s.guides) const levelId = useViewer((s) => s.selection.levelId) + const unit = useViewer((s) => s.unit) const groupRef = useRef(null) // Guides carry only XZ (building-local plan coords); their Y has to track @@ -71,16 +73,16 @@ export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() { return ( {guides.map((guide, i) => ( - + ))} ) }) -function GuideLine({ guide }: { guide: AlignmentGuide }) { +function GuideLine({ guide, unit }: { guide: AlignmentGuide; unit: 'metric' | 'imperial' }) { const { x: fx, z: fz } = guide.from const { x: tx, z: tz } = guide.to - const distLabel = formatMeters(guide.distance) + const distLabel = formatMeasurement(guide.distance, unit) // Lay out the dash centres along the from→to direction. The ribbon // stretches the dash period up if the line is long enough to exceed the @@ -152,11 +154,3 @@ function Dot({ position }: { position: Vec3 }) { /> ) } - -function formatMeters(meters: number): string { - // Sub-centimetre = "0"; otherwise up to 2 decimals, trimmed. Matches the - // 2D floor-plan guide layer's pill formatting. - if (meters < 0.005) return '0' - const fixed = meters.toFixed(2) - return `${fixed.replace(/\.?0+$/, '')}m` -} diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts index 3be0748b..574e95ba 100644 --- a/packages/editor/src/components/editor/handles/use-handle-drag.ts +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -44,6 +44,7 @@ export type HandleDragMoveContext = { type HandleDragSession = { move: (context: HandleDragMoveContext) => Partial | null + markDirty?: boolean onBegin?: () => void onEnd?: () => void overrideId?: AnyNodeId @@ -122,6 +123,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { if (!session) return const overrideId = session.overrideId ?? nodeId + const markDirty = session.markDirty !== false document.body.style.cursor = cursor sfxEmitter.emit('sfx:item-pick') useViewer.getState().setInputDragging(true) @@ -137,7 +139,9 @@ export function useHandleDrag(args: UseHandleDragArgs) { if (!patch) return lastPatch = patch useLiveNodeOverrides.getState().set(overrideId, patch as Record) - useScene.getState().markDirty(overrideId) + if (markDirty) { + useScene.getState().markDirty(overrideId) + } } const cleanup = () => { @@ -157,7 +161,9 @@ export function useHandleDrag(args: UseHandleDragArgs) { const clearOverride = () => { useLiveNodeOverrides.getState().clear(overrideId) - useScene.getState().markDirty(overrideId) + if (markDirty) { + useScene.getState().markDirty(overrideId) + } } const onUp = () => { diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 869841dc..c8bef3f3 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -12,6 +12,7 @@ import { nodeRegistry, type RadialResizeHandle, sceneRegistry, + snapScalar, type TapActionHandle, type TranslateHandle, useLiveNodeOverrides, @@ -581,6 +582,10 @@ function LinearArrow({ descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi) const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi) + const gridSnapStep = + descriptor.kind === 'linear-resize' && descriptor.gridSnap + ? useEditor.getState().gridSnapStep + : null const factor = descriptor.kind === 'radial-resize' ? 1 @@ -615,7 +620,10 @@ function LinearArrow({ ? intersectionLocal.y : intersectionLocal.z const delta = currentPointer - initialPointer - const next = Math.min(maxBound, Math.max(minBound, initialValue + delta * factor)) + const rawNext = initialValue + delta * factor + const snappedNext = + gridSnapStep && gridSnapStep > 0 ? snapScalar(rawNext, gridSnapStep) : rawNext + const next = Math.min(maxBound, Math.max(minBound, snappedNext)) return descriptor.apply(initialNode as never, next, sceneApi) as Partial }, } @@ -1185,6 +1193,7 @@ function TranslateArrow({ .position ?? [0, 0, 0] return { + markDirty: false, move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { const hit = new Vector3() if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index f7811948..e41007e1 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -9,6 +9,7 @@ import { getEffectiveRoofSurfaceMaterial, getEffectiveSegmentSurfaceMaterial, getMaterialPresetByRef, + getRoofSegmentSurfaceY, getSelectableKinds, type ItemNode, isRegistrySelectable, @@ -40,7 +41,7 @@ import { useViewer, } from '@pascal-app/viewer' import { useCallback, useEffect, useRef } from 'react' -import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three' +import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three' import { type ActivePaintMaterial, buildRoofSegmentSurfaceMaterialPatch, @@ -56,7 +57,7 @@ import useEditor, { type Phase, type StructureLayer, } from './../../store/use-editor' -import { boxSelectHandled } from '../tools/select/box-select-tool' +import { boxSelectHandled } from '../tools/select/box-select-state' const isNodeInCurrentLevel = (node: AnyNode): boolean => { // Elevators are building-scoped, so they stay selectable across level filters. @@ -205,6 +206,59 @@ function getRegisteredMesh(nodeId: string): Mesh | null { return object && (object as Mesh).isMesh ? (object as Mesh) : null } +const roofSelectionWorldPoint = new Vector3() + +function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null { + const roof = event.node + if (roof.type !== 'roof') return null + + roofSelectionWorldPoint.set(...event.position) + const nodes = useScene.getState().nodes + let firstSegment: RoofSegmentNode | null = null + let bestSegment: { node: RoofSegmentNode; score: number } | null = null + + for (const childId of roof.children ?? []) { + const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined + if (segment?.type !== 'roof-segment') continue + + const object = getRegisteredNodeObject(segment.id) + if (!object) continue + + if (!firstSegment) firstSegment = segment + + object.updateWorldMatrix(true, false) + const local = object.worldToLocal(roofSelectionWorldPoint.clone()) + const overhang = segment.overhang ?? 0 + const halfWidth = segment.width / 2 + overhang + const halfDepth = segment.depth / 2 + overhang + + if (Math.abs(local.x) > halfWidth || Math.abs(local.z) > halfDepth) { + continue + } + + const score = Math.abs(local.y - getRoofSegmentSurfaceY(segment, local.x, local.z)) + if (!bestSegment || score < bestSegment.score) { + bestSegment = { node: segment, score } + } + } + + return bestSegment?.node ?? firstSegment +} + +function isInActiveRoofContext( + segment: RoofSegmentNode, + selectedIds: readonly string[], + nodes: Record, +): boolean { + if (!segment.parentId) return false + if (selectedIds.includes(segment.id) || selectedIds.includes(segment.parentId)) return true + + return selectedIds.some((selectedId) => { + const selectedNode = nodes[selectedId] + return selectedNode?.type === 'roof-segment' && selectedNode.parentId === segment.parentId + }) +} + function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup { const previousMaterial = mesh.material mesh.material = material @@ -1251,8 +1305,14 @@ export const SelectionManager = () => { let nodeToSelect = node if (node.type === 'roof-segment' && node.parentId) { - const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] - if (parentNode && parentNode.type === 'roof') { + const nodes = useScene.getState().nodes + const parentNode = nodes[node.parentId as AnyNodeId] + const selectedIds = useViewer.getState().selection.selectedIds + if ( + parentNode && + parentNode.type === 'roof' && + !isInActiveRoofContext(node, selectedIds, nodes) + ) { nodeToSelect = parentNode } } @@ -1439,7 +1499,11 @@ export const SelectionManager = () => { } const onDoubleClick = (event: NodeEvent) => { - const node = event.node + let node = event.node + if (node.type === 'roof') { + node = resolveRoofSegmentSelectionTarget(event) ?? node + } + const currentPhase = useEditor.getState().phase let targetPhase: 'site' | 'structure' | 'furnish' | null = null diff --git a/packages/editor/src/components/editor/slab-hole-highlights.tsx b/packages/editor/src/components/editor/slab-hole-highlights.tsx index 56468904..4fff543b 100644 --- a/packages/editor/src/components/editor/slab-hole-highlights.tsx +++ b/packages/editor/src/components/editor/slab-hole-highlights.tsx @@ -16,7 +16,6 @@ import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useState } from 'react' import { - BoxGeometry, BufferGeometry, DoubleSide, Float32BufferAttribute, @@ -31,7 +30,6 @@ import { swallowNextClick } from './handles/use-handle-drag' const ACCENT = 0x83_81_ed const SURFACE_OFFSET = 0.01 -const HIT_PADDING = 0.08 const MIN_HIT_HEIGHT = 0.16 const NO_RAYCAST = () => null @@ -104,24 +102,34 @@ function makeOutlineGeometry(hole: HolePolygon, y: number): BufferGeometry { } function makeHitGeometry(hole: HolePolygon, centerY: number, height: number): BufferGeometry { - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - let maxZ = Number.NEGATIVE_INFINITY + const topY = centerY + height / 2 + const bottomY = centerY - height / 2 + const positions: number[] = [] + const indices: number[] = [] - for (const [x, z] of hole) { - minX = Math.min(minX, x) - maxX = Math.max(maxX, x) - minZ = Math.min(minZ, z) - maxZ = Math.max(maxZ, z) + for (const [x, z] of hole) positions.push(x, topY, z) + for (const [x, z] of hole) positions.push(x, bottomY, z) + + const triangles = ShapeUtils.triangulateShape( + hole.map(([x, z]) => new Vector2(x, z)), + [], + ) + const bottomOffset = hole.length + for (const tri of triangles) { + indices.push(tri[0]!, tri[2]!, tri[1]!) + indices.push(bottomOffset + tri[0]!, bottomOffset + tri[1]!, bottomOffset + tri[2]!) } - const width = Math.max(maxX - minX + HIT_PADDING * 2, HIT_PADDING * 2) - const depth = Math.max(maxZ - minZ + HIT_PADDING * 2, HIT_PADDING * 2) - const centerX = (minX + maxX) / 2 - const centerZ = (minZ + maxZ) / 2 - const geometry = new BoxGeometry(width, height, depth) - geometry.translate(centerX, centerY, centerZ) + for (let index = 0; index < hole.length; index += 1) { + const nextIndex = (index + 1) % hole.length + indices.push(index, nextIndex, bottomOffset + nextIndex) + indices.push(index, bottomOffset + nextIndex, bottomOffset + index) + } + + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setIndex(indices) + geometry.computeVertexNormals() geometry.computeBoundingSphere() return geometry } diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 773aa396..982854ee 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -9,6 +9,7 @@ import { RoofSegmentNode, resolveAlignment, sceneRegistry, + snapScalar, useAlignmentGuides, useScene, } from '@pascal-app/core' @@ -28,6 +29,10 @@ const GRID_OFFSET = 0.02 /** Figma-style alignment-snap threshold (meters), matching the move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 +function snapToActiveGrid(value: number): number { + return snapScalar(value, useEditor.getState().gridSnapStep) +} + /** * Creates a roof group with one default gable segment */ @@ -233,8 +238,8 @@ export const RoofTool: React.FC = () => { if (!cursorRef.current) return const [gridX, gridZ] = alignPoint( - Math.round(event.localPosition[0] * 2) / 2, - Math.round(event.localPosition[2] * 2) / 2, + snapToActiveGrid(event.localPosition[0]), + snapToActiveGrid(event.localPosition[2]), event.localPosition[0], event.localPosition[2], event.nativeEvent?.altKey === true, @@ -271,8 +276,8 @@ export const RoofTool: React.FC = () => { if (!currentLevelId) return const [gridX, gridZ] = alignPoint( - Math.round(event.localPosition[0] * 2) / 2, - Math.round(event.localPosition[2] * 2) / 2, + snapToActiveGrid(event.localPosition[0]), + snapToActiveGrid(event.localPosition[2]), event.localPosition[0], event.localPosition[2], event.nativeEvent?.altKey === true, diff --git a/packages/editor/src/components/tools/select/box-select-state.ts b/packages/editor/src/components/tools/select/box-select-state.ts new file mode 100644 index 00000000..cacdb50f --- /dev/null +++ b/packages/editor/src/components/tools/select/box-select-state.ts @@ -0,0 +1,22 @@ +export let boxSelectHandled = false + +let resetTimeout: ReturnType | null = null + +export function markBoxSelectHandled() { + boxSelectHandled = true + if (resetTimeout) { + clearTimeout(resetTimeout) + } + resetTimeout = setTimeout(() => { + boxSelectHandled = false + resetTimeout = null + }, 50) +} + +export function clearBoxSelectHandled() { + if (resetTimeout) { + clearTimeout(resetTimeout) + resetTimeout = null + } + boxSelectHandled = false +} diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 43a647b5..62f29992 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -1,319 +1,33 @@ -import '../../../three-types' - -import { Icon } from '@iconify/react' -import { - type AnyNodeId, - type CeilingNode, - type ColumnNode, - emitter, - type GridEvent, - type ItemNode, - isRegistrySelectable, - type LevelNode, - resolveBuildingForLevel, - type SlabNode, - sceneRegistry, - useScene, - type WallNode, - type ZoneNode, -} from '@pascal-app/core' +import { sceneRegistry, type ZoneNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import type { ThreeElements } from '@react-three/fiber' import { useThree } from '@react-three/fiber' -import { useEffect, useRef } from 'react' -import { - Box3, - BufferAttribute, - BufferGeometry, - DoubleSide, - type Group, - LineBasicMaterial, - LineSegments, - type Mesh, - Plane, - Raycaster, - Vector2, - Vector3, -} from 'three' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' +import { useCallback, useEffect, useRef } from 'react' +import { Box3, type Camera, type Object3D, Vector3 } from 'three' import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' +import { clearBoxSelectHandled, markBoxSelectHandled } from './box-select-state' +import { PlaneBoxSelectTool } from './plane-box-select-tool' +import { collectSelectableCandidateIds } from './select-candidates' -declare module 'react/jsx-runtime' { - namespace JSX { - interface IntrinsicElements extends ThreeElements {} - } -} +type ScreenRect = { minX: number; minY: number; maxX: number; maxY: number } -/** - * Module-level flag to prevent the SelectionManager from deselecting - * on the grid:click that fires right after a box-select drag completes. - */ -export let boxSelectHandled = false +const BOX_SELECT_FILL_COLOR = 'rgba(129, 140, 248, 0.14)' +const BOX_SELECT_BORDER_COLOR = 'rgba(129, 140, 248, 0.9)' +const BOX_SELECT_SHADOW_COLOR = 'rgba(129, 140, 248, 0.28)' +const DRAG_THRESHOLD_PX = 4 -// ── Geometry helpers ──────────────────────────────────────────────────────── - -type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number } - -function pointInBounds(x: number, z: number, b: Bounds): boolean { - return x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ -} - -function segmentsIntersect( - ax1: number, - az1: number, - ax2: number, - az2: number, - bx1: number, - bz1: number, - bx2: number, - bz2: number, -): boolean { - const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1) - const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2) - const d3 = cross(ax1, az1, ax2, az2, bx1, bz1) - const d4 = cross(ax1, az1, ax2, az2, bx2, bz2) - - if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { - return true - } - - if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true - if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true - if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true - if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true - - return false -} - -function cross(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): number { - return (bx - ax) * (cz - az) - (bz - az) * (cx - ax) -} - -function onSeg(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): boolean { - return ( - Math.min(ax, bx) <= cx && - cx <= Math.max(ax, bx) && - Math.min(az, bz) <= cz && - cz <= Math.max(az, bz) - ) -} - -function segmentIntersectsBounds( - x1: number, - z1: number, - x2: number, - z2: number, - b: Bounds, -): boolean { - if (pointInBounds(x1, z1, b) || pointInBounds(x2, z2, b)) return true - - const edges: [number, number, number, number][] = [ - [b.minX, b.minZ, b.maxX, b.minZ], - [b.maxX, b.minZ, b.maxX, b.maxZ], - [b.maxX, b.maxZ, b.minX, b.maxZ], - [b.minX, b.maxZ, b.minX, b.minZ], - ] - for (const [ex1, ez1, ex2, ez2] of edges) { - if (segmentsIntersect(x1, z1, x2, z2, ex1, ez1, ex2, ez2)) return true - } - return false -} - -function polygonIntersectsBounds(polygon: [number, number][], b: Bounds): boolean { - if (polygon.some(([x, z]) => pointInBounds(x, z, b))) return true - - const corners: [number, number][] = [ - [b.minX, b.minZ], - [b.maxX, b.minZ], - [b.maxX, b.maxZ], - [b.minX, b.maxZ], - ] - if (corners.some(([cx, cz]) => pointInPolygon(cx, cz, polygon))) return true - - const edges: [number, number, number, number][] = [ - [b.minX, b.minZ, b.maxX, b.minZ], - [b.maxX, b.minZ, b.maxX, b.maxZ], - [b.maxX, b.maxZ, b.minX, b.maxZ], - [b.minX, b.maxZ, b.minX, b.minZ], - ] - for (let i = 0; i < polygon.length; i++) { - const [px1, pz1] = polygon[i]! - const [px2, pz2] = polygon[(i + 1) % polygon.length]! - for (const [ex1, ez1, ex2, ez2] of edges) { - if (segmentsIntersect(px1, pz1, px2, pz2, ex1, ez1, ex2, ez2)) return true - } - } - - return false -} - -function pointInPolygon(x: number, z: number, polygon: [number, number][]): boolean { - let inside = false - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const [xi, zi] = polygon[i]! - const [xj, zj] = polygon[j]! - if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) { - inside = !inside - } - } - return inside -} - -// ── Node-in-bounds checks ─────────────────────────────────────────────────── - -const _tempVec = new Vector3() -const _tempBox = new Box3() - -function getNodeWorldXZ(nodeId: string): [number, number] | null { - const obj = sceneRegistry.nodes.get(nodeId) - if (!obj) return null - obj.getWorldPosition(_tempVec) - return [_tempVec.x, _tempVec.z] -} - -function objectBoundsIntersectsBounds(nodeId: string, bounds: Bounds): boolean { - const obj = sceneRegistry.nodes.get(nodeId) - if (!obj) return false - - obj.updateWorldMatrix(true, true) - _tempBox.setFromObject(obj) - - if (_tempBox.isEmpty()) { - const xz = getNodeWorldXZ(nodeId) - return Boolean(xz && pointInBounds(xz[0], xz[1], bounds)) - } - - return !( - _tempBox.max.x < bounds.minX || - _tempBox.min.x > bounds.maxX || - _tempBox.max.z < bounds.minZ || - _tempBox.min.z > bounds.maxZ - ) -} - -/** - * Collect the ids of selectable nodes on the active level. When `bounds` is a - * rectangle, only nodes intersecting it are returned (a box-select hit-test). - * When `bounds` is `null`, every selectable node on the level is returned — - * the full set the box-select could ever capture — which lets the caller tell - * whether a drag selected the entire level. - */ -function collectNodeIdsInBounds(bounds: Bounds | null): string[] { - const { levelId } = useViewer.getState().selection - const { nodes } = useScene.getState() - const { phase, structureLayer } = useEditor.getState() - - if (!levelId) return [] - const levelNode = nodes[levelId] as LevelNode | undefined - if (!levelNode || levelNode.type !== 'level') return [] - - const result: string[] = [] - - if (phase === 'structure' && structureLayer === 'zones') { - for (const childId of levelNode.children) { - const node = nodes[childId as AnyNodeId] - if (!node || node.type !== 'zone') continue - const zone = node as ZoneNode - if (!bounds || polygonIntersectsBounds(zone.polygon, bounds)) { - result.push(zone.id) - } - } - } else { - // structure (elements) and furnish: collect all node types - for (const childId of levelNode.children) { - const node = nodes[childId as AnyNodeId] - if (!node) continue - - if (node.type === 'wall' || node.type === 'fence') { - const wall = node as WallNode - if ( - !bounds || - segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds) - ) { - result.push(wall.id) - } - // Check wall children (doors/windows) - for (const itemId of Array.isArray(wall.children) ? wall.children : []) { - const child = nodes[itemId as AnyNodeId] - if (!child) continue - if ( - child.type === 'window' || - child.type === 'door' || - (child.type === 'item' && - ((child as ItemNode).asset.category === 'door' || - (child as ItemNode).asset.category === 'window')) - ) { - const xz = getNodeWorldXZ(child.id) - if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) { - result.push(child.id) - } - } - } - } else if (node.type === 'slab') { - const slab = node as SlabNode - if (!bounds || polygonIntersectsBounds(slab.polygon, bounds)) { - result.push(slab.id) - } - } else if (node.type === 'ceiling') { - const ceiling = node as CeilingNode - if (!bounds || polygonIntersectsBounds(ceiling.polygon, bounds)) { - result.push(ceiling.id) - } - } else if (node.type === 'roof') { - const xz = getNodeWorldXZ(node.id) - if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) { - result.push(node.id) - } - } else if (node.type === 'stair') { - if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) { - result.push(node.id) - } - } else if (node.type === 'column') { - const column = node as ColumnNode - if (!bounds || objectBoundsIntersectsBounds(column.id, bounds)) { - result.push(column.id) - } - } else if (node.type === 'item') { - const item = node as ItemNode - if (item.asset.category === 'door' || item.asset.category === 'window') continue - const xz = getNodeWorldXZ(item.id) - if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) { - result.push(item.id) - } - } else if (isRegistrySelectable(node.type)) { - // Registry-driven selectable kinds (shelf + future furnish/structure - // kinds) aren't in the hardcoded list above; pick them up by their - // rendered bounding box, the same path column/stair use. - if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) { - result.push(node.id) - } - } - } - - // Building-scoped selectable nodes (e.g. elevator) are siblings of the - // level — children of the building, not the level — so the loop above - // never reaches them. Walk the active level's building children and - // box-test any registry-selectable kind by its rendered bounds, the same - // path column/stair/shelf use. - const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes) - const buildingNode = buildingId ? nodes[buildingId] : undefined - const buildingChildren = - buildingNode && 'children' in buildingNode && Array.isArray(buildingNode.children) - ? (buildingNode.children as AnyNodeId[]) - : [] - for (const childId of buildingChildren) { - const node = nodes[childId] - if (!node || node.type === 'level' || !isRegistrySelectable(node.type)) continue - if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) { - result.push(node.id) - } - } - } - - return result -} +const tempBox = new Box3() +const tempWorldPoint = new Vector3() +const tempScreenPoint = new Vector3() +const boxCorners = [ + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), +] function haveSameIds(currentIds: string[], nextIds: string[]): boolean { return ( @@ -322,379 +36,429 @@ function haveSameIds(currentIds: string[], nextIds: string[]): boolean { ) } -// ── Visual helpers ────────────────────────────────────────────────────────── +function createSelectionElement(): HTMLDivElement { + const element = document.createElement('div') + element.style.position = 'fixed' + element.style.display = 'none' + element.style.pointerEvents = 'none' + element.style.zIndex = '2147483647' + element.style.border = `1px solid ${BOX_SELECT_BORDER_COLOR}` + element.style.background = BOX_SELECT_FILL_COLOR + element.style.boxShadow = `0 0 0 1px ${BOX_SELECT_SHADOW_COLOR} inset` + element.style.contain = 'layout paint style' + return element +} -function updateRectVisuals( - fillMesh: Mesh, - outline: LineSegments, - start: Vector3, - end: Vector3, - y: number, -) { - const cx = (start.x + end.x) / 2 - const cz = (start.z + end.z) / 2 - const w = Math.abs(end.x - start.x) - const h = Math.abs(end.z - start.z) +function normalizeScreenRect( + startX: number, + startY: number, + endX: number, + endY: number, +): ScreenRect { + return { + minX: Math.min(startX, endX), + minY: Math.min(startY, endY), + maxX: Math.max(startX, endX), + maxY: Math.max(startY, endY), + } +} - if (w < 0.01 && h < 0.01) { - fillMesh.visible = false - outline.visible = false +function updateSelectionElement(element: HTMLDivElement, rect: ScreenRect) { + element.style.display = 'block' + element.style.left = `${rect.minX}px` + element.style.top = `${rect.minY}px` + element.style.width = `${Math.max(0, rect.maxX - rect.minX)}px` + element.style.height = `${Math.max(0, rect.maxY - rect.minY)}px` +} + +function hideSelectionElement(element: HTMLDivElement | null) { + if (!element) return + element.style.display = 'none' + element.style.width = '0px' + element.style.height = '0px' +} + +function screenRectsIntersect(a: ScreenRect, b: ScreenRect): boolean { + return !(b.maxX < a.minX || b.minX > a.maxX || b.maxY < a.minY || b.minY > a.maxY) +} + +function screenRectFromDomRect(rect: DOMRect): ScreenRect { + return { + minX: rect.left, + minY: rect.top, + maxX: rect.right, + maxY: rect.bottom, + } +} + +function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | null { + const rect = { + minX: Math.max(a.minX, b.minX), + minY: Math.max(a.minY, b.minY), + maxX: Math.min(a.maxX, b.maxX), + maxY: Math.min(a.maxY, b.maxY), + } + + if (rect.maxX <= rect.minX || rect.maxY <= rect.minY) { + return null + } + + return rect +} + +function projectWorldPointToScreen( + point: Vector3, + camera: Camera, + canvasRect: DOMRect, +): [number, number] | null { + tempScreenPoint.copy(point).project(camera) + if (tempScreenPoint.z < -1 || tempScreenPoint.z > 1) return null + + return [ + canvasRect.left + (tempScreenPoint.x * 0.5 + 0.5) * canvasRect.width, + canvasRect.top + (-tempScreenPoint.y * 0.5 + 0.5) * canvasRect.height, + ] +} + +function getObjectScreenRect( + object: Object3D, + camera: Camera, + canvasRect: DOMRect, +): ScreenRect | null { + object.updateWorldMatrix(true, true) + tempBox.setFromObject(object) + + if (tempBox.isEmpty()) { + object.getWorldPosition(tempWorldPoint) + const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect) + if (!projected) return null + const [x, y] = projected + return { minX: x, minY: y, maxX: x, maxY: y } + } + + boxCorners[0]!.set(tempBox.min.x, tempBox.min.y, tempBox.min.z) + boxCorners[1]!.set(tempBox.min.x, tempBox.min.y, tempBox.max.z) + boxCorners[2]!.set(tempBox.min.x, tempBox.max.y, tempBox.min.z) + boxCorners[3]!.set(tempBox.min.x, tempBox.max.y, tempBox.max.z) + boxCorners[4]!.set(tempBox.max.x, tempBox.min.y, tempBox.min.z) + boxCorners[5]!.set(tempBox.max.x, tempBox.min.y, tempBox.max.z) + boxCorners[6]!.set(tempBox.max.x, tempBox.max.y, tempBox.min.z) + boxCorners[7]!.set(tempBox.max.x, tempBox.max.y, tempBox.max.z) + + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const corner of boxCorners) { + const projected = projectWorldPointToScreen(corner, camera, canvasRect) + if (!projected) continue + const [x, y] = projected + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x) + maxY = Math.max(maxY, y) + } + + if (minX !== Number.POSITIVE_INFINITY) { + return { minX, minY, maxX, maxY } + } + + object.getWorldPosition(tempWorldPoint) + const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect) + if (!projected) return null + const [x, y] = projected + return { minX: x, minY: y, maxX: x, maxY: y } +} + +function isObjectVisible(object: Object3D): boolean { + let current: Object3D | null = object + while (current) { + if (!current.visible) return false + current = current.parent + } + return true +} + +function collectNodeIdsInScreenRect( + rect: ScreenRect, + camera: Camera, + canvas: HTMLCanvasElement, +): string[] { + const canvasRect = canvas.getBoundingClientRect() + const result: string[] = [] + + for (const id of collectSelectableCandidateIds()) { + const object = sceneRegistry.nodes.get(id) + if (!object || !isObjectVisible(object)) continue + const objectRect = getObjectScreenRect(object, camera, canvasRect) + if (objectRect && screenRectsIntersect(rect, objectRect)) { + result.push(id) + } + } + + return result +} + +function commitBoxSelection(ids: string[], event: PointerEvent) { + const shouldAppend = event.metaKey || event.ctrlKey + const { phase, structureLayer } = useEditor.getState() + const viewer = useViewer.getState() + + if (phase === 'structure' && structureLayer === 'zones') { + if (ids.length > 0) { + viewer.setSelection({ zoneId: ids[0] as ZoneNode['id'] }) + } else if (!shouldAppend) { + viewer.setSelection({ zoneId: null }) + } return } - // Fill rect (unit plane scaled) - fillMesh.visible = true - fillMesh.position.set(cx, y + 0.02, cz) - fillMesh.scale.set(w, h, 1) + if (shouldAppend) { + viewer.setSelection({ + selectedIds: Array.from(new Set([...viewer.selection.selectedIds, ...ids])), + }) + return + } - // Outline — 4 edges as line segment pairs (8 vertices) - outline.visible = true - const oy = y + 0.03 - const x0 = cx - w / 2 - const x1 = cx + w / 2 - const z0 = cz - h / 2 - const z1 = cz + h / 2 - const pos = outline.geometry.attributes.position as BufferAttribute - // bottom: (x0,z0)→(x1,z0) - pos.setXYZ(0, x0, oy, z0) - pos.setXYZ(1, x1, oy, z0) - // right: (x1,z0)→(x1,z1) - pos.setXYZ(2, x1, oy, z0) - pos.setXYZ(3, x1, oy, z1) - // top: (x1,z1)→(x0,z1) - pos.setXYZ(4, x1, oy, z1) - pos.setXYZ(5, x0, oy, z1) - // left: (x0,z1)→(x0,z0) - pos.setXYZ(6, x0, oy, z1) - pos.setXYZ(7, x0, oy, z0) - pos.needsUpdate = true + viewer.setSelection({ selectedIds: ids }) } -// ── Outline geometry (allocated once, reused) ─────────────────────────────── - -function createOutlineSegments(): LineSegments { - const geo = new BufferGeometry() - // 4 edges × 2 vertices each = 8 vertices - const positions = new Float32Array(8 * 3) - geo.setAttribute('position', new BufferAttribute(positions, 3)) - - const mat = new LineBasicMaterial({ - color: BOX_SELECT_ACCENT_COLOR, - depthTest: false, - depthWrite: false, - transparent: true, - opacity: 0.85, - }) - - const segments = new LineSegments(geo, mat) - segments.layers.set(EDITOR_LAYER) - segments.renderOrder = 2 - segments.visible = false - segments.frustumCulled = false - - return segments -} - -// ── Drag threshold (pixels) ───────────────────────────────────────────────── - -const BOX_SELECT_ACCENT_COLOR = '#818cf8' -const DRAG_THRESHOLD_PX = 4 - -function getSnappedGridPosition(x: number, z: number): [number, number] { - return [Math.round(x * 2) / 2, Math.round(z * 2) / 2] -} - -function setSnappedPoint(target: Vector3, x: number, y: number, z: number) { - const [snappedX, snappedZ] = getSnappedGridPosition(x, z) - target.set(snappedX, y, snappedZ) -} - -// ── Component ─────────────────────────────────────────────────────────────── - export const BoxSelectTool: React.FC = () => { + const phase = useEditor((s) => s.phase) const mode = useEditor((s) => s.mode) const selectionTool = useEditor((s) => s.floorplanSelectionTool) - const isActive = mode === 'select' && selectionTool === 'marquee' + const isActive = mode === 'select' && (phase === 'structure' || phase === 'furnish') if (!isActive) return null - return + if (selectionTool === 'marquee') { + return + } + + return } -const BOX_SELECT_TOOLTIP = ( - -) - -const BoxSelectToolInner: React.FC = () => { +const ScreenRectangleSelectTool: React.FC = () => { const { camera, gl } = useThree() const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) - const cursorRef = useRef(null) - const rectFillRef = useRef(null!) - const outlineRef = useRef(createOutlineSegments()) - const startPoint = useRef(new Vector3()) - const currentPoint = useRef(new Vector3()) - const pointerDown = useRef(false) - const isDragging = useRef(false) - const startClientX = useRef(0) - const startClientY = useRef(0) - const gridY = useRef(0) - const previousGridPosition = useRef<[number, number] | null>(null) + const elementRef = useRef(null) const previewSelectedIdsRef = useRef([]) + const pointerDownRef = useRef(false) + const isDraggingRef = useRef(false) + const ownsInputDraggingRef = useRef(false) + const pointerIdRef = useRef(null) + const startClientXRef = useRef(0) + const startClientYRef = useRef(0) + const currentClientXRef = useRef(0) + const currentClientYRef = useRef(0) + const spaceDownRef = useRef(false) - // Raycasting helpers (same technique as useGridEvents) - const raycasterRef = useRef(new Raycaster()) - const pointerNDC = useRef(new Vector2()) - const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0)) - const hitPoint = useRef(new Vector3()) + const syncPreviewSelectedIds = useCallback( + (nextIds: string[]) => { + if (haveSameIds(previewSelectedIdsRef.current, nextIds)) return + + previewSelectedIdsRef.current = nextIds + setPreviewSelectedIds(nextIds) + }, + [setPreviewSelectedIds], + ) + + const resetDrag = useCallback(() => { + pointerDownRef.current = false + isDraggingRef.current = false + pointerIdRef.current = null + hideSelectionElement(elementRef.current) + syncPreviewSelectedIds([]) + + if (ownsInputDraggingRef.current) { + useViewer.getState().setInputDragging(false) + ownsInputDraggingRef.current = false + } + }, [syncPreviewSelectedIds]) - // Cleanup outline geometry on unmount useEffect(() => { - const outline = outlineRef.current + const element = createSelectionElement() + document.body.appendChild(element) + elementRef.current = element + return () => { - previewSelectedIdsRef.current = [] - setPreviewSelectedIds([]) - outline.geometry.dispose() - ;(outline.material as LineBasicMaterial).dispose() + element.remove() + elementRef.current = null } - }, [setPreviewSelectedIds]) - - const syncPreviewSelectedIds = (nextIds: string[]) => { - if (haveSameIds(previewSelectedIdsRef.current, nextIds)) { - return - } - - previewSelectedIdsRef.current = nextIds - setPreviewSelectedIds(nextIds) - } - - // Sync ground plane Y with the current level - useEffect(() => { - const unsubscribe = useViewer.subscribe((state) => { - const levelId = state.selection.levelId - if (!levelId) return - const obj = sceneRegistry.nodes.get(levelId) - if (obj) groundPlane.current.constant = -obj.position.y - }) - // Set initial value - const levelId = useViewer.getState().selection.levelId - if (levelId) { - const obj = sceneRegistry.nodes.get(levelId) - if (obj) groundPlane.current.constant = -obj.position.y - } - return unsubscribe }, []) - const raycastToGround = (e: PointerEvent): Vector3 | null => { - const rect = gl.domElement.getBoundingClientRect() - pointerNDC.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1 - pointerNDC.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1 - raycasterRef.current.setFromCamera(pointerNDC.current, camera) - if (raycasterRef.current.ray.intersectPlane(groundPlane.current, hitPoint.current)) { - return hitPoint.current + useEffect(() => { + const cancelForSpace = () => { + if (!pointerDownRef.current) return + markBoxSelectHandled() + resetDrag() } - return null - } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code !== 'Space') return + spaceDownRef.current = true + cancelForSpace() + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.code !== 'Space') return + spaceDownRef.current = false + } + + const onBlur = () => { + spaceDownRef.current = false + resetDrag() + } + + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) + + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) + } + }, [resetDrag]) useEffect(() => { const canvas = gl.domElement - const onCanvasPointerDown = (e: PointerEvent) => { - if (e.button !== 0) return - if (useViewer.getState().cameraDragging) return - // A gizmo/handle drag is underway (group rotate/move, resize arrows, …). - // Those use raw window listeners too, so without this guard box-select - // would run in parallel and clobber the selection on release. - if (useViewer.getState().inputDragging) return + const updateDrag = (event: PointerEvent) => { + if (!pointerDownRef.current) return + if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return - const point = raycastToGround(e) - if (!point) return + const viewer = useViewer.getState() + if ( + spaceDownRef.current || + viewer.cameraDragging || + (viewer.inputDragging && !ownsInputDraggingRef.current) + ) { + markBoxSelectHandled() + resetDrag() + return + } - setSnappedPoint(startPoint.current, point.x, point.y, point.z) - setSnappedPoint(currentPoint.current, point.x, point.y, point.z) - gridY.current = point.y - pointerDown.current = true - isDragging.current = false - previousGridPosition.current = getSnappedGridPosition(point.x, point.z) - startClientX.current = e.clientX - startClientY.current = e.clientY - syncPreviewSelectedIds([]) - } + currentClientXRef.current = event.clientX + currentClientYRef.current = event.clientY - const onCanvasPointerUp = (e: PointerEvent) => { - if (e.button !== 0) return - // If a gizmo/handle drag is in progress, don't let box-select replace the - // selection. Canvas listeners fire before the gizmo's window pointer-up - // (which clears `inputDragging`), so this still reads true here. Reset our - // own state and bail. - if (useViewer.getState().inputDragging) { - pointerDown.current = false - isDragging.current = false - if (rectFillRef.current) rectFillRef.current.visible = false - if (outlineRef.current) outlineRef.current.visible = false + const dragDistance = Math.hypot( + currentClientXRef.current - startClientXRef.current, + currentClientYRef.current - startClientYRef.current, + ) + + if (!isDraggingRef.current && dragDistance >= DRAG_THRESHOLD_PX) { + isDraggingRef.current = true + ownsInputDraggingRef.current = true + useViewer.getState().setInputDragging(true) + markBoxSelectHandled() + try { + canvas.setPointerCapture(event.pointerId) + } catch {} + } + + if (!isDraggingRef.current) return + + event.preventDefault() + const rect = normalizeScreenRect( + startClientXRef.current, + startClientYRef.current, + currentClientXRef.current, + currentClientYRef.current, + ) + const clampedRect = intersectScreenRects( + rect, + screenRectFromDomRect(canvas.getBoundingClientRect()), + ) + if (!clampedRect) { + hideSelectionElement(elementRef.current) syncPreviewSelectedIds([]) return } - if (!pointerDown.current) return - if (isDragging.current) { - const point = raycastToGround(e) - if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z) + updateSelectionElement(elementRef.current!, clampedRect) + syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas)) + } - const bounds: Bounds = { - minX: Math.min(startPoint.current.x, currentPoint.current.x), - maxX: Math.max(startPoint.current.x, currentPoint.current.x), - minZ: Math.min(startPoint.current.z, currentPoint.current.z), - maxZ: Math.max(startPoint.current.z, currentPoint.current.z), - } + const finishDrag = (event: PointerEvent) => { + if (!pointerDownRef.current) return + if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return - const ids = collectNodeIdsInBounds(bounds) - - const shouldAppend = e.metaKey || e.ctrlKey - const { phase, structureLayer } = useEditor.getState() - - if (phase === 'structure' && structureLayer === 'zones') { - if (ids.length > 0) { - useViewer.getState().setSelection({ zoneId: ids[0] as ZoneNode['id'] }) - } else if (!shouldAppend) { - useViewer.getState().setSelection({ zoneId: null }) - } - } else if (shouldAppend) { - const currentIds = useViewer.getState().selection.selectedIds - const merged = Array.from(new Set([...currentIds, ...ids])) - useViewer.getState().setSelection({ selectedIds: merged }) - } else { - // If the box captured every selectable node on the level, promote to - // selecting the parent building — same as clicking it in the side menu. - const allOnLevel = collectNodeIdsInBounds(null) - const { buildingId } = useViewer.getState().selection - const selectedEntireLevel = allOnLevel.length > 0 && ids.length === allOnLevel.length - - if (selectedEntireLevel && buildingId) { - useViewer.getState().setSelection({ buildingId }) - } else { - useViewer.getState().setSelection({ selectedIds: ids }) - } - } - - // Prevent the subsequent grid:click from deselecting - boxSelectHandled = true - setTimeout(() => { - boxSelectHandled = false - }, 50) + if (useViewer.getState().inputDragging && !ownsInputDraggingRef.current) { + markBoxSelectHandled() + resetDrag() + return } - // NOTE: Short clicks (no drag) fall through to the SelectionManager's - // existing grid:click / node:click handlers — no extra logic needed here. - // Hide visuals - if (rectFillRef.current) rectFillRef.current.visible = false - if (outlineRef.current) outlineRef.current.visible = false + if (isDraggingRef.current) { + event.preventDefault() + event.stopPropagation() + markBoxSelectHandled() + + const rect = normalizeScreenRect( + startClientXRef.current, + startClientYRef.current, + event.clientX, + event.clientY, + ) + const clampedRect = intersectScreenRects( + rect, + screenRectFromDomRect(canvas.getBoundingClientRect()), + ) + const ids = clampedRect ? collectNodeIdsInScreenRect(clampedRect, camera, canvas) : [] + commitBoxSelection(ids, event) + } + + try { + canvas.releasePointerCapture(event.pointerId) + } catch {} + + resetDrag() + } + + const onCanvasPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + if (spaceDownRef.current) return + + const viewer = useViewer.getState() + if (viewer.cameraDragging || viewer.inputDragging) return + + pointerDownRef.current = true + isDraggingRef.current = false + pointerIdRef.current = event.pointerId + startClientXRef.current = event.clientX + startClientYRef.current = event.clientY + currentClientXRef.current = event.clientX + currentClientYRef.current = event.clientY syncPreviewSelectedIds([]) + } - // Reset - pointerDown.current = false - isDragging.current = false + const onPointerCancel = (event: PointerEvent) => { + if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return + resetDrag() } canvas.addEventListener('pointerdown', onCanvasPointerDown) - canvas.addEventListener('pointerup', onCanvasPointerUp) + window.addEventListener('pointermove', updateDrag, { passive: false }) + window.addEventListener('pointerup', finishDrag) + window.addEventListener('pointercancel', onPointerCancel) return () => { canvas.removeEventListener('pointerdown', onCanvasPointerDown) - canvas.removeEventListener('pointerup', onCanvasPointerUp) + window.removeEventListener('pointermove', updateDrag) + window.removeEventListener('pointerup', finishDrag) + window.removeEventListener('pointercancel', onPointerCancel) + resetDrag() } - }, [gl, raycastToGround, syncPreviewSelectedIds]) + }, [camera, gl, resetDrag, syncPreviewSelectedIds]) - // grid:move for cursor tracking + rectangle update during drag useEffect(() => { - const onMove = (event: GridEvent) => { - const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2]) - - // Always update cursor position - if (cursorRef.current) { - cursorRef.current.position.set(snappedX, event.position[1], snappedZ) - } - - if (!pointerDown.current) return - // A gizmo/handle drag took over — don't draw a selection box underneath it. - if (useViewer.getState().inputDragging) return - - currentPoint.current.set(snappedX, event.position[1], snappedZ) - - // Check drag threshold (screen pixels) - const nativeEvent = event.nativeEvent as unknown as PointerEvent - const dx = nativeEvent.clientX - startClientX.current - const dy = nativeEvent.clientY - startClientY.current - if (!isDragging.current && Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) { - isDragging.current = true - } - - if (isDragging.current && rectFillRef.current && outlineRef.current) { - updateRectVisuals( - rectFillRef.current, - outlineRef.current, - startPoint.current, - currentPoint.current, - gridY.current, - ) - - const nextGridPosition: [number, number] = [snappedX, snappedZ] - if ( - previousGridPosition.current && - (nextGridPosition[0] !== previousGridPosition.current[0] || - nextGridPosition[1] !== previousGridPosition.current[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousGridPosition.current = nextGridPosition - - const bounds: Bounds = { - minX: Math.min(startPoint.current.x, currentPoint.current.x), - maxX: Math.max(startPoint.current.x, currentPoint.current.x), - minZ: Math.min(startPoint.current.z, currentPoint.current.z), - maxZ: Math.max(startPoint.current.z, currentPoint.current.z), - } - syncPreviewSelectedIds(collectNodeIdsInBounds(bounds)) - } - } - - emitter.on('grid:move', onMove) return () => { - emitter.off('grid:move', onMove) + clearBoxSelectHandled() + resetDrag() } - }, [syncPreviewSelectedIds]) + }, [resetDrag]) - return ( - - {/* Cursor indicator */} - - - {/* Selection rectangle fill */} - - - - - - {/* Outline (LineLoop added as primitive — allocated once in ref) */} - - - ) + return null } diff --git a/packages/editor/src/components/tools/select/plane-box-select-tool.tsx b/packages/editor/src/components/tools/select/plane-box-select-tool.tsx new file mode 100644 index 00000000..63b75cf2 --- /dev/null +++ b/packages/editor/src/components/tools/select/plane-box-select-tool.tsx @@ -0,0 +1,566 @@ +import '../../../three-types' + +import { Icon } from '@iconify/react' +import { + type AnyNodeId, + emitter, + type GridEvent, + sceneRegistry, + useScene, + type ZoneNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import type { ThreeElements } from '@react-three/fiber' +import { useThree } from '@react-three/fiber' +import { useCallback, useEffect, useRef } from 'react' +import { + Box3, + BufferAttribute, + BufferGeometry, + DoubleSide, + type Group, + LineBasicMaterial, + LineSegments, + type Mesh, + Plane, + Raycaster, + Vector2, + Vector3, +} from 'three' +import { EDITOR_LAYER } from '../../../lib/constants' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { CursorSphere } from '../shared/cursor-sphere' +import { markBoxSelectHandled } from './box-select-state' +import { collectSelectableCandidateIds } from './select-candidates' + +declare module 'react/jsx-runtime' { + namespace JSX { + interface IntrinsicElements extends ThreeElements {} + } +} + +type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number } + +const BOX_SELECT_ACCENT_COLOR = '#818cf8' +const DRAG_THRESHOLD_PX = 4 +const tempVec = new Vector3() +const tempBox = new Box3() + +function pointInBounds(x: number, z: number, b: Bounds): boolean { + return x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ +} + +function segmentsIntersect( + ax1: number, + az1: number, + ax2: number, + az2: number, + bx1: number, + bz1: number, + bx2: number, + bz2: number, +): boolean { + const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1) + const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2) + const d3 = cross(ax1, az1, ax2, az2, bx1, bz1) + const d4 = cross(ax1, az1, ax2, az2, bx2, bz2) + + if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { + return true + } + + if (d1 === 0 && onSegment(bx1, bz1, bx2, bz2, ax1, az1)) return true + if (d2 === 0 && onSegment(bx1, bz1, bx2, bz2, ax2, az2)) return true + if (d3 === 0 && onSegment(ax1, az1, ax2, az2, bx1, bz1)) return true + if (d4 === 0 && onSegment(ax1, az1, ax2, az2, bx2, bz2)) return true + + return false +} + +function cross(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): number { + return (bx - ax) * (cz - az) - (bz - az) * (cx - ax) +} + +function onSegment( + ax: number, + az: number, + bx: number, + bz: number, + cx: number, + cz: number, +): boolean { + return ( + Math.min(ax, bx) <= cx && + cx <= Math.max(ax, bx) && + Math.min(az, bz) <= cz && + cz <= Math.max(az, bz) + ) +} + +function segmentIntersectsBounds( + x1: number, + z1: number, + x2: number, + z2: number, + b: Bounds, +): boolean { + if (pointInBounds(x1, z1, b) || pointInBounds(x2, z2, b)) return true + + const edges: [number, number, number, number][] = [ + [b.minX, b.minZ, b.maxX, b.minZ], + [b.maxX, b.minZ, b.maxX, b.maxZ], + [b.maxX, b.maxZ, b.minX, b.maxZ], + [b.minX, b.maxZ, b.minX, b.minZ], + ] + for (const [ex1, ez1, ex2, ez2] of edges) { + if (segmentsIntersect(x1, z1, x2, z2, ex1, ez1, ex2, ez2)) return true + } + return false +} + +function polygonIntersectsBounds(polygon: [number, number][], b: Bounds): boolean { + if (polygon.some(([x, z]) => pointInBounds(x, z, b))) return true + + const corners: [number, number][] = [ + [b.minX, b.minZ], + [b.maxX, b.minZ], + [b.maxX, b.maxZ], + [b.minX, b.maxZ], + ] + if (corners.some(([cx, cz]) => pointInPolygon(cx, cz, polygon))) return true + + const edges: [number, number, number, number][] = [ + [b.minX, b.minZ, b.maxX, b.minZ], + [b.maxX, b.minZ, b.maxX, b.maxZ], + [b.maxX, b.maxZ, b.minX, b.maxZ], + [b.minX, b.maxZ, b.minX, b.minZ], + ] + for (let i = 0; i < polygon.length; i++) { + const [px1, pz1] = polygon[i]! + const [px2, pz2] = polygon[(i + 1) % polygon.length]! + for (const [ex1, ez1, ex2, ez2] of edges) { + if (segmentsIntersect(px1, pz1, px2, pz2, ex1, ez1, ex2, ez2)) return true + } + } + + return false +} + +function pointInPolygon(x: number, z: number, polygon: [number, number][]): boolean { + let inside = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, zi] = polygon[i]! + const [xj, zj] = polygon[j]! + if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) { + inside = !inside + } + } + return inside +} + +function getNodeWorldXZ(nodeId: string): [number, number] | null { + const obj = sceneRegistry.nodes.get(nodeId) + if (!obj) return null + obj.getWorldPosition(tempVec) + return [tempVec.x, tempVec.z] +} + +function objectBoundsIntersectsBounds(nodeId: string, bounds: Bounds): boolean { + const obj = sceneRegistry.nodes.get(nodeId) + if (!obj) return false + + obj.updateWorldMatrix(true, true) + tempBox.setFromObject(obj) + + if (tempBox.isEmpty()) { + const xz = getNodeWorldXZ(nodeId) + return Boolean(xz && pointInBounds(xz[0], xz[1], bounds)) + } + + return !( + tempBox.max.x < bounds.minX || + tempBox.min.x > bounds.maxX || + tempBox.max.z < bounds.minZ || + tempBox.min.z > bounds.maxZ + ) +} + +function collectNodeIdsInPlaneBounds(bounds: Bounds | null): string[] { + const candidateIds = collectSelectableCandidateIds() + if (!bounds) return candidateIds + + const { nodes } = useScene.getState() + return candidateIds.filter((id) => { + const node = nodes[id as AnyNodeId] + if (!node) return false + + if (node.type === 'wall' || node.type === 'fence') { + return segmentIntersectsBounds(node.start[0], node.start[1], node.end[0], node.end[1], bounds) + } + + if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') { + return polygonIntersectsBounds(node.polygon, bounds) + } + + return objectBoundsIntersectsBounds(id, bounds) + }) +} + +function haveSameIds(currentIds: string[], nextIds: string[]): boolean { + return ( + currentIds.length === nextIds.length && + currentIds.every((currentId, index) => currentId === nextIds[index]) + ) +} + +function updateRectVisuals( + fillMesh: Mesh, + outline: LineSegments, + start: Vector3, + end: Vector3, + y: number, +) { + const cx = (start.x + end.x) / 2 + const cz = (start.z + end.z) / 2 + const w = Math.abs(end.x - start.x) + const h = Math.abs(end.z - start.z) + + if (w < 0.01 && h < 0.01) { + fillMesh.visible = false + outline.visible = false + return + } + + fillMesh.visible = true + fillMesh.position.set(cx, y + 0.02, cz) + fillMesh.scale.set(w, h, 1) + + outline.visible = true + const oy = y + 0.03 + const x0 = cx - w / 2 + const x1 = cx + w / 2 + const z0 = cz - h / 2 + const z1 = cz + h / 2 + const pos = outline.geometry.attributes.position as BufferAttribute + pos.setXYZ(0, x0, oy, z0) + pos.setXYZ(1, x1, oy, z0) + pos.setXYZ(2, x1, oy, z0) + pos.setXYZ(3, x1, oy, z1) + pos.setXYZ(4, x1, oy, z1) + pos.setXYZ(5, x0, oy, z1) + pos.setXYZ(6, x0, oy, z1) + pos.setXYZ(7, x0, oy, z0) + pos.needsUpdate = true +} + +function createOutlineSegments(): LineSegments { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new BufferAttribute(new Float32Array(8 * 3), 3)) + + const material = new LineBasicMaterial({ + color: BOX_SELECT_ACCENT_COLOR, + depthTest: false, + depthWrite: false, + transparent: true, + opacity: 0.85, + }) + + const segments = new LineSegments(geometry, material) + segments.layers.set(EDITOR_LAYER) + segments.renderOrder = 2 + segments.visible = false + segments.frustumCulled = false + + return segments +} + +function getSnappedGridPosition(x: number, z: number): [number, number] { + return [Math.round(x * 2) / 2, Math.round(z * 2) / 2] +} + +function setSnappedPoint(target: Vector3, x: number, y: number, z: number) { + const [snappedX, snappedZ] = getSnappedGridPosition(x, z) + target.set(snappedX, y, snappedZ) +} + +const BOX_SELECT_TOOLTIP = ( + +) + +export const PlaneBoxSelectTool: React.FC = () => { + const { camera, gl } = useThree() + const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) + const cursorRef = useRef(null) + const rectFillRef = useRef(null!) + const outlineRef = useRef(createOutlineSegments()) + const startPoint = useRef(new Vector3()) + const currentPoint = useRef(new Vector3()) + const pointerDown = useRef(false) + const isDragging = useRef(false) + const startClientX = useRef(0) + const startClientY = useRef(0) + const gridY = useRef(0) + const previousGridPosition = useRef<[number, number] | null>(null) + const previewSelectedIdsRef = useRef([]) + const spaceDownRef = useRef(false) + const raycasterRef = useRef(new Raycaster()) + const pointerNDC = useRef(new Vector2()) + const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0)) + const hitPoint = useRef(new Vector3()) + + const syncPreviewSelectedIds = useCallback( + (nextIds: string[]) => { + if (haveSameIds(previewSelectedIdsRef.current, nextIds)) return + previewSelectedIdsRef.current = nextIds + setPreviewSelectedIds(nextIds) + }, + [setPreviewSelectedIds], + ) + + const resetDrag = useCallback(() => { + pointerDown.current = false + isDragging.current = false + rectFillRef.current.visible = false + outlineRef.current.visible = false + syncPreviewSelectedIds([]) + }, [syncPreviewSelectedIds]) + + const raycastToGround = useCallback( + (event: PointerEvent): Vector3 | null => { + const rect = gl.domElement.getBoundingClientRect() + pointerNDC.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 + pointerNDC.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 + raycasterRef.current.setFromCamera(pointerNDC.current, camera) + if (raycasterRef.current.ray.intersectPlane(groundPlane.current, hitPoint.current)) { + return hitPoint.current + } + return null + }, + [camera, gl], + ) + + useEffect(() => { + const outline = outlineRef.current + return () => { + previewSelectedIdsRef.current = [] + setPreviewSelectedIds([]) + outline.geometry.dispose() + ;(outline.material as LineBasicMaterial).dispose() + } + }, [setPreviewSelectedIds]) + + useEffect(() => { + const unsubscribe = useViewer.subscribe((state) => { + const levelId = state.selection.levelId + if (!levelId) return + const obj = sceneRegistry.nodes.get(levelId) + if (obj) groundPlane.current.constant = -obj.position.y + }) + const levelId = useViewer.getState().selection.levelId + if (levelId) { + const obj = sceneRegistry.nodes.get(levelId) + if (obj) groundPlane.current.constant = -obj.position.y + } + return unsubscribe + }, []) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.code !== 'Space') return + spaceDownRef.current = true + if (pointerDown.current) { + resetDrag() + } + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.code !== 'Space') return + spaceDownRef.current = false + } + + const onBlur = () => { + spaceDownRef.current = false + resetDrag() + } + + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) + + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) + } + }, [resetDrag]) + + useEffect(() => { + const canvas = gl.domElement + + const onCanvasPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + if (spaceDownRef.current) return + if (useViewer.getState().cameraDragging) return + if (useViewer.getState().inputDragging) return + + const point = raycastToGround(event) + if (!point) return + + setSnappedPoint(startPoint.current, point.x, point.y, point.z) + setSnappedPoint(currentPoint.current, point.x, point.y, point.z) + gridY.current = point.y + pointerDown.current = true + isDragging.current = false + previousGridPosition.current = getSnappedGridPosition(point.x, point.z) + startClientX.current = event.clientX + startClientY.current = event.clientY + syncPreviewSelectedIds([]) + } + + const onCanvasPointerUp = (event: PointerEvent) => { + if (event.button !== 0) return + if (useViewer.getState().inputDragging) { + resetDrag() + return + } + if (!pointerDown.current) return + + if (isDragging.current) { + const point = raycastToGround(event) + if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z) + + const bounds: Bounds = { + minX: Math.min(startPoint.current.x, currentPoint.current.x), + maxX: Math.max(startPoint.current.x, currentPoint.current.x), + minZ: Math.min(startPoint.current.z, currentPoint.current.z), + maxZ: Math.max(startPoint.current.z, currentPoint.current.z), + } + + const ids = collectNodeIdsInPlaneBounds(bounds) + const shouldAppend = event.metaKey || event.ctrlKey + const { phase, structureLayer } = useEditor.getState() + + if (phase === 'structure' && structureLayer === 'zones') { + if (ids.length > 0) { + useViewer.getState().setSelection({ zoneId: ids[0] as ZoneNode['id'] }) + } else if (!shouldAppend) { + useViewer.getState().setSelection({ zoneId: null }) + } + } else if (shouldAppend) { + const currentIds = useViewer.getState().selection.selectedIds + useViewer.getState().setSelection({ + selectedIds: Array.from(new Set([...currentIds, ...ids])), + }) + } else { + const allOnLevel = collectNodeIdsInPlaneBounds(null) + const { buildingId } = useViewer.getState().selection + const selectedEntireLevel = allOnLevel.length > 0 && ids.length === allOnLevel.length + + if (selectedEntireLevel && buildingId) { + useViewer.getState().setSelection({ buildingId }) + } else { + useViewer.getState().setSelection({ selectedIds: ids }) + } + } + + markBoxSelectHandled() + } + + resetDrag() + } + + canvas.addEventListener('pointerdown', onCanvasPointerDown) + canvas.addEventListener('pointerup', onCanvasPointerUp) + + return () => { + canvas.removeEventListener('pointerdown', onCanvasPointerDown) + canvas.removeEventListener('pointerup', onCanvasPointerUp) + } + }, [gl, raycastToGround, resetDrag, syncPreviewSelectedIds]) + + useEffect(() => { + const onMove = (event: GridEvent) => { + const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2]) + + if (cursorRef.current) { + cursorRef.current.position.set(snappedX, event.position[1], snappedZ) + } + + if (!pointerDown.current) return + if (spaceDownRef.current || useViewer.getState().inputDragging) return + + currentPoint.current.set(snappedX, event.position[1], snappedZ) + + const nativeEvent = event.nativeEvent as unknown as PointerEvent + const dx = nativeEvent.clientX - startClientX.current + const dy = nativeEvent.clientY - startClientY.current + if (!isDragging.current && Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) { + isDragging.current = true + } + + if (isDragging.current && rectFillRef.current && outlineRef.current) { + updateRectVisuals( + rectFillRef.current, + outlineRef.current, + startPoint.current, + currentPoint.current, + gridY.current, + ) + + const nextGridPosition: [number, number] = [snappedX, snappedZ] + if ( + previousGridPosition.current && + (nextGridPosition[0] !== previousGridPosition.current[0] || + nextGridPosition[1] !== previousGridPosition.current[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + previousGridPosition.current = nextGridPosition + + const bounds: Bounds = { + minX: Math.min(startPoint.current.x, currentPoint.current.x), + maxX: Math.max(startPoint.current.x, currentPoint.current.x), + minZ: Math.min(startPoint.current.z, currentPoint.current.z), + maxZ: Math.max(startPoint.current.z, currentPoint.current.z), + } + syncPreviewSelectedIds(collectNodeIdsInPlaneBounds(bounds)) + } + } + + emitter.on('grid:move', onMove) + return () => { + emitter.off('grid:move', onMove) + } + }, [syncPreviewSelectedIds]) + + return ( + + + + + + + + + ) +} diff --git a/packages/editor/src/components/tools/select/select-candidates.ts b/packages/editor/src/components/tools/select/select-candidates.ts new file mode 100644 index 00000000..2ef4d371 --- /dev/null +++ b/packages/editor/src/components/tools/select/select-candidates.ts @@ -0,0 +1,127 @@ +import { + type AnyNode, + type AnyNodeId, + isRegistrySelectable, + type LevelNode, + nodeRegistry, + resolveBuildingForLevel, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import useEditor from '../../../store/use-editor' + +export function isFurnishSelectableCandidate(node: AnyNode): boolean { + if (node.type === 'item') { + return node.asset.category !== 'door' && node.asset.category !== 'window' + } + + const def = nodeRegistry.get(node.type) + return Boolean(def?.category === 'furnish' && def.capabilities.selectable) +} + +export function isStructureSelectableCandidate(node: AnyNode): boolean { + if ( + node.type === 'wall' || + node.type === 'fence' || + node.type === 'column' || + node.type === 'elevator' || + node.type === 'slab' || + node.type === 'ceiling' || + node.type === 'roof' || + node.type === 'stair' || + node.type === 'spawn' || + node.type === 'window' || + node.type === 'door' + ) { + return true + } + + if (node.type === 'item') { + return node.asset.category === 'door' || node.asset.category === 'window' + } + + const def = nodeRegistry.get(node.type) + return Boolean(def && def.category !== 'furnish' && def.capabilities.selectable) +} + +export function collectSelectableCandidateIds(): string[] { + const { levelId } = useViewer.getState().selection + const { nodes } = useScene.getState() + const { phase, structureLayer } = useEditor.getState() + const result: string[] = [] + const seen = new Set() + const addNode = (node: AnyNode | undefined) => { + if (!node || seen.has(node.id)) return + seen.add(node.id) + result.push(node.id) + } + + if (phase === 'site') { + for (const node of Object.values(nodes)) { + if (node.type === 'building') addNode(node) + } + return result + } + + if (!levelId) return [] + const levelNode = nodes[levelId as AnyNodeId] as LevelNode | undefined + if (!levelNode || levelNode.type !== 'level') return [] + + if (phase === 'structure' && structureLayer === 'zones') { + for (const childId of levelNode.children) { + const node = nodes[childId as AnyNodeId] + if (node?.type === 'zone') addNode(node) + } + return result + } + + for (const childId of levelNode.children) { + const node = nodes[childId as AnyNodeId] + if (!node) continue + + if (phase === 'furnish') { + if (isFurnishSelectableCandidate(node)) addNode(node) + continue + } + + if (node.type === 'wall' || node.type === 'fence') { + addNode(node) + const hostedChildren = 'children' in node && Array.isArray(node.children) ? node.children : [] + for (const hostedChildId of hostedChildren) { + const child = nodes[hostedChildId as AnyNodeId] + if (!child) continue + if ( + child.type === 'window' || + child.type === 'door' || + (child.type === 'item' && + (child.asset.category === 'door' || child.asset.category === 'window')) + ) { + addNode(child) + } + } + continue + } + + if (isStructureSelectableCandidate(node)) { + addNode(node) + } + } + + const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes) + const buildingNode = buildingId ? nodes[buildingId] : undefined + const buildingChildren = + buildingNode && 'children' in buildingNode && Array.isArray(buildingNode.children) + ? (buildingNode.children as AnyNodeId[]) + : [] + for (const childId of buildingChildren) { + const node = nodes[childId] + if (!node || node.type === 'level' || !isRegistrySelectable(node.type)) continue + if (phase === 'furnish') { + if (isFurnishSelectableCandidate(node)) addNode(node) + } else if (isStructureSelectableCandidate(node)) { + addNode(node) + } + } + + return result +} diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 32bcf0d9..ce8c4b6f 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -1,5 +1,5 @@ import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' -import { SCENE_LAYER } from '@pascal-app/viewer' +import { SCENE_LAYER, useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { @@ -317,6 +317,7 @@ export const PolygonEditor: React.FC = ({ const [dragState, setDragState] = useState(null) const [previewPolygon, setPreviewPolygon] = useState | null>(null) const previewPolygonRef = useRef | null>(null) + const previousInputDraggingRef = useRef(false) const onPolygonPreviewRef = useRef(onPolygonPreview) useEffect(() => { @@ -346,6 +347,20 @@ export const PolygonEditor: React.FC = ({ const lineRef = useRef(null!) const previousPositionRef = useRef<[number, number] | null>(null) + const startDrag = useCallback((nextDragState: DragState) => { + previousInputDraggingRef.current = useViewer.getState().inputDragging + useViewer.getState().setInputDragging(true) + setDragState(nextDragState) + }, []) + + useEffect(() => { + if (!dragState?.isDragging) return + + return () => { + useViewer.getState().setInputDragging(previousInputDraggingRef.current) + } + }, [dragState?.isDragging]) + // Track the last polygon prop to detect external changes (undo/redo) or // our own post-commit prop update arriving while a preview is still in // flight. Either way, drop the stale preview/drag. @@ -687,7 +702,7 @@ export const PolygonEditor: React.FC = ({ if (e.button !== 0) return e.stopPropagation() setHoveredEdge(null) - setDragState({ + startDrag({ isDragging: true, mode: 'vertex', vertexIndex: index, @@ -721,7 +736,7 @@ export const PolygonEditor: React.FC = ({ if (e.button !== 0) return e.stopPropagation() setHoveredEdge(null) - setDragState({ + startDrag({ isDragging: true, mode: 'polygon', vertexIndex: null, @@ -750,7 +765,7 @@ export const PolygonEditor: React.FC = ({ if (!edgeNormal) return setHoveredEdge(null) - setDragState({ + startDrag({ isDragging: true, mode: 'edge', vertexIndex: null, @@ -835,7 +850,7 @@ export const PolygonEditor: React.FC = ({ e.stopPropagation() const insertedVertex = handleAddVertex(index, [x!, z!]) if (insertedVertex.vertexIndex >= 0) { - setDragState({ + startDrag({ isDragging: true, mode: 'vertex', vertexIndex: insertedVertex.vertexIndex, diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index d152b2b4..f11de2c0 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -1,11 +1,16 @@ import { + type AnyNode, collectAlignmentAnchors, + createSurfaceOpeningPreviewController, + type EventSuffix, emitter, type GridEvent, type LevelNode, + type NodeEvent, resolveAlignment, StairNode, StairSegmentNode, + syncAutoStairOpenings, useAlignmentGuides, useScene, } from '@pascal-app/core' @@ -13,6 +18,10 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' +import { + resolveStairDestinationLevel, + resolveStairPlacementLevelId, +} from '../../../lib/stair-levels' import { CursorSphere } from '../shared/cursor-sphere' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { @@ -37,6 +46,21 @@ import { const GRID_OFFSET = 0.02 /** Figma-style alignment-snap threshold (meters), matching the move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 +type ClickTriggerEvent = GridEvent | NodeEvent + +const CLICK_TRIGGER_KINDS = [ + 'shelf', + 'item', + 'slab', + 'ceiling', + 'wall', + 'fence', + 'column', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', +] as const /** * Generates the step-profile geometry for the ghost preview. @@ -140,28 +164,42 @@ function commitStairPlacement( rotation: number, ): void { const { createNodes, nodes } = useScene.getState() + const placementLevelId = resolveStairPlacementLevelId( + nodes, + levelId, + useViewer.getState().selection.buildingId, + ) + if (!placementLevelId) return const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length const name = `Staircase ${stairCount + 1}` const segment = createDefaultStairSegment() - const sortedLevels = Object.values(nodes) - .filter((node): node is LevelNode => node.type === 'level') - .sort((left, right) => left.level - right.level) - const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId) - const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId + const destinationPlan = resolveStairDestinationLevel({ + createMissing: true, + fromLevelId: placementLevelId, + nodes, + }) + const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId const stair = createDefaultStairNode({ name, - levelId, + levelId: placementLevelId, nextLevelId, position, rotation, segmentId: segment.id, }) + const createdLevel = destinationPlan?.createdLevel + const levelCreateOps = + createdLevel && destinationPlan.buildingId + ? [{ node: createdLevel, parentId: destinationPlan.buildingId }] + : [] + createNodes([ - { node: stair, parentId: levelId }, + ...levelCreateOps, + { node: stair, parentId: placementLevelId }, { node: segment, parentId: stair.id }, ]) @@ -181,39 +219,60 @@ export const StairTool: React.FC = () => { useEffect(() => { if (!currentLevelId) return + const openingPreview = createSurfaceOpeningPreviewController() + // Reset rotation when tool activates rotationRef.current = 0 if (previewRef.current) previewRef.current.rotation.y = 0 lastCanonicalPositionRef.current = null - const getPreviewPosition = ( - position: [number, number, number], - rotation: number, - ): [number, number, number] => { + const buildPreviewScene = (position: [number, number, number], rotation: number) => { + const nodes = useScene.getState().nodes + const placementLevelId = resolveStairPlacementLevelId( + nodes, + currentLevelId, + useViewer.getState().selection.buildingId, + ) + if (!placementLevelId) return null + + const destinationPlan = resolveStairDestinationLevel({ + createMissing: true, + fromLevelId: placementLevelId, + nodes, + }) + const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId const segment = createDefaultStairSegment() const stair = createDefaultStairNode({ name: 'Staircase Preview', - levelId: currentLevelId, - nextLevelId: currentLevelId, + levelId: placementLevelId, + nextLevelId, position, rotation, segmentId: segment.id, }) - return getFloorStackPreviewPosition({ - node: stair, - position, - rotation, - levelId: currentLevelId, - nodes: { - ...useScene.getState().nodes, - [stair.id]: stair, - [segment.id]: segment, - }, - }) + const previewNodes = { + ...nodes, + ...(destinationPlan?.createdLevel + ? { [destinationPlan.createdLevel.id]: destinationPlan.createdLevel } + : {}), + [stair.id]: { ...stair, parentId: placementLevelId }, + [segment.id]: { ...segment, parentId: stair.id }, + } as Record + + return { placementLevelId, previewNodes, stair } } - const applyPreview = (position: [number, number, number], rotation: number) => { - const visualPosition = getPreviewPosition(position, rotation) + const applyDraftPreview = (position: [number, number, number], rotation: number) => { + const preview = buildPreviewScene(position, rotation) + const visualPosition = preview + ? getFloorStackPreviewPosition({ + node: preview.stair, + position, + rotation, + levelId: preview.placementLevelId, + nodes: preview.previewNodes, + }) + : position if (cursorRef.current) { cursorRef.current.position.set( visualPosition[0], @@ -226,6 +285,13 @@ export const StairTool: React.FC = () => { previewRef.current.position.set(...visualPosition) previewRef.current.rotation.y = rotation } + + if (!preview) { + openingPreview.clear() + return + } + + openingPreview.apply(syncAutoStairOpenings(preview.previewNodes)) } // Alignment candidates — anchors of every alignable object; refreshed @@ -277,7 +343,7 @@ export const StairTool: React.FC = () => { ) const position: [number, number, number] = [gridX, 0, gridZ] lastCanonicalPositionRef.current = position - applyPreview(position, rotationRef.current) + applyDraftPreview(position, rotationRef.current) if ( previousGridPosRef.current && @@ -289,9 +355,7 @@ export const StairTool: React.FC = () => { previousGridPosRef.current = [gridX, gridZ] } - const onGridClick = (event: GridEvent) => { - if (!currentLevelId) return - + const getAlignedGridPosition = (event: GridEvent): [number, number, number] => { const [gridX, gridZ] = alignPoint( Math.round(event.localPosition[0] * 2) / 2, Math.round(event.localPosition[2] * 2) / 2, @@ -299,7 +363,24 @@ export const StairTool: React.FC = () => { event.localPosition[2], event.nativeEvent?.altKey === true, ) - commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current) + return [gridX, 0, gridZ] + } + + const commitAtCursor = (event: ClickTriggerEvent) => { + if (!currentLevelId) return + const nodeEvent = 'node' in event ? (event as NodeEvent) : null + if (nodeEvent) { + nodeEvent.stopPropagation() + nodeEvent.nativeEvent.stopPropagation() + } + + const position = nodeEvent + ? lastCanonicalPositionRef.current + : getAlignedGridPosition(event as GridEvent) + if (!position) return + + commitStairPlacement(currentLevelId, position, rotationRef.current) + openingPreview.clear() alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId) useAlignmentGuides.getState().clear() } @@ -319,7 +400,7 @@ export const StairTool: React.FC = () => { sfxEmitter.emit('sfx:item-rotate') rotationRef.current += rotationDelta if (lastCanonicalPositionRef.current) { - applyPreview(lastCanonicalPositionRef.current, rotationRef.current) + applyDraftPreview(lastCanonicalPositionRef.current, rotationRef.current) } else if (previewRef.current) { previewRef.current.rotation.y = rotationRef.current } @@ -327,14 +408,25 @@ export const StairTool: React.FC = () => { } emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) + emitter.on('grid:click', commitAtCursor) + type SuffixedKey = `${K}:${EventSuffix}` + type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]> + for (const kind of CLICK_TRIGGER_KINDS) { + const key = `${kind}:click` as ClickKey + emitter.on(key, commitAtCursor as never) + } window.addEventListener('keydown', onKeyDown) return () => { emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) + emitter.off('grid:click', commitAtCursor) + for (const kind of CLICK_TRIGGER_KINDS) { + const key = `${kind}:click` as ClickKey + emitter.off(key, commitAtCursor as never) + } window.removeEventListener('keydown', onKeyDown) useAlignmentGuides.getState().clear() + openingPreview.clear() } }, [currentLevelId]) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index d688a4e1..43a71250 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -209,6 +209,14 @@ export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { triggerSFX } from './lib/sfx-bus' export { duplicateStairSubtree } from './lib/stair-duplication' +export { + getBuildingLevelsForLevel, + getStairLevelOptions, + resolveStairDestinationLevel, + resolveStairFromLevelId, + resolveStairPlacementLevelId, + resolveStairToLevelId, +} from './lib/stair-levels' // `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ // nodes` so they don't need their own copy / their own tailwind-merge // dependency. diff --git a/packages/editor/src/lib/stair-levels.test.ts b/packages/editor/src/lib/stair-levels.test.ts new file mode 100644 index 00000000..fa1e9946 --- /dev/null +++ b/packages/editor/src/lib/stair-levels.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + BuildingNode, + LevelNode, + StairNode, +} from '@pascal-app/core/schema' +import { + getBuildingLevelsForLevel, + getStairLevelOptions, + resolveStairDestinationLevel, + resolveStairFromLevelId, + resolveStairPlacementLevelId, + resolveStairToLevelId, +} from './stair-levels' + +describe('stair level helpers', () => { + test('creates a missing upper level in the same building', () => { + const ground = LevelNode.parse({ level: 0, children: [] }) + const building = BuildingNode.parse({ children: [ground.id] }) + const nodes = { + [building.id]: building, + [ground.id]: ground, + } as Record + + const plan = resolveStairDestinationLevel({ + createMissing: true, + fromLevelId: ground.id, + nodes, + }) + + expect(plan?.buildingId).toBe(building.id) + expect(plan?.fromLevel.id).toBe(ground.id) + expect(plan?.toLevel.level).toBe(1) + expect(plan?.toLevel.id).toBe(plan?.createdLevel?.id) + expect(plan?.createdLevel?.parentId).toBe(building.id) + }) + + test('uses the nearest higher sibling level instead of creating one', () => { + const building = BuildingNode.parse({}) + const ground = LevelNode.parse({ level: 0, parentId: building.id }) + const second = LevelNode.parse({ level: 1, parentId: building.id }) + const third = LevelNode.parse({ level: 2, parentId: building.id }) + const nodes = { + [building.id]: { ...building, children: [ground.id, third.id, second.id] }, + [ground.id]: ground, + [second.id]: second, + [third.id]: third, + } as Record + + const plan = resolveStairDestinationLevel({ + createMissing: true, + fromLevelId: ground.id, + nodes, + }) + + expect(plan?.createdLevel).toBeNull() + expect(plan?.toLevel.id).toBe(second.id) + }) + + test('ignores levels from other buildings', () => { + const buildingA = BuildingNode.parse({}) + const buildingB = BuildingNode.parse({}) + const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id }) + const upperA = LevelNode.parse({ level: 1, parentId: buildingA.id }) + const upperB = LevelNode.parse({ level: 1, parentId: buildingB.id }) + const nodes = { + [buildingA.id]: { ...buildingA, children: [groundA.id, upperA.id] }, + [buildingB.id]: { ...buildingB, children: [upperB.id] }, + [groundA.id]: groundA, + [upperA.id]: upperA, + [upperB.id]: upperB, + } as Record + + expect(getBuildingLevelsForLevel(nodes, groundA.id).map((level) => level.id)).toEqual([ + groundA.id, + upperA.id, + ]) + expect( + resolveStairDestinationLevel({ createMissing: true, fromLevelId: groundA.id, nodes })?.toLevel + .id, + ).toBe(upperA.id) + }) + + test('includes source and parent-linked sibling levels when building children are stale', () => { + const building = BuildingNode.parse({ children: [] }) + const ground = LevelNode.parse({ level: 0, parentId: building.id }) + const upper = LevelNode.parse({ level: 1, parentId: building.id }) + const nodes = { + [building.id]: building, + [ground.id]: ground, + [upper.id]: upper, + } as Record + + const levels = getBuildingLevelsForLevel(nodes, ground.id) + const plan = resolveStairDestinationLevel({ + createMissing: true, + fromLevelId: ground.id, + nodes, + }) + + expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id]) + expect(plan?.createdLevel).toBeNull() + expect(plan?.toLevel.id).toBe(upper.id) + }) + + test('falls back from stale placement level ids to a valid level in the selected building', () => { + const buildingA = BuildingNode.parse({}) + const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id }) + const buildingB = BuildingNode.parse({}) + const groundB = LevelNode.parse({ level: 0, parentId: buildingB.id }) + const nodes = { + [buildingA.id]: { ...buildingA, children: [groundA.id] }, + [groundA.id]: groundA, + [buildingB.id]: { ...buildingB, children: [groundB.id] }, + [groundB.id]: groundB, + } as Record + + expect(resolveStairPlacementLevelId(nodes, 'level_missing', buildingB.id)).toBe(groundB.id) + }) + + test('repairs panel level ids for stairs with stale from-level data', () => { + const building = BuildingNode.parse({}) + const ground = LevelNode.parse({ level: 0, parentId: building.id }) + const upper = LevelNode.parse({ level: 1, parentId: building.id }) + const stair = StairNode.parse({ + parentId: ground.id, + fromLevelId: 'default', + toLevelId: upper.id, + }) + const nodes = { + [building.id]: { ...building, children: [ground.id, upper.id] }, + [ground.id]: ground, + [upper.id]: upper, + [stair.id]: stair, + } as Record + + const levels = getStairLevelOptions(nodes, stair) + const fromLevelId = resolveStairFromLevelId(nodes, stair, levels) + + expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id]) + expect(fromLevelId).toBe(ground.id) + expect(resolveStairToLevelId(nodes, stair, fromLevelId, levels)).toBe(upper.id) + }) +}) diff --git a/packages/editor/src/lib/stair-levels.ts b/packages/editor/src/lib/stair-levels.ts new file mode 100644 index 00000000..d0f1841a --- /dev/null +++ b/packages/editor/src/lib/stair-levels.ts @@ -0,0 +1,177 @@ +import { + type AnyNode, + type AnyNodeId, + LevelNode, + type LevelNode as LevelNodeType, + resolveBuildingForLevel, + type StairNode, +} from '@pascal-app/core' + +function sortLevelsByHeight(levels: LevelNodeType[]) { + return [...levels].sort((left, right) => left.level - right.level) +} + +function isLevelNode(node: AnyNode | undefined): node is LevelNodeType { + return node?.type === 'level' +} + +function getAllSceneLevels(nodes: Record) { + return sortLevelsByHeight( + Object.values(nodes).filter((entry): entry is LevelNodeType => entry?.type === 'level'), + ) +} + +function getBuildingLevels( + nodes: Record, + buildingId: AnyNodeId | string | null | undefined, + source?: LevelNodeType, +) { + if (!buildingId) return source ? [source] : [] + const building = nodes[buildingId as AnyNodeId] + if (building?.type !== 'building') return source ? [source] : [] + + const levels = new Map() + if (source) levels.set(source.id, source) + + for (const childId of building.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isLevelNode(child)) levels.set(child.id, child) + } + + for (const candidate of Object.values(nodes)) { + if (isLevelNode(candidate) && candidate.parentId === building.id) { + levels.set(candidate.id, candidate) + } + } + + return sortLevelsByHeight(Array.from(levels.values())) +} + +export function getBuildingLevelsForLevel( + nodes: Record, + levelId: AnyNodeId | string | null | undefined, +) { + if (!levelId) return [] + const source = nodes[levelId as AnyNodeId] + if (!isLevelNode(source)) return [] + + const buildingId = resolveBuildingForLevel( + source.id as AnyNodeId, + nodes as Record, + ) + return getBuildingLevels(nodes, buildingId, source) +} + +export function getStairLevelOptions(nodes: Record, stair: StairNode) { + for (const candidateId of [stair.fromLevelId, stair.parentId, stair.toLevelId]) { + if (isLevelNode(nodes[candidateId as AnyNodeId])) { + return getBuildingLevelsForLevel(nodes, candidateId) + } + } + + return getAllSceneLevels(nodes) +} + +export function resolveStairPlacementLevelId( + nodes: Record, + preferredLevelId: AnyNodeId | string | null | undefined, + preferredBuildingId?: AnyNodeId | string | null, +) { + if (isLevelNode(nodes[preferredLevelId as AnyNodeId])) { + return preferredLevelId as LevelNodeType['id'] + } + + const buildingLevels = getBuildingLevels(nodes, preferredBuildingId) + return buildingLevels[0]?.id ?? getAllSceneLevels(nodes)[0]?.id ?? null +} + +export function resolveStairFromLevelId( + nodes: Record, + stair: StairNode, + levels = getStairLevelOptions(nodes, stair), +) { + const optionIds = new Set(levels.map((level) => level.id)) + if (stair.fromLevelId && optionIds.has(stair.fromLevelId)) return stair.fromLevelId + if (stair.parentId && optionIds.has(stair.parentId)) return stair.parentId + + const toLevel = stair.toLevelId ? nodes[stair.toLevelId as AnyNodeId] : undefined + if (isLevelNode(toLevel)) { + const lowerLevel = [...levels].reverse().find((level) => level.level < toLevel.level) + if (lowerLevel) return lowerLevel.id + } + + return levels[0]?.id ?? null +} + +export function resolveStairToLevelId( + nodes: Record, + stair: StairNode, + fromLevelId: AnyNodeId | string | null | undefined, + levels = getStairLevelOptions(nodes, stair), +) { + const optionIds = new Set(levels.map((level) => level.id)) + if (stair.toLevelId && stair.toLevelId !== fromLevelId && optionIds.has(stair.toLevelId)) { + return stair.toLevelId + } + + const fromLevel = fromLevelId ? nodes[fromLevelId as AnyNodeId] : undefined + if (isLevelNode(fromLevel)) { + return levels.find((level) => level.level > fromLevel.level)?.id ?? fromLevel.id + } + + return levels[0]?.id ?? null +} + +export function resolveStairDestinationLevel({ + createMissing, + fromLevelId, + nodes, +}: { + createMissing?: boolean + fromLevelId: AnyNodeId | string | null | undefined + nodes: Record +}) { + if (!fromLevelId) return null + const fromLevel = nodes[fromLevelId as AnyNodeId] + if (!isLevelNode(fromLevel)) return null + + const buildingId = resolveBuildingForLevel( + fromLevel.id as AnyNodeId, + nodes as Record, + ) + const levels = getBuildingLevelsForLevel(nodes, fromLevel.id) + const nextExistingLevel = levels.find((level) => level.level > fromLevel.level) ?? null + if (nextExistingLevel) { + return { + buildingId, + createdLevel: null, + fromLevel, + levels, + toLevel: nextExistingLevel, + } + } + + if (createMissing && buildingId) { + const createdLevel = LevelNode.parse({ + children: [], + level: fromLevel.level + 1, + parentId: buildingId, + }) + + return { + buildingId, + createdLevel, + fromLevel, + levels: sortLevelsByHeight([...levels, createdLevel]), + toLevel: createdLevel, + } + } + + return { + buildingId, + createdLevel: null, + fromLevel, + levels, + toLevel: fromLevel, + } +} diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 976466bf..71d64914 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -56,6 +56,7 @@ function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor n.width, apply: (initial, newWidth) => { const rotY = initial.rotation ?? 0 @@ -100,6 +101,7 @@ function roofSegmentDepthHandle(side: 'front' | 'back'): HandleDescriptor n.depth, apply: (initial, newDepth) => { // Recenter so the anchored Z edge stays at the same world point. diff --git a/packages/nodes/src/roof-segment/floorplan-affordances.ts b/packages/nodes/src/roof-segment/floorplan-affordances.ts index cbf7ab86..17e6b279 100644 --- a/packages/nodes/src/roof-segment/floorplan-affordances.ts +++ b/packages/nodes/src/roof-segment/floorplan-affordances.ts @@ -4,6 +4,7 @@ import { type FloorplanMoveTarget, type RoofNode, type RoofSegmentNode, + snapScalar, useScene, } from '@pascal-app/core' @@ -56,7 +57,7 @@ function resolveSegmentFrame( * the math survives any parent-roof rotation. */ export const roofSegmentResizeAffordance: FloorplanAffordance = { - start({ node, payload, nodes, initialPlanPoint }) { + start({ node, payload, nodes, initialPlanPoint, gridSnapStep }) { const { axis, side } = payload as RoofSegmentResizePayload const segmentId = node.id as AnyNodeId const initialValue = axis === 'x' ? node.width : node.depth @@ -79,7 +80,9 @@ export const roofSegmentResizeAffordance: FloorplanAffordance = apply({ planPoint }) { const currentLocal = projectLocalAxis(planPoint[0], planPoint[1]) const delta = (currentLocal - initialLocal) * side - const newValue = Math.max(MIN_ROOF_DIM, initialValue + 2 * delta) + const rawValue = initialValue + 2 * delta + const snappedValue = gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue + const newValue = Math.max(MIN_ROOF_DIM, snappedValue) lastValue = newValue useScene .getState() diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index 8f3663b4..bd86a393 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -1,8 +1,84 @@ -import { type NodeDefinition, RoofNode as RoofNodeSchema } from '@pascal-app/core' +import { + type AnyNodeId, + type HandleDescriptor, + type NodeDefinition, + RoofNode as RoofNodeSchema, + type RoofNode as RoofNodeType, + type RoofSegmentNode, + type SceneApi, +} from '@pascal-app/core' import { buildRoofFloorplan } from './floorplan' import { roofParametrics } from './parametrics' import { RoofNode } from './schema' +const MOVE_FRONT_OFFSET = 0.35 +const MIN_ROOF_FOOTPRINT = 1 + +type RoofFootprintBounds = { + maxX: number + maxZ: number + minX: number + minZ: number +} + +function getRoofFootprintBounds(node: RoofNodeType, sceneApi: SceneApi): RoofFootprintBounds { + let bounds: RoofFootprintBounds | null = null + + for (const childId of node.children ?? []) { + const segment = sceneApi.get(childId as AnyNodeId) + if (segment?.type !== 'roof-segment') continue + + const halfWidth = Math.max(segment.width, MIN_ROOF_FOOTPRINT) / 2 + const halfDepth = Math.max(segment.depth, MIN_ROOF_FOOTPRINT) / 2 + const cos = Math.cos(segment.rotation ?? 0) + const sin = Math.sin(segment.rotation ?? 0) + const corners = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] as const + + for (const [x, z] of corners) { + const localX = segment.position[0] + x * cos + z * sin + const localZ = segment.position[2] - x * sin + z * cos + bounds = + bounds === null + ? { maxX: localX, maxZ: localZ, minX: localX, minZ: localZ } + : { + maxX: Math.max(bounds.maxX, localX), + maxZ: Math.max(bounds.maxZ, localZ), + minX: Math.min(bounds.minX, localX), + minZ: Math.min(bounds.minZ, localZ), + } + } + } + + return bounds ?? { maxX: 0.5, maxZ: 0.5, minX: -0.5, minZ: -0.5 } +} + +function roofMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + placement: { + position: (node, sceneApi) => { + const bounds = getRoofFootprintBounds(node, sceneApi) + return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + MOVE_FRONT_OFFSET] + }, + }, + apply: (_node, position) => ({ position: [position[0], position[1], position[2]] }), + snapExtents: (node, sceneApi) => { + const bounds = getRoofFootprintBounds(node, sceneApi) + const width = Math.max(bounds.maxX - bounds.minX, MIN_ROOF_FOOTPRINT) + const depth = Math.max(bounds.maxZ - bounds.minZ, MIN_ROOF_FOOTPRINT) + const swap = Math.abs(Math.sin(node.rotation ?? 0)) > 0.9 + return [swap ? depth : width, swap ? width : depth] + }, + } +} + +const roofHandles: HandleDescriptor[] = [roofMoveHandle()] + /** * Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer` * + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` + @@ -43,6 +119,7 @@ export const roofDefinition: NodeDefinition = { }, parametrics: roofParametrics, + handles: roofHandles, floorplan: buildRoofFloorplan, renderer: { diff --git a/packages/nodes/src/roof/renderer.tsx b/packages/nodes/src/roof/renderer.tsx index a09878bd..dd68f359 100644 --- a/packages/nodes/src/roof/renderer.tsx +++ b/packages/nodes/src/roof/renderer.tsx @@ -5,6 +5,7 @@ import { hasSegmentMaterialOverride, type RoofNode, type RoofSegmentNode, + useLiveNodeOverrides, useRegistry, useScene, } from '@pascal-app/core' @@ -14,8 +15,13 @@ import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials' -export const RoofRenderer = ({ node }: { node: RoofNode }) => { +export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => { const ref = useRef(null!) + const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(rawNode.id)) + const node = useMemo( + () => (liveOverride ? ({ ...rawNode, ...liveOverride } as RoofNode) : rawNode), + [rawNode, liveOverride], + ) useRegistry(node.id, 'roof', ref) useLayoutEffect(() => { diff --git a/packages/nodes/src/shared/roof-segment-hit.ts b/packages/nodes/src/shared/roof-segment-hit.ts index 82b41790..262c847c 100644 --- a/packages/nodes/src/shared/roof-segment-hit.ts +++ b/packages/nodes/src/shared/roof-segment-hit.ts @@ -1,6 +1,6 @@ import { type AnyNodeId, - getActiveRoofHeight, + getRoofSegmentSurfaceY, type RoofNode, type RoofSegmentNode, sceneRegistry, @@ -17,40 +17,6 @@ export type RoofSegmentHit = { localZ: number } -/** - * Analytical surface Y for `seg` at segment-local (lx, lz). Mirrors - * the per-roof-type slope math in `shared/roof-surface.ts` so the - * disambiguator below stays free of cross-kind imports. Returns the - * roof's local surface height; the value is only used to compare - * candidates, never written to the scene. - */ -function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): number { - const rh = getActiveRoofHeight(seg) - const peakY = seg.wallHeight + rh - if (rh === 0) return seg.wallHeight - - if ( - seg.roofType === 'gable' || - seg.roofType === 'gambrel' || - seg.roofType === 'mansard' || - seg.roofType === 'dutch' - ) { - const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0 - return peakY - t * rh - } - if (seg.roofType === 'shed') { - const t = (lz + seg.depth / 2) / (seg.depth || 1) - return peakY - t * rh - } - if (seg.roofType === 'hip') { - const fx = seg.width > 0 ? Math.abs(lx) / (seg.width / 2) : 0 - const fz = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0 - return peakY - Math.max(fx, fz) * rh - } - const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0 - return peakY - t * rh -} - /** * Resolve which roof-segment the user clicked. Used by every placement * tool that drops a new node onto a roof (box-vent, ridge-vent, @@ -61,7 +27,7 @@ function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): numbe * point's (x, z) lies inside *every* segment's axis-aligned half- * extents, so a naive first-match returns the wrong slope (typically * segments[0]). We instead score each candidate by - * `|localY − analyticalSurfaceY(localX, localZ)|` and pick the + * `|localY − getRoofSegmentSurfaceY(localX, localZ)|` and pick the * smallest — the slope the user actually clicked is the one whose * sloped surface passes through the hit point. * @@ -101,7 +67,7 @@ export function resolveRoofSegmentHit( const halfW = seg.width / 2 + overhang const halfD = seg.depth / 2 + overhang if (Math.abs(local.x) <= halfW && Math.abs(local.z) <= halfD) { - const surfaceY = analyticalSurfaceY(seg, local.x, local.z) + const surfaceY = getRoofSegmentSurfaceY(seg, local.x, local.z) const score = Math.abs(local.y - surfaceY) if (!best || score < best.score) { best = { diff --git a/packages/nodes/src/shared/roof-surface.ts b/packages/nodes/src/shared/roof-surface.ts index 7e539308..9a1368b3 100644 --- a/packages/nodes/src/shared/roof-surface.ts +++ b/packages/nodes/src/shared/roof-surface.ts @@ -1,5 +1,5 @@ import { - getActiveRoofHeight, + getRoofSegmentSurfaceY, getSegmentSlopeFrame, ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, @@ -13,26 +13,7 @@ import * as THREE from 'three' // accessories don't reach across into a sibling kind for it. export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number { - const { roofType, wallHeight, depth, width } = seg - const rh = getActiveRoofHeight(seg) - const peakY = wallHeight + rh - if (rh === 0) return wallHeight - - if (roofType === 'gable') { - const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - t * rh - } - if (roofType === 'shed') { - const t = (lz + depth / 2) / (depth || 1) - return peakY - t * rh - } - if (roofType === 'hip') { - const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0 - const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - Math.max(fx, fz) * rh - } - const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - t * rh + return getRoofSegmentSurfaceY(seg, lx, lz) } // Outward normal for a roof surface tilting at angle θ in the horizontal diff --git a/packages/nodes/src/slab/__tests__/definition.test.ts b/packages/nodes/src/slab/__tests__/definition.test.ts new file mode 100644 index 00000000..710400d0 --- /dev/null +++ b/packages/nodes/src/slab/__tests__/definition.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test' +import { pointInPolygon2D, SlabNode } from '@pascal-app/core' +import { slabDefinition } from '../definition' + +function getHeightHandlePosition(slab: SlabNode) { + const handles = + typeof slabDefinition.handles === 'function' + ? slabDefinition.handles(slab) + : (slabDefinition.handles ?? []) + const heightHandle = handles.find( + (handle) => handle.kind === 'linear-resize' && handle.axis === 'y', + ) + if (!(heightHandle && heightHandle.kind === 'linear-resize')) { + throw new Error('Missing slab height handle') + } + return heightHandle.placement.position(slab, {} as never) +} + +describe('slabDefinition handles', () => { + test('keeps the height handle over solid slab area when the center is a hole', () => { + const slab = SlabNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + holes: [ + [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + ], + ], + }) + + const [x, , z] = getHeightHandlePosition(slab) + + expect(pointInPolygon2D([x, z], slab.polygon, { includeBoundary: false })).toBe(true) + expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false) + }) +}) diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 5f289b89..01d0b971 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -1,4 +1,9 @@ -import type { HandleDescriptor, NodeDefinition, SlabNode as SlabNodeType } from '@pascal-app/core' +import { + type HandleDescriptor, + type NodeDefinition, + pointInPolygon2D, + type SlabNode as SlabNodeType, +} from '@pascal-app/core' import { buildSlabFloorplan } from './floorplan' import { slabAddVertexAffordance, @@ -13,8 +18,7 @@ import { SlabNode } from './schema' const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_SLAB_ELEVATION = 0.02 -function slabPolygonCenter(n: SlabNodeType): [number, number] { - const polygon = n.polygon ?? [] +function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] { if (polygon.length === 0) return [0, 0] let cx = 0 let cz = 0 @@ -25,11 +29,68 @@ function slabPolygonCenter(n: SlabNodeType): [number, number] { return [cx / polygon.length, cz / polygon.length] } -// Slab height arrow — vertical chevron at the polygon centroid, just -// above the slab's top face. Drags elevation (the extrusion thickness) -// with `anchor: 'min'` so the bottom stays at world Y=0 and the top -// follows the pointer. Same registry-handle pipeline as the column -// height arrow, so live override + commit-on-release come for free. +function pointIsOnSolidSlab(point: [number, number], slab: SlabNodeType) { + if (!pointInPolygon2D(point, slab.polygon, { includeBoundary: false })) return false + return !(slab.holes ?? []).some( + (hole) => hole.length >= 3 && pointInPolygon2D(point, hole, { includeBoundary: true }), + ) +} + +function slabHandleAnchor(slab: SlabNodeType): [number, number] { + const polygon = slab.polygon ?? [] + const fallback = polygonVertexAverage(polygon) + if (polygon.length < 3) return fallback + if (pointIsOnSolidSlab(fallback, slab)) return fallback + + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + for (const [x, z] of polygon) { + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minZ = Math.min(minZ, z) + maxZ = Math.max(maxZ, z) + } + + const candidates: [number, number][] = [] + for (const point of polygon) { + candidates.push([ + fallback[0] + (point[0] - fallback[0]) * 0.35, + fallback[1] + (point[1] - fallback[1]) * 0.35, + ]) + } + + const steps = 12 + for (let xi = 1; xi < steps; xi += 1) { + const x = minX + ((maxX - minX) * xi) / steps + for (let zi = 1; zi < steps; zi += 1) { + const z = minZ + ((maxZ - minZ) * zi) / steps + candidates.push([x, z]) + } + } + + let best: [number, number] | null = null + let bestDistance = Number.POSITIVE_INFINITY + for (const candidate of candidates) { + if (!pointIsOnSolidSlab(candidate, slab)) continue + const dx = candidate[0] - fallback[0] + const dz = candidate[1] - fallback[1] + const distance = dx * dx + dz * dz + if (distance < bestDistance) { + best = candidate + bestDistance = distance + } + } + + return best ?? fallback +} + +// Slab height arrow — vertical chevron on solid slab surface near the +// polygon center. Drags elevation (the extrusion thickness) with +// `anchor: 'min'` so the bottom stays at world Y=0 and the top follows +// the pointer. Same registry-handle pipeline as the column height arrow, +// so live override + commit-on-release come for free. function slabHeightHandle(): HandleDescriptor { return { kind: 'linear-resize', @@ -40,7 +101,7 @@ function slabHeightHandle(): HandleDescriptor { apply: (_n, newValue) => ({ elevation: newValue }), placement: { position: (n) => { - const [cx, cz] = slabPolygonCenter(n) + const [cx, cz] = slabHandleAnchor(n) const elevation = n.elevation ?? 0.05 return [cx, elevation + HEIGHT_HANDLE_OFFSET, cz] }, diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 3ecdc505..1b9f2b92 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -18,9 +18,13 @@ import { ActionGroup, DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, duplicateStairSubtree, + getStairLevelOptions, MetricControl, PanelSection, PanelWrapper, + resolveStairDestinationLevel, + resolveStairFromLevelId, + resolveStairToLevelId, SegmentedControl, SliderControl, ToggleControl, @@ -29,7 +33,7 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ @@ -62,16 +66,14 @@ export default function StairPanel() { const updateNode = useScene((s) => s.updateNode) const createNode = useScene((s) => s.createNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const nodes = useScene((s) => s.nodes) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, ) - const levels = useScene( - useShallow((s) => - Object.values(s.nodes) - .filter((entry): entry is LevelNode => entry.type === 'level') - .sort((left, right) => left.level - right.level), - ), + const levels = useMemo( + () => (node?.type === 'stair' ? getStairLevelOptions(nodes, node) : []), + [node, nodes], ) const segments = useScene( useShallow((s) => { @@ -96,6 +98,41 @@ export default function StairPanel() { setSelection({ selectedIds: [] }) }, [setSelection]) + const handleAutoCutoutChange = useCallback( + (checked: boolean) => { + if (!node) return + const updates: Partial = { + slabOpeningMode: checked ? 'destination' : 'none', + } + const sceneNodes = useScene.getState().nodes + const fromLevelId = resolveStairFromLevelId(sceneNodes, node) + if (checked && fromLevelId) updates.fromLevelId = fromLevelId + if (checked && (!node.toLevelId || node.toLevelId === fromLevelId)) { + const plan = resolveStairDestinationLevel({ + fromLevelId, + nodes: sceneNodes, + }) + if (plan?.toLevel.id) updates.toLevelId = plan.toLevel.id + } + handleUpdate(updates) + }, + [node, handleUpdate], + ) + + const handleFromLevelChange = useCallback( + (fromLevelId: string) => { + const plan = resolveStairDestinationLevel({ + fromLevelId: fromLevelId as AnyNodeId, + nodes: useScene.getState().nodes, + }) + handleUpdate({ + fromLevelId, + toLevelId: plan?.toLevel.id ?? fromLevelId, + }) + }, + [handleUpdate], + ) + const getLastSegmentFillDefaults = useCallback(() => { if (!node) return { fillToFloor: true } const children = node.children ?? [] @@ -184,8 +221,8 @@ export default function StairPanel() { if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null - const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null - const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId + const resolvedFromLevelId = resolveStairFromLevelId(nodes, node, levels) + const resolvedToLevelId = resolveStairToLevelId(nodes, node, resolvedFromLevelId, levels) return ( - handleUpdate({ - slabOpeningMode: checked ? 'destination' : 'none', - }) - } + onChange={handleAutoCutoutChange} />
@@ -230,7 +263,7 @@ export default function StairPanel() {