From 762c9fc763a2b34a2c06c9ba81a131300d4ce48f Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 4 May 2026 23:06:23 +0530 Subject: [PATCH] Refactor guide events and column placement handling --- packages/core/src/events/bus.ts | 8 - .../src/components/editor/floorplan-panel.tsx | 13 +- .../components/tools/column/column-tool.tsx | 10 +- .../src/components/tools/tool-manager.tsx | 10 +- .../components/ui/panels/reference-panel.tsx | 8 +- packages/editor/src/lib/guide-events.ts | 10 + .../renderers/column/column-renderer.tsx | 189 +----------------- .../src/systems/column/column-geometry.ts | 186 +++++++++++++++++ 8 files changed, 224 insertions(+), 210 deletions(-) create mode 100644 packages/editor/src/lib/guide-events.ts create mode 100644 packages/viewer/src/systems/column/column-geometry.ts diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 8a5c0bf6..fa467e9d 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -7,7 +7,6 @@ import type { ColumnNode, DoorNode, FenceNode, - GuideNode, ItemNode, LevelNode, RoofNode, @@ -133,12 +132,6 @@ type ToolEvents = { 'tool:cancel': undefined } -type GuideEvents = { - 'guide:set-reference-scale': { guideId: GuideNode['id'] } - 'guide:cancel-reference-scale': undefined - 'guide:deleted': { guideId: GuideNode['id'] } -} - type PresetEvents = { 'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } @@ -180,7 +173,6 @@ type EditorEvents = GridEvents & NodeEvents<'door', DoorEvent> & CameraControlEvents & ToolEvents & - GuideEvents & PresetEvents & ThumbnailEvents & SnapshotEvents & diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index bf3b5f9c..9e20eee1 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -64,6 +64,7 @@ import { rotatePlanVector as rotateSharedPlanVector, type FloorplanNodeTransform as SharedFloorplanNodeTransform, } from '../../lib/floorplan' +import { guideEmitter } from '../../lib/guide-events' import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' @@ -9131,9 +9132,9 @@ export function FloorplanPanel() { } } - emitter.on('guide:set-reference-scale', handleSetReferenceScale) + guideEmitter.on('guide:set-reference-scale', handleSetReferenceScale) return () => { - emitter.off('guide:set-reference-scale', handleSetReferenceScale) + guideEmitter.off('guide:set-reference-scale', handleSetReferenceScale) } }, [startReferenceScaleForGuide]) @@ -9143,9 +9144,9 @@ export function FloorplanPanel() { setPendingReferenceScale(null) } - emitter.on('guide:cancel-reference-scale', handleCancel) + guideEmitter.on('guide:cancel-reference-scale', handleCancel) return () => { - emitter.off('guide:cancel-reference-scale', handleCancel) + guideEmitter.off('guide:cancel-reference-scale', handleCancel) } }, []) @@ -9160,9 +9161,9 @@ export function FloorplanPanel() { clearGuideUi(payload.guideId) } - emitter.on('guide:deleted', handleDeleted) + guideEmitter.on('guide:deleted', handleDeleted) return () => { - emitter.off('guide:deleted', handleDeleted) + guideEmitter.off('guide:deleted', handleDeleted) } }, [clearGuideUi]) diff --git a/packages/editor/src/components/tools/column/column-tool.tsx b/packages/editor/src/components/tools/column/column-tool.tsx index 9a239f10..e9593d1a 100644 --- a/packages/editor/src/components/tools/column/column-tool.tsx +++ b/packages/editor/src/components/tools/column/column-tool.tsx @@ -1,16 +1,15 @@ import '../../../three-types' import { - type AnyNode, COLUMN_PRESETS, ColumnNode, + type ColumnNode as ColumnNodeType, type ColumnPresetId, emitter, type GridEvent, type LevelNode, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef, useState } from 'react' import type { Group } from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -41,9 +40,10 @@ function createColumnFromPreset(presetId: ColumnPresetId, position: [number, num type ColumnToolProps = { currentLevelId: LevelNode['id'] | null + onPlaced?: (nodeId: ColumnNodeType['id']) => void } -export const ColumnTool: React.FC = ({ currentLevelId }) => { +export const ColumnTool: React.FC = ({ currentLevelId, onPlaced }) => { const [, setCursorPosition] = useState<[number, number, number] | null>(null) const cursorRef = useRef(null) @@ -68,7 +68,7 @@ export const ColumnTool: React.FC = ({ currentLevelId }) => { ] const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) useScene.getState().createNode(column, currentLevelId) - useViewer.getState().setSelection({ selectedIds: [column.id as AnyNode['id']] }) + onPlaced?.(column.id) sfxEmitter.emit('sfx:structure-build') useEditor.getState().setTool(null) useEditor.getState().setMode('select') @@ -81,7 +81,7 @@ export const ColumnTool: React.FC = ({ currentLevelId }) => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) } - }, [currentLevelId]) + }, [currentLevelId, onPlaced]) if (!currentLevelId) return null diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index a0205ae5..777c8d72 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -127,7 +127,7 @@ export const ToolManager: React.FC = () => { const showBuildTool = mode === 'build' && tool !== null const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null - const handleSpawnSelected = (nodeId: `spawn_${string}`) => { + const handlePlacedNodeSelected = (nodeId: AnyNodeId) => { setSelection({ selectedIds: [nodeId] }) } @@ -135,7 +135,7 @@ export const ToolManager: React.FC = () => { <> {showSiteBoundaryEditor && } {/* World-space tools: site boundary and building movement operate in world coordinates */} - {movingNode?.type === 'building' && } + {movingNode?.type === 'building' && } {/* Building-local group: all other tools are relative to the selected building. Cursor visuals set positions in building-local space; this group applies the @@ -160,13 +160,13 @@ export const ToolManager: React.FC = () => { {curvingWall && } {curvingFence && } {movingNode && movingNode.type !== 'building' && ( - + )} {!movingNode && showBuildTool && tool === 'spawn' && ( - + )} {!movingNode && showBuildTool && tool === 'column' && ( - + )} {!movingNode && BuildToolComponent && tool !== 'column' && } diff --git a/packages/editor/src/components/ui/panels/reference-panel.tsx b/packages/editor/src/components/ui/panels/reference-panel.tsx index 57b771c8..723f7627 100644 --- a/packages/editor/src/components/ui/panels/reference-panel.tsx +++ b/packages/editor/src/components/ui/panels/reference-panel.tsx @@ -2,7 +2,6 @@ import { type AnyNode, - emitter, type GuideNode, loadAssetUrl, saveAsset, @@ -11,6 +10,7 @@ import { } from '@pascal-app/core' import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' +import { guideEmitter } from '../../../lib/guide-events' import { getGuideImageName } from '../../../lib/local-guide-image' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' @@ -98,7 +98,7 @@ export function ReferencePanel() { } deleteNode(selectedReferenceId as AnyNode['id']) - emitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] }) + guideEmitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] }) clearGuideUi(selectedReferenceId) setSelectedReferenceId(null) }, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId]) @@ -108,11 +108,11 @@ export function ReferencePanel() { return } - emitter.emit('guide:set-reference-scale', { guideId: node.id }) + guideEmitter.emit('guide:set-reference-scale', { guideId: node.id }) }, [node]) const handleCancelScale = useCallback(() => { - emitter.emit('guide:cancel-reference-scale') + guideEmitter.emit('guide:cancel-reference-scale') }, []) useEffect(() => { diff --git a/packages/editor/src/lib/guide-events.ts b/packages/editor/src/lib/guide-events.ts new file mode 100644 index 00000000..40bd172f --- /dev/null +++ b/packages/editor/src/lib/guide-events.ts @@ -0,0 +1,10 @@ +import type { GuideNode } from '@pascal-app/core' +import mitt from 'mitt' + +type GuideEditorEvents = { + 'guide:set-reference-scale': { guideId: GuideNode['id'] } + 'guide:cancel-reference-scale': undefined + 'guide:deleted': { guideId: GuideNode['id'] } +} + +export const guideEmitter = mitt() diff --git a/packages/viewer/src/components/renderers/column/column-renderer.tsx b/packages/viewer/src/components/renderers/column/column-renderer.tsx index 28ad8dde..d80b89cf 100644 --- a/packages/viewer/src/components/renderers/column/column-renderer.tsx +++ b/packages/viewer/src/components/renderers/column/column-renderer.tsx @@ -1,198 +1,23 @@ import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' import { createContext, useContext, useMemo, useRef } from 'react' -import { - BoxGeometry, - type BufferGeometry, - CylinderGeometry, - Float32BufferAttribute, - type Group, - type Material, - SphereGeometry, - TorusGeometry, -} from 'three' -import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' +import type { Group, Material } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' +import { + createColumnBoxGeometry, + createColumnCylinderGeometry, + createColumnSphereGeometry, + createColumnTorusGeometry, +} from '../../../systems/column/column-geometry' const ColumnMaterialContext = createContext(baseMaterial as Material) const ColumnEdgeSoftnessContext = createContext(0.025) -const COLUMN_UV_SCALE = 1 function ColumnMaterial() { const material = useContext(ColumnMaterialContext) return } -function setUvAttributes(geometry: BufferGeometry, uvs: number[]) { - geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) - geometry.setAttribute('uv2', new Float32BufferAttribute(uvs.slice(), 2)) - return geometry -} - -function toUvReadyGeometry(geometry: BufferGeometry) { - return geometry.index ? geometry.toNonIndexed() : geometry -} - -function applyPlanarColumnUvs(geometry: BufferGeometry) { - const mappedGeometry = toUvReadyGeometry(geometry) - const positions = mappedGeometry.getAttribute('position') - const normals = mappedGeometry.getAttribute('normal') - const uvs: number[] = [] - - for (let index = 0; index < positions.count; index += 1) { - const x = positions.getX(index) - const y = positions.getY(index) - const z = positions.getZ(index) - const normalX = normals ? Math.abs(normals.getX(index)) : 0 - const normalY = normals ? Math.abs(normals.getY(index)) : 1 - const normalZ = normals ? Math.abs(normals.getZ(index)) : 0 - - if (normalY >= normalX && normalY >= normalZ) { - uvs.push(x * COLUMN_UV_SCALE, z * COLUMN_UV_SCALE) - } else if (normalX >= normalZ) { - uvs.push(z * COLUMN_UV_SCALE, y * COLUMN_UV_SCALE) - } else { - uvs.push(x * COLUMN_UV_SCALE, y * COLUMN_UV_SCALE) - } - } - - return setUvAttributes(mappedGeometry, uvs) -} - -function ellipseCircumference(radiusX: number, radiusZ: number) { - const a = Math.max(0.001, Math.abs(radiusX)) - const b = Math.max(0.001, Math.abs(radiusZ)) - return Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b))) -} - -function applyCylindricalColumnUvs( - geometry: BufferGeometry, - sideCircumference: number, - height: number, -) { - const mappedGeometry = toUvReadyGeometry(geometry) - const positions = mappedGeometry.getAttribute('position') - const normals = mappedGeometry.getAttribute('normal') - const defaultUvs = mappedGeometry.getAttribute('uv') - const halfHeight = height / 2 - const uvs: number[] = [] - - for (let index = 0; index < positions.count; index += 1) { - const x = positions.getX(index) - const y = positions.getY(index) - const z = positions.getZ(index) - const normalY = normals ? Math.abs(normals.getY(index)) : 0 - - if (normalY > 0.65) { - uvs.push(x * COLUMN_UV_SCALE, z * COLUMN_UV_SCALE) - } else { - const defaultU = defaultUvs ? defaultUvs.getX(index) : 0 - uvs.push(defaultU * sideCircumference * COLUMN_UV_SCALE, (y + halfHeight) * COLUMN_UV_SCALE) - } - } - - return setUvAttributes(mappedGeometry, uvs) -} - -function applySphericalColumnUvs(geometry: BufferGeometry, radius: number) { - const mappedGeometry = toUvReadyGeometry(geometry) - const defaultUvs = mappedGeometry.getAttribute('uv') - if (!defaultUvs) return mappedGeometry - - const uvs: number[] = [] - const circumference = Math.PI * 2 * radius - const arcHeight = Math.PI * radius - - for (let index = 0; index < defaultUvs.count; index += 1) { - uvs.push( - defaultUvs.getX(index) * circumference * COLUMN_UV_SCALE, - defaultUvs.getY(index) * arcHeight * COLUMN_UV_SCALE, - ) - } - - return setUvAttributes(mappedGeometry, uvs) -} - -function applyTorusColumnUvs(geometry: BufferGeometry, ringRadius: number, tubeRadius: number) { - const mappedGeometry = toUvReadyGeometry(geometry) - const defaultUvs = mappedGeometry.getAttribute('uv') - if (!defaultUvs) return mappedGeometry - - const uvs: number[] = [] - const ringLength = Math.PI * 2 * Math.max(0.001, ringRadius) - const tubeLength = Math.PI * 2 * Math.max(0.001, tubeRadius) - - for (let index = 0; index < defaultUvs.count; index += 1) { - uvs.push( - defaultUvs.getX(index) * ringLength * COLUMN_UV_SCALE, - defaultUvs.getY(index) * tubeLength * COLUMN_UV_SCALE, - ) - } - - return setUvAttributes(mappedGeometry, uvs) -} - -function createColumnBoxGeometry(width: number, height: number, depth: number, bevelRadius = 0) { - const geometry = - bevelRadius > 0.001 - ? new RoundedBoxGeometry(width, height, depth, 3, bevelRadius) - : new BoxGeometry(width, height, depth) - return applyPlanarColumnUvs(geometry) -} - -function createColumnCylinderGeometry({ - height, - radiusBottom, - radiusTop = radiusBottom, - radiusX = 1, - radiusZ = 1, - segments = 32, -}: { - height: number - radiusBottom: number - radiusTop?: number - radiusX?: number - radiusZ?: number - segments?: number -}) { - const geometry = new CylinderGeometry(radiusTop, radiusBottom, height, segments) - geometry.scale(radiusX, 1, radiusZ) - const sideRadius = Math.max(radiusTop, radiusBottom) - return applyCylindricalColumnUvs( - geometry, - ellipseCircumference(sideRadius * radiusX, sideRadius * radiusZ), - height, - ) -} - -function createColumnSphereGeometry(radius: number, widthSegments = 10, heightSegments = 8) { - return applySphericalColumnUvs(new SphereGeometry(radius, widthSegments, heightSegments), radius) -} - -function createColumnTorusGeometry({ - arc = Math.PI * 2, - radialSegments = 10, - ringRadius, - scaleX = ringRadius, - scaleY = ringRadius, - scaleZ = 1, - tubeRadius, - tubularSegments = 24, -}: { - arc?: number - radialSegments?: number - ringRadius: number - scaleX?: number - scaleY?: number - scaleZ?: number - tubeRadius: number - tubularSegments?: number -}) { - const geometry = new TorusGeometry(1, 0.18, radialSegments, tubularSegments, arc) - geometry.scale(scaleX, scaleY, scaleZ) - return applyTorusColumnUvs(geometry, ringRadius, tubeRadius) -} - function createColumnMaterial({ material, materialPreset, diff --git a/packages/viewer/src/systems/column/column-geometry.ts b/packages/viewer/src/systems/column/column-geometry.ts new file mode 100644 index 00000000..3319cfd6 --- /dev/null +++ b/packages/viewer/src/systems/column/column-geometry.ts @@ -0,0 +1,186 @@ +import { + BoxGeometry, + type BufferGeometry, + CylinderGeometry, + Float32BufferAttribute, + SphereGeometry, + TorusGeometry, +} from 'three' +import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' + +const COLUMN_UV_SCALE = 1 + +function setUvAttributes(geometry: BufferGeometry, uvs: number[]) { + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) + geometry.setAttribute('uv2', new Float32BufferAttribute(uvs.slice(), 2)) + return geometry +} + +function toUvReadyGeometry(geometry: BufferGeometry) { + return geometry.index ? geometry.toNonIndexed() : geometry +} + +function applyPlanarColumnUvs(geometry: BufferGeometry) { + const mappedGeometry = toUvReadyGeometry(geometry) + const positions = mappedGeometry.getAttribute('position') + const normals = mappedGeometry.getAttribute('normal') + const uvs: number[] = [] + + for (let index = 0; index < positions.count; index += 1) { + const x = positions.getX(index) + const y = positions.getY(index) + const z = positions.getZ(index) + const normalX = normals ? Math.abs(normals.getX(index)) : 0 + const normalY = normals ? Math.abs(normals.getY(index)) : 1 + const normalZ = normals ? Math.abs(normals.getZ(index)) : 0 + + if (normalY >= normalX && normalY >= normalZ) { + uvs.push(x * COLUMN_UV_SCALE, z * COLUMN_UV_SCALE) + } else if (normalX >= normalZ) { + uvs.push(z * COLUMN_UV_SCALE, y * COLUMN_UV_SCALE) + } else { + uvs.push(x * COLUMN_UV_SCALE, y * COLUMN_UV_SCALE) + } + } + + return setUvAttributes(mappedGeometry, uvs) +} + +function ellipseCircumference(radiusX: number, radiusZ: number) { + const a = Math.max(0.001, Math.abs(radiusX)) + const b = Math.max(0.001, Math.abs(radiusZ)) + return Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b))) +} + +function applyCylindricalColumnUvs( + geometry: BufferGeometry, + sideCircumference: number, + height: number, +) { + const mappedGeometry = toUvReadyGeometry(geometry) + const positions = mappedGeometry.getAttribute('position') + const normals = mappedGeometry.getAttribute('normal') + const defaultUvs = mappedGeometry.getAttribute('uv') + const halfHeight = height / 2 + const uvs: number[] = [] + + for (let index = 0; index < positions.count; index += 1) { + const x = positions.getX(index) + const y = positions.getY(index) + const z = positions.getZ(index) + const normalY = normals ? Math.abs(normals.getY(index)) : 0 + + if (normalY > 0.65) { + uvs.push(x * COLUMN_UV_SCALE, z * COLUMN_UV_SCALE) + } else { + const defaultU = defaultUvs ? defaultUvs.getX(index) : 0 + uvs.push(defaultU * sideCircumference * COLUMN_UV_SCALE, (y + halfHeight) * COLUMN_UV_SCALE) + } + } + + return setUvAttributes(mappedGeometry, uvs) +} + +function applySphericalColumnUvs(geometry: BufferGeometry, radius: number) { + const mappedGeometry = toUvReadyGeometry(geometry) + const defaultUvs = mappedGeometry.getAttribute('uv') + if (!defaultUvs) return mappedGeometry + + const uvs: number[] = [] + const circumference = Math.PI * 2 * radius + const arcHeight = Math.PI * radius + + for (let index = 0; index < defaultUvs.count; index += 1) { + uvs.push( + defaultUvs.getX(index) * circumference * COLUMN_UV_SCALE, + defaultUvs.getY(index) * arcHeight * COLUMN_UV_SCALE, + ) + } + + return setUvAttributes(mappedGeometry, uvs) +} + +function applyTorusColumnUvs(geometry: BufferGeometry, ringRadius: number, tubeRadius: number) { + const mappedGeometry = toUvReadyGeometry(geometry) + const defaultUvs = mappedGeometry.getAttribute('uv') + if (!defaultUvs) return mappedGeometry + + const uvs: number[] = [] + const ringLength = Math.PI * 2 * Math.max(0.001, ringRadius) + const tubeLength = Math.PI * 2 * Math.max(0.001, tubeRadius) + + for (let index = 0; index < defaultUvs.count; index += 1) { + uvs.push( + defaultUvs.getX(index) * ringLength * COLUMN_UV_SCALE, + defaultUvs.getY(index) * tubeLength * COLUMN_UV_SCALE, + ) + } + + return setUvAttributes(mappedGeometry, uvs) +} + +export function createColumnBoxGeometry( + width: number, + height: number, + depth: number, + bevelRadius = 0, +) { + const geometry = + bevelRadius > 0.001 + ? new RoundedBoxGeometry(width, height, depth, 3, bevelRadius) + : new BoxGeometry(width, height, depth) + return applyPlanarColumnUvs(geometry) +} + +export function createColumnCylinderGeometry({ + height, + radiusBottom, + radiusTop = radiusBottom, + radiusX = 1, + radiusZ = 1, + segments = 32, +}: { + height: number + radiusBottom: number + radiusTop?: number + radiusX?: number + radiusZ?: number + segments?: number +}) { + const geometry = new CylinderGeometry(radiusTop, radiusBottom, height, segments) + geometry.scale(radiusX, 1, radiusZ) + const sideRadius = Math.max(radiusTop, radiusBottom) + return applyCylindricalColumnUvs( + geometry, + ellipseCircumference(sideRadius * radiusX, sideRadius * radiusZ), + height, + ) +} + +export function createColumnSphereGeometry(radius: number, widthSegments = 10, heightSegments = 8) { + return applySphericalColumnUvs(new SphereGeometry(radius, widthSegments, heightSegments), radius) +} + +export function createColumnTorusGeometry({ + arc = Math.PI * 2, + radialSegments = 10, + ringRadius, + scaleX = ringRadius, + scaleY = ringRadius, + scaleZ = 1, + tubeRadius, + tubularSegments = 24, +}: { + arc?: number + radialSegments?: number + ringRadius: number + scaleX?: number + scaleY?: number + scaleZ?: number + tubeRadius: number + tubularSegments?: number +}) { + const geometry = new TorusGeometry(1, 0.18, radialSegments, tubularSegments, arc) + geometry.scale(scaleX, scaleY, scaleZ) + return applyTorusColumnUvs(geometry, ringRadius, tubeRadius) +}