Merge branch 'main' into feat/color-picker-tool

This commit is contained in:
Sudhir Yadav
2026-04-22 17:57:26 +05:30
committed by GitHub
31 changed files with 1104 additions and 149 deletions
+59
View File
@@ -0,0 +1,59 @@
# Changelog
## 0.6.0 (2026-04-21)
### Features
- **Multi-surface material system** — per-surface materials for walls, stairs, roofs with click-targeted 3D editing ([#266](https://github.com/pascalorg/editor/pull/266)) by [@sudhir9297](https://github.com/sudhir9297)
- **Automatic wall-room generation** — closed wall loops auto-split and generate slabs ([#255](https://github.com/pascalorg/editor/pull/255), [#257](https://github.com/pascalorg/editor/pull/257)) by [@sudhir9297](https://github.com/sudhir9297)
- **Stair-slab integration** — stair-driven cutouts in slabs and ceilings, auto ceilings from wall loops
- **Curved fence support** + endpoint move tools ([#267](https://github.com/pascalorg/editor/pull/267)) by [@sudhir9297](https://github.com/sudhir9297)
- **13 material presets** — granite, marble, parquet, wallpaper, wood and more ([#231](https://github.com/pascalorg/editor/pull/231)) by [@sudhir9297](https://github.com/sudhir9297)
- **Export scene system** — GLB, STL, OBJ formats ([#203](https://github.com/pascalorg/editor/pull/203)) by [@zephran-dev](https://github.com/zephran-dev), with STL/OBJ groundwork by [@mvanhorn](https://github.com/mvanhorn) ([#175](https://github.com/pascalorg/editor/pull/175))
- **Street view / walkthrough mode** ([#173](https://github.com/pascalorg/editor/pull/173)) by [@Yashism](https://github.com/Yashism)
- **Duplicate project** ([#178](https://github.com/pascalorg/editor/pull/178)) by [@kleenkanteen](https://github.com/kleenkanteen)
- **Editable wall length slider** ([#195](https://github.com/pascalorg/editor/pull/195)) by [@zephran-dev](https://github.com/zephran-dev)
- **Infinity dragging slider** using PointerLock API ([#206](https://github.com/pascalorg/editor/pull/206)) by [@claygeo](https://github.com/claygeo)
- **Material system enhancements** ([#201](https://github.com/pascalorg/editor/pull/201)) by [@PMAT77](https://github.com/PMAT77)
- **Editor layout redesign v2** + 3D box select
- **Move/rotate building** + relative positioning for all tools
- **Grid snap toolbar controls**
- **Cut-out button** in floating action menu for slabs and ceilings
### Fixes
- **WebGPU renderer** — await `renderer.init()` in Canvas GL factory ([#233](https://github.com/pascalorg/editor/pull/233)) by [@b9llach](https://github.com/b9llach)
- **WebGPU fallback** — skip post-processing when unavailable ([#234](https://github.com/pascalorg/editor/pull/234)) by [@b9llach](https://github.com/b9llach)
- **Crash on mode switch** — fix crash when switching to Furniture mode ([#237](https://github.com/pascalorg/editor/pull/237)) by [@txhno](https://github.com/txhno)
- **Crash on duplicate** — prevent crash when duplicating elements ([#239](https://github.com/pascalorg/editor/pull/239)) by [@nnhhoang](https://github.com/nnhhoang)
- **Delete walls/slabs** via floating action menu ([#180](https://github.com/pascalorg/editor/pull/180)) by [@nnhhoang](https://github.com/nnhhoang)
- **Counter-clockwise rotation** — T key for CCW rotation on selected nodes ([#184](https://github.com/pascalorg/editor/pull/184)) by [@nnhhoang](https://github.com/nnhhoang)
- **Scene singleton cleanup** — release singletons on Editor unmount ([#214](https://github.com/pascalorg/editor/pull/214)) by [@geopenta](https://github.com/geopenta)
- **State management & memory leaks** ([#152](https://github.com/pascalorg/editor/pull/152)) by [@hobostay](https://github.com/hobostay)
- **Ghost wall prevention** — use WALL_MIN_LENGTH constant ([#168](https://github.com/pascalorg/editor/pull/168)) by [@zephran-dev](https://github.com/zephran-dev)
- **Catalog image optimization** — add sizes and loading props ([#189](https://github.com/pascalorg/editor/pull/189)) by [@korvixhq](https://github.com/korvixhq)
- **Code cleanup** — remove unused `@ts-expect-error` directive ([#150](https://github.com/pascalorg/editor/pull/150)) by [@cs68614-hash](https://github.com/cs68614-hash)
- Robust undo/redo with nested history pause/resume
- Post-processing recovery after duplicate scene mutations
- Improved snapping across all geometry types
- Thumbnails, placement, and responsiveness improvements
- Stair elevation sync with floor slabs
### Contributors
A huge thank you to everyone who contributed to this release! 🎉
- [@sudhir9297](https://github.com/sudhir9297) — material system, wall-room generation, curved walls, stairs, fences (7 PRs!)
- [@zephran-dev](https://github.com/zephran-dev) — export system, wall length slider, ghost wall fix
- [@nnhhoang](https://github.com/nnhhoang) — rotation controls, delete actions, crash fix
- [@b9llach](https://github.com/b9llach) — WebGPU renderer fixes
- [@txhno](https://github.com/txhno) — furniture mode crash fix
- [@Yashism](https://github.com/Yashism) — street view / walkthrough mode
- [@claygeo](https://github.com/claygeo) — infinity dragging slider
- [@geopenta](https://github.com/geopenta) — scene singleton cleanup
- [@kleenkanteen](https://github.com/kleenkanteen) — duplicate project feature
- [@mvanhorn](https://github.com/mvanhorn) — STL/OBJ export formats
- [@PMAT77](https://github.com/PMAT77) — material system enhancements
- [@korvixhq](https://github.com/korvixhq) — catalog image optimization
- [@hobostay](https://github.com/hobostay) — state management & memory leak fixes
- [@cs68614-hash](https://github.com/cs68614-hash) — code cleanup
+8 -8
View File
@@ -48,7 +48,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@pascal-app/core", "name": "@pascal-app/core",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"dedent": "^1.7.1", "dedent": "^1.7.1",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
@@ -75,7 +75,7 @@
}, },
"packages/editor": { "packages/editor": {
"name": "@pascal-app/editor", "name": "@pascal-app/editor",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14", "@number-flow/react": "^0.5.14",
@@ -106,8 +106,8 @@
"zustand": "^5.0.11", "zustand": "^5.0.11",
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.5.1", "@pascal-app/viewer": "^0.6.0",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/node": "^22.19.12", "@types/node": "^22.19.12",
@@ -117,8 +117,8 @@
"typescript": "5.9.3", "typescript": "5.9.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.5.1", "@pascal-app/viewer": "^0.6.0",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"next": ">=15", "next": ">=15",
@@ -167,7 +167,7 @@
}, },
"packages/viewer": { "packages/viewer": {
"name": "@pascal-app/viewer", "name": "@pascal-app/viewer",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"polygon-clipping": "^0.15.7", "polygon-clipping": "^0.15.7",
"zustand": "^5", "zustand": "^5",
@@ -180,7 +180,7 @@
"typescript": "5.9.3", "typescript": "5.9.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/core", "name": "@pascal-app/core",
"version": "0.5.1", "version": "0.6.0",
"description": "Core library for Pascal 3D building editor", "description": "Core library for Pascal 3D building editor",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
+6
View File
@@ -54,6 +54,12 @@ export {
type ItemInteractiveState, type ItemInteractiveState,
useInteractive, useInteractive,
} from './store/use-interactive' } from './store/use-interactive'
export {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resetSceneHistoryPauseDepth,
resumeSceneHistory,
} from './store/history-control'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { FenceSystem } from './systems/fence/fence-system' export { FenceSystem } from './systems/fence/fence-system'
export { clearSceneHistory, default as useScene } from './store/use-scene' export { clearSceneHistory, default as useScene } from './store/use-scene'
+8 -2
View File
@@ -4,6 +4,11 @@ import {
isCurvedWall, isCurvedWall,
} from '../systems/wall/wall-curve' } from '../systems/wall/wall-curve'
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema' import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema'
import {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resumeSceneHistory,
} from '../store/history-control'
import { simplifyClosedPolygon } from './polygon-geometry' import { simplifyClosedPolygon } from './polygon-geometry'
type Point2D = { x: number; y: number } type Point2D = { x: number; y: number }
@@ -855,6 +860,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
const unsubscribe = sceneStore.subscribe((state: any) => { const unsubscribe = sceneStore.subscribe((state: any) => {
if (isProcessing) return if (isProcessing) return
if (getSceneHistoryPauseDepth() > 0) return
const nodes = state.nodes const nodes = state.nodes
const wallsByLevel = new Map<string, WallNode[]>() const wallsByLevel = new Map<string, WallNode[]>()
@@ -889,11 +895,11 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
} }
isProcessing = true isProcessing = true
sceneStore.temporal.getState().pause() pauseSceneHistory(sceneStore)
try { try {
runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes) runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes)
} finally { } finally {
sceneStore.temporal.getState().resume() resumeSceneHistory(sceneStore)
previousSnapshots.clear() previousSnapshots.clear()
for (const [levelId, snapshot] of currentSnapshots.entries()) { for (const [levelId, snapshot] of currentSnapshots.entries()) {
previousSnapshots.set(levelId, snapshot) previousSnapshots.set(levelId, snapshot)
+2
View File
@@ -13,6 +13,7 @@ export const FenceNode = BaseNode.extend({
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
start: z.tuple([z.number(), z.number()]), start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]),
curveOffset: z.number().optional(),
height: z.number().default(1.8), height: z.number().default(1.8),
thickness: z.number().default(0.08), thickness: z.number().default(0.08),
baseHeight: z.number().default(0.22), baseHeight: z.number().default(0.22),
@@ -28,6 +29,7 @@ export const FenceNode = BaseNode.extend({
dedent` dedent`
Fence node - used to represent a fence segment in the building/site level coordinate system Fence node - used to represent a fence segment in the building/site level coordinate system
- start/end: fence endpoints in level coordinate system - start/end: fence endpoints in level coordinate system
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
- height/thickness: overall fence dimensions in meters - height/thickness: overall fence dimensions in meters
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration - groundClearance/edgeInset/baseStyle: fence support and inset configuration
@@ -0,0 +1,36 @@
let sceneHistoryPauseDepth = 0
type TemporalStoreLike = {
temporal: {
getState(): {
pause(): void
resume(): void
}
}
}
export function pauseSceneHistory(sceneStore: TemporalStoreLike): void {
if (sceneHistoryPauseDepth === 0) {
sceneStore.temporal.getState().pause()
}
sceneHistoryPauseDepth += 1
}
export function resumeSceneHistory(sceneStore: TemporalStoreLike): void {
if (sceneHistoryPauseDepth === 0) {
return
}
sceneHistoryPauseDepth -= 1
if (sceneHistoryPauseDepth === 0) {
sceneStore.temporal.getState().resume()
}
}
export function getSceneHistoryPauseDepth(): number {
return sceneHistoryPauseDepth
}
export function resetSceneHistoryPauseDepth(): void {
sceneHistoryPauseDepth = 0
}
+5 -2
View File
@@ -11,6 +11,7 @@ import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import { resetSceneHistoryPauseDepth } from './history-control'
import * as nodeActions from './actions/node-actions' import * as nodeActions from './actions/node-actions'
function getFiniteNumber(value: unknown, fallback: number) { function getFiniteNumber(value: unknown, fallback: number) {
@@ -630,6 +631,7 @@ let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
export function clearSceneHistory() { export function clearSceneHistory() {
useScene.temporal.getState().clear() useScene.temporal.getState().clear()
resetSceneHistoryPauseDepth()
prevPastLength = 0 prevPastLength = 0
prevFutureLength = 0 prevFutureLength = 0
prevNodesSnapshot = null prevNodesSnapshot = null
@@ -649,8 +651,9 @@ useScene.temporal.subscribe((state) => {
// Capture the previous snapshot before RAF fires // Capture the previous snapshot before RAF fires
const snapshotBefore = prevNodesSnapshot const snapshotBefore = prevNodesSnapshot
// Use RAF to ensure all middleware and store updates are complete // Defer to a microtask so the scene store has settled before we diff,
requestAnimationFrame(() => { // but still mark walls/items dirty before the next paint.
queueMicrotask(() => {
const currentNodes = useScene.getState().nodes const currentNodes = useScene.getState().nodes
const { markDirty } = useScene.getState() const { markDirty } = useScene.getState()
+128 -42
View File
@@ -4,12 +4,90 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, FenceNode } from '../../schema' import type { AnyNodeId, FenceNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve'
type FencePart = { type FencePart = {
position: [number, number, number] position: [number, number, number]
rotationY?: number
scale: [number, number, number] scale: [number, number, number]
} }
const MIN_CURVE_SEGMENT_LENGTH = 0.18
function createFencePartGeometry(part: FencePart) {
const geometry = new THREE.BoxGeometry(1, 1, 1)
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
if (part.rotationY) {
geometry.rotateY(part.rotationY)
}
geometry.translate(part.position[0], part.position[1], part.position[2])
applyFenceUVs(geometry)
return geometry
}
function getFencePointAt(fence: FenceNode, t: number) {
const frame = getWallCurveFrameAt(fence, t)
return {
point: frame.point,
tangentAngle: Math.atan2(frame.tangent.y, frame.tangent.x),
}
}
function createStraightFenceSpanPart(
start: [number, number],
end: [number, number],
centerY: number,
height: number,
depth: number,
): FencePart | null {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length <= 1e-4) {
return null
}
return {
position: [(start[0] + end[0]) / 2, centerY, (start[1] + end[1]) / 2],
rotationY: -Math.atan2(dz, dx),
scale: [length, height, depth],
}
}
function createFenceCurveSpanParts(
fence: FenceNode,
startT: number,
endT: number,
centerY: number,
height: number,
depth: number,
): FencePart[] {
const parts: FencePart[] = []
const frameCount = Math.max(
1,
Math.ceil((getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH),
)
let previous = getFencePointAt(fence, startT)
for (let index = 1; index <= frameCount; index += 1) {
const t = startT + (endT - startT) * (index / frameCount)
const current = getFencePointAt(fence, t)
const segment = createStraightFenceSpanPart(
[previous.point.x, previous.point.y],
[current.point.x, current.point.y],
centerY,
height,
depth,
)
if (segment) {
parts.push(segment)
}
previous = current
}
return parts
}
function applyFenceUVs(geometry: THREE.BufferGeometry) { function applyFenceUVs(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position') const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal') const normal = geometry.getAttribute('normal')
@@ -71,10 +149,7 @@ function getStyleDefaults(style: FenceNode['style']) {
function createFenceParts(fence: FenceNode): FencePart[] { function createFenceParts(fence: FenceNode): FencePart[] {
const parts: FencePart[] = [] const parts: FencePart[] = []
const length = Math.max( const length = Math.max(getWallCurveLength(fence), 0.01)
Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]),
0.01,
)
const panelDepth = Math.max(fence.thickness, 0.03) const panelDepth = Math.max(fence.thickness, 0.03)
const clearance = Math.max(fence.groundClearance, 0) const clearance = Math.max(fence.groundClearance, 0)
const styleDefaults = getStyleDefaults(fence.style) const styleDefaults = getStyleDefaults(fence.style)
@@ -87,31 +162,39 @@ function createFenceParts(fence: FenceNode): FencePart[] {
const isFloating = fence.baseStyle === 'floating' const isFloating = fence.baseStyle === 'floating'
const baseY = isFloating ? clearance : 0 const baseY = isFloating ? clearance : 0
const effectiveBaseHeight = baseHeight const effectiveBaseHeight = baseHeight
const startInsetT = Math.min(0.499, edgeInset / length)
const endInsetT = Math.max(0.501, 1 - edgeInset / length)
if (!isFloating) { if (!isFloating) {
parts.push({ parts.push(
position: [0, baseY + effectiveBaseHeight / 2, 0], ...createFenceCurveSpanParts(
scale: [length, effectiveBaseHeight, panelDepth * 1.05], fence,
}) 0,
parts.push({ 1,
position: [0, baseY + effectiveBaseHeight + verticalHeight * 0.15, 0], baseY + effectiveBaseHeight / 2,
scale: [length, topRailHeight * 0.8, panelDepth * 0.35], effectiveBaseHeight,
}) panelDepth * 1.05,
),
)
parts.push(
...createFenceCurveSpanParts(
fence,
0,
1,
baseY + effectiveBaseHeight + verticalHeight * 0.15,
topRailHeight * 0.8,
panelDepth * 0.35,
),
)
} }
const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1) const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1)
const step = count > 1 ? (length - edgeInset * 2) / (count - 1) : 0
const startX = -length / 2 + edgeInset
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2 const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
const x = count === 1 ? 0 : startX + step * index const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
let posX = x const frame = getFencePointAt(fence, t)
const isEdgePost = index === 0 || index === count - 1 const isEdgePost = index === 0 || index === count - 1
if (count > 1) {
if (index === 0) posX = -length / 2 + edgeInset + postWidth / 2
else if (index === count - 1) posX = length / 2 - edgeInset - postWidth / 2
}
const postHeight = const postHeight =
isFloating && isEdgePost isFloating && isEdgePost
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance ? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
@@ -119,21 +202,34 @@ function createFenceParts(fence: FenceNode): FencePart[] {
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
parts.push({ parts.push({
position: [posX, postY, 0], position: [frame.point.x, postY, frame.point.y],
rotationY: -frame.tangentAngle,
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)], scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
}) })
} }
parts.push({ parts.push(
position: [0, baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2, 0], ...createFenceCurveSpanParts(
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)], fence,
}) 0,
1,
baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2,
topRailHeight,
Math.max(panelDepth * 0.55, 0.018),
),
)
if (isFloating) { if (isFloating) {
parts.push({ parts.push(
position: [0, baseY + effectiveBaseHeight + topRailHeight / 2, 0], ...createFenceCurveSpanParts(
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)], fence,
}) 0,
1,
baseY + effectiveBaseHeight + topRailHeight / 2,
topRailHeight,
Math.max(panelDepth * 0.55, 0.018),
),
)
} }
return parts return parts
@@ -141,13 +237,7 @@ function createFenceParts(fence: FenceNode): FencePart[] {
function generateFenceGeometry(fence: FenceNode) { function generateFenceGeometry(fence: FenceNode) {
const parts = createFenceParts(fence) const parts = createFenceParts(fence)
const geometries = parts.map((part) => { const geometries = parts.map(createFencePartGeometry)
const geometry = new THREE.BoxGeometry(1, 1, 1)
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
applyFenceUVs(geometry)
geometry.translate(part.position[0], part.position[1], part.position[2])
return geometry
})
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry() const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
geometries.forEach((geometry) => geometry.dispose()) geometries.forEach((geometry) => geometry.dispose())
@@ -169,12 +259,8 @@ function updateFenceGeometry(fenceId: FenceNode['id']) {
const newGeometry = generateFenceGeometry(node) const newGeometry = generateFenceGeometry(node)
mesh.geometry.dispose() mesh.geometry.dispose()
mesh.geometry = newGeometry mesh.geometry = newGeometry
mesh.position.set(0, 0, 0)
const centerX = (node.start[0] + node.end[0]) / 2 mesh.rotation.set(0, 0, 0)
const centerZ = (node.start[1] + node.end[1]) / 2
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
mesh.position.set(centerX, 0, centerZ)
mesh.rotation.set(0, -angle, 0)
} }
export const FenceSystem = () => { export const FenceSystem = () => {
+3 -3
View File
@@ -1,10 +1,10 @@
import type { Point2D } from './wall-mitering' import type { Point2D } from './wall-mitering'
import type { WallNode } from '../../schema' import type { FenceNode, WallNode } from '../../schema'
const CURVE_EPSILON = 1e-6 const CURVE_EPSILON = 1e-6
const DEFAULT_SAMPLE_SEGMENTS = 24 const DEFAULT_SAMPLE_SEGMENTS = 24
type WallCurveLike = Pick<WallNode, 'start' | 'end' | 'curveOffset'> type WallCurveLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
type CurveFrame = { type CurveFrame = {
point: Point2D point: Point2D
@@ -198,7 +198,7 @@ export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPL
} }
export function getWallSurfacePolygon( export function getWallSurfacePolygon(
wall: Pick<WallNode, 'start' | 'end' | 'curveOffset' | 'thickness'>, wall: Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
segments = DEFAULT_SAMPLE_SEGMENTS, segments = DEFAULT_SAMPLE_SEGMENTS,
miterOverrides?: WallSurfaceMiterOverrides, miterOverrides?: WallSurfaceMiterOverrides,
) { ) {
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/editor", "name": "@pascal-app/editor",
"version": "0.5.1", "version": "0.6.0",
"description": "Pascal building editor component", "description": "Pascal building editor component",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -11,8 +11,8 @@
"check-types": "tsc --noEmit" "check-types": "tsc --noEmit"
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.5.1", "@pascal-app/viewer": "^0.6.0",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"next": ">=15", "next": ">=15",
@@ -50,8 +50,8 @@
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.5.1", "@pascal-app/viewer": "^0.6.0",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/node": "^22.19.12", "@types/node": "^22.19.12",
@@ -49,9 +49,13 @@ export function FloatingActionMenu() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const curvingFence = useEditor((s) => s.curvingFence)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint) const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
const setCurvingWall = useEditor((s) => s.setCurvingWall) const setCurvingWall = useEditor((s) => s.setCurvingWall)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
@@ -128,12 +132,26 @@ export function FloatingActionMenu() {
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z) groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
} }
if (node?.type === 'wall') { if (node?.type === 'wall' || node?.type === 'fence') {
const wall = node as WallNode const segment = node as WallNode | FenceNode
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
const endpointYOffset = 0.35 const endpointYOffset = 0.35
const startWorld = obj.localToWorld(new THREE.Vector3(0, 0, 0)) const startWorld =
const endWorld = obj.localToWorld(new THREE.Vector3(wallLength, 0, 0)) node.type === 'wall'
? obj.localToWorld(new THREE.Vector3(0, 0, 0))
: obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
const endWorld =
node.type === 'wall'
? obj.localToWorld(
new THREE.Vector3(
Math.hypot(
segment.end[0] - segment.start[0],
segment.end[1] - segment.start[1],
),
0,
0,
),
)
: obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
if (startEndpointGroupRef.current) { if (startEndpointGroupRef.current) {
startEndpointGroupRef.current.position.set( startEndpointGroupRef.current.position.set(
@@ -180,22 +198,35 @@ export function FloatingActionMenu() {
const handleCurve = useCallback( const handleCurve = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (!canCurveSelectedWall || !node || node.type !== 'wall') return if (!node) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setCurvingWall(node) if (node.type === 'wall') {
if (!canCurveSelectedWall) return
setCurvingWall(node)
} else if (node.type === 'fence') {
setCurvingFence(node)
} else {
return
}
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, },
[canCurveSelectedWall, node, setCurvingWall, setSelection], [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
) )
const handleEndpointMove = useCallback( const handleEndpointMove = useCallback(
(endpoint: 'start' | 'end', e: React.MouseEvent) => { (endpoint: 'start' | 'end', e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (!(node && node.type === 'wall')) return if (!node) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setMovingWallEndpoint({ wall: node, endpoint }) if (node.type === 'wall') {
setMovingWallEndpoint({ wall: node, endpoint })
} else if (node.type === 'fence') {
setMovingFenceEndpoint({ fence: node, endpoint })
} else {
return
}
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, },
[node, setMovingWallEndpoint, setSelection], [node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection],
) )
const handleDuplicate = useCallback( const handleDuplicate = useCallback(
@@ -396,7 +427,9 @@ export function FloatingActionMenu() {
if ( if (
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
movingWallEndpoint movingWallEndpoint ||
movingFenceEndpoint ||
curvingFence
) )
return null return null
@@ -413,7 +446,11 @@ export function FloatingActionMenu() {
> >
<NodeActionMenu <NodeActionMenu
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined} onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={canCurveSelectedWall ? handleCurve : undefined} onCurve={
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
? handleCurve
: undefined
}
onDelete={handleDelete} onDelete={handleDelete}
onDuplicate={ onDuplicate={
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type) node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
@@ -426,7 +463,7 @@ export function FloatingActionMenu() {
/> />
</Html> </Html>
</group> </group>
{node?.type === 'wall' && ( {(node?.type === 'wall' || node?.type === 'fence') && (
<> <>
<group ref={startEndpointGroupRef}> <group ref={startEndpointGroupRef}>
<Html <Html
@@ -435,7 +472,7 @@ export function FloatingActionMenu() {
zIndexRange={[100, 0]} zIndexRange={[100, 0]}
> >
<button <button
aria-label="Move wall start" aria-label={node.type === 'wall' ? 'Move wall start' : 'Move fence start'}
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${ className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
altPressed altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white' ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
@@ -443,7 +480,11 @@ export function FloatingActionMenu() {
}`} }`}
onClick={(e) => handleEndpointMove('start', e)} onClick={(e) => handleEndpointMove('start', e)}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
title="Move wall start (Alt to detach)" title={
node.type === 'wall'
? 'Move wall start (Alt to detach)'
: 'Move fence start (Alt to detach)'
}
type="button" type="button"
> >
<Move className="h-4 w-4" /> <Move className="h-4 w-4" />
@@ -457,7 +498,7 @@ export function FloatingActionMenu() {
zIndexRange={[100, 0]} zIndexRange={[100, 0]}
> >
<button <button
aria-label="Move wall end" aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${ className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
altPressed altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white' ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
@@ -465,7 +506,11 @@ export function FloatingActionMenu() {
}`} }`}
onClick={(e) => handleEndpointMove('end', e)} onClick={(e) => handleEndpointMove('end', e)}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
title="Move wall end (Alt to detach)" title={
node.type === 'wall'
? 'Move wall end (Alt to detach)'
: 'Move fence end (Alt to detach)'
}
type="button" type="button"
> >
<Move className="h-4 w-4" /> <Move className="h-4 w-4" />
@@ -159,6 +159,12 @@ type FloorplanViewport = {
width: number width: number
} }
function floorplanViewportEquals(a: FloorplanViewport | null, b: FloorplanViewport | null) {
if (a === b) return true
if (!(a && b)) return false
return a.centerX === b.centerX && a.centerY === b.centerY && a.width === b.width
}
type SvgPoint = { type SvgPoint = {
x: number x: number
y: number y: number
@@ -4772,8 +4778,9 @@ const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall) const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
if (!isFloorplanHovered || movingNode || curvingWall) { if (!isFloorplanHovered || movingNode || curvingWall || curvingFence) {
return null return null
} }
@@ -4976,6 +4983,7 @@ export function FloorplanPanel() {
const setMode = useEditor((state) => state.setMode) const setMode = useEditor((state) => state.setMode)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall) const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const phase = useEditor((state) => state.phase) const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
@@ -5650,6 +5658,7 @@ export function FloorplanPanel() {
const isCeilingMoveActive = movingNode?.type === 'ceiling' const isCeilingMoveActive = movingNode?.type === 'ceiling'
const isWallMoveActive = movingNode?.type === 'wall' const isWallMoveActive = movingNode?.type === 'wall'
const isWallCurveActive = curvingWall?.type === 'wall' const isWallCurveActive = curvingWall?.type === 'wall'
const isFenceCurveActive = curvingFence?.type === 'fence'
const isItemPlacementPreviewActive = const isItemPlacementPreviewActive =
(mode === 'build' && tool === 'item') || movingNode?.type === 'item' (mode === 'build' && tool === 'item') || movingNode?.type === 'item'
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
@@ -5661,6 +5670,7 @@ export function FloorplanPanel() {
isCeilingMoveActive || isCeilingMoveActive ||
isWallMoveActive || isWallMoveActive ||
isWallCurveActive || isWallCurveActive ||
isFenceCurveActive ||
isFloorItemBuildActive || isFloorItemBuildActive ||
isFloorItemMoveActive isFloorItemMoveActive
const floorplanPreviewStairSegment = useMemo( const floorplanPreviewStairSegment = useMemo(
@@ -6155,12 +6165,12 @@ export function FloorplanPanel() {
if (levelChanged) { if (levelChanged) {
previousLevelIdRef.current = levelId ?? null previousLevelIdRef.current = levelId ?? null
hasUserAdjustedViewportRef.current = false hasUserAdjustedViewportRef.current = false
setViewport(fittedViewport) setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
return return
} }
if (!hasUserAdjustedViewportRef.current) { if (!hasUserAdjustedViewportRef.current) {
setViewport(fittedViewport) setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
} }
}, [fittedViewport, levelId]) }, [fittedViewport, levelId])
@@ -496,6 +496,7 @@ export const SelectionManager = () => {
const movingNode = useEditor((s) => s.movingNode) const movingNode = useEditor((s) => s.movingNode)
const curvingWall = useEditor((s) => s.curvingWall) const curvingWall = useEditor((s) => s.curvingWall)
const curvingFence = useEditor((s) => s.curvingFence)
useEffect(() => { useEffect(() => {
const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default' const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
@@ -841,7 +842,7 @@ export const SelectionManager = () => {
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode || curvingWall) return if (movingNode || curvingWall || curvingFence) return
const onClick = (event: NodeEvent) => { const onClick = (event: NodeEvent) => {
// Skip if box-select just completed (drag ended over a node) // Skip if box-select just completed (drag ended over a node)
@@ -987,12 +988,12 @@ export const SelectionManager = () => {
}) })
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
} }
}, [curvingWall, mode, movingNode]) }, [curvingFence, curvingWall, mode, movingNode])
// Global double-click handler for auto-switching phases and cross-phase hover // Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode || curvingWall) return if (movingNode || curvingWall || curvingFence) return
const onEnter = (event: NodeEvent) => { const onEnter = (event: NodeEvent) => {
const node = event.node const node = event.node
@@ -1121,7 +1122,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:double-click` as any, onDoubleClick as any) emitter.off(`${type}:double-click` as any, onDoubleClick as any)
}) })
} }
}, [curvingWall, mode, movingNode]) }, [curvingFence, curvingWall, mode, movingNode])
// Delete mode: click-to-delete (sledgehammer tool) // Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => { useEffect(() => {
@@ -0,0 +1,179 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
pauseSceneHistory,
resumeSceneHistory,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting'
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = useCallback(() => {
useEditor.getState().setCurvingFence(null)
}, [])
useEffect(() => {
const nodeId = node.id
const originalCurveOffset = originalCurveOffsetRef.current
const chord = getWallChordFrame(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyPreview = (curveOffset: number) => {
if (previewOffsetRef.current === curveOffset) {
return
}
previewOffsetRef.current = curveOffset
const nextNode = {
...node,
curveOffset,
}
const handlePoint = getWallMidpointHandlePoint(nextNode)
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const restoreOriginal = () => {
if (previewOffsetRef.current === originalCurveOffset) {
return
}
previewOffsetRef.current = originalCurveOffset
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const snapStep = getWallGridStep()
const localX = shiftPressedRef.current
? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint =
-(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
)
if (
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousCurveOffsetRef.current = nextCurveOffset
applyPreview(nextCurveOffset)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const curveOffset = previewOffsetRef.current
wasCommitted = true
if (curveOffset !== originalCurveOffset) {
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
resumeSceneHistory(useScene)
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
pauseSceneHistory(useScene)
}
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitCurveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitCurveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitCurveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,7 +1,9 @@
import { FenceNode, useScene, type WallNode } from '@pascal-app/core' import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { import {
getWallAngleSnapStep,
getWallGridStep,
type WallPlanPoint, type WallPlanPoint,
findWallSnapTarget, findWallSnapTarget,
isWallLongEnough, isWallLongEnough,
@@ -58,11 +60,16 @@ function findFenceSnapTarget(
continue continue
} }
const candidates: Array<FencePlanPoint | null> = [ const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
fence.start, if (isCurvedWall(fence)) {
fence.end, const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
projectPointOntoSegment(point, fence), for (let index = 0; index <= sampleCount; index += 1) {
] const frame = getWallCurveFrameAt(fence, index / sampleCount)
candidates.push([frame.point.x, frame.point.y])
}
} else {
candidates.push(projectPointOntoSegment(point, fence))
}
for (const candidate of candidates) { for (const candidate of candidates) {
if (!candidate) { if (!candidate) {
@@ -94,7 +101,12 @@ export function snapFenceDraftPoint(args: {
ignoreFenceIds?: string[] ignoreFenceIds?: string[]
}): FencePlanPoint { }): FencePlanPoint {
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point) const gridStep = getWallGridStep()
const angleStep = getWallAngleSnapStep(gridStep)
const basePoint =
start && angleSnap
? snapPointTo45Degrees(start, point, gridStep, angleStep)
: snapPointToGrid(point, gridStep)
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds) const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
@@ -0,0 +1,327 @@
'use client'
import {
type AnyNodeId,
type FenceNode,
type WallNode,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
} from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { snapFenceDraftPoint, type FencePlanPoint } from './fence-drafting'
import { isWallLongEnough } from '../wall/wall-drafting'
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedFenceSnapshot = {
id: FenceNode['id']
start: FencePlanPoint
end: FencePlanPoint
}
function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id']
fenceParentId: string | null
originalStart: FencePlanPoint
originalEnd: FencePlanPoint
}) {
const { fenceId, fenceParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'fence' && node.id !== fenceId)) {
continue
}
if ((node.parentId ?? null) !== fenceParentId) {
continue
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as FencePlanPoint,
end: [...node.end] as FencePlanPoint,
})
}
return snapshots
}
function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[],
originalStart: FencePlanPoint,
originalEnd: FencePlanPoint,
nextStart: FencePlanPoint,
nextEnd: FencePlanPoint,
) {
return linkedFences.map((fence) => ({
id: fence.id,
start: samePoint(fence.start, originalStart)
? nextStart
: samePoint(fence.start, originalEnd)
? nextEnd
: fence.start,
end: samePoint(fence.end, originalStart)
? nextStart
: samePoint(fence.end, originalEnd)
? nextEnd
: fence.end,
}))
}
export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
const shiftPressedRef = useRef(false)
const altPressedRef = useRef(false)
const nodeIdRef = useRef(target.fence.id)
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
const fixedPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.end] as FencePlanPoint)
: ([...target.fence.start] as FencePlanPoint),
)
const linkedOriginalsRef = useRef(
getLinkedFenceSnapshots({
fenceId: target.fence.id,
fenceParentId: target.fence.parentId ?? null,
originalStart: target.fence.start,
originalEnd: target.fence.end,
}),
)
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
return [point[0], 0, point[1]]
})
const [altPressed, setAltPressed] = useState(false)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingFenceEndpoint(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const fixedPoint = fixedPointRef.current
const siblings = Object.values(useScene.getState().nodes)
const levelWalls = siblings.filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
const levelFences = siblings.filter(
(node): node is FenceNode =>
node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
applyNodePreview([
{ id: nodeId, start: nextStart, end: nextEnd },
...(detachLinkedFences
? []
: getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)),
])
}
const restoreOriginal = () => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
}
const onGridMove = (event: GridEvent) => {
const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const snappedPoint = snapFenceDraftPoint({
point: planPoint,
walls: levelWalls,
fences: levelFences,
start: fixedPoint,
angleSnap: !shiftPressedRef.current,
ignoreFenceIds: [nodeId],
})
if (
previousGridPosRef.current &&
(snappedPoint[0] !== previousGridPosRef.current[0] ||
snappedPoint[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = snappedPoint
applyPreview(snappedPoint, event.nativeEvent.altKey)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged =
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
resumeSceneHistory(useScene)
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current
? []
: getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
)),
])
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
if (event.key === 'Alt') {
altPressedRef.current = true
setAltPressed(true)
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
if (event.key === 'Alt') {
altPressedRef.current = false
setAltPressed(false)
}
}
const onWindowBlur = () => {
shiftPressedRef.current = false
altPressedRef.current = false
setAltPressed(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
}
}, [exitMoveMode, target])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
: 'border-border/70 bg-background/90 text-foreground/80'
}`}
>
{altPressed ? 'Detach endpoint' : 'Drag endpoint'}
</div>
</div>
</Html>
</group>
)
}
@@ -167,6 +167,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const preview = previewRef.current ?? { start: originalStart, end: originalEnd } const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end }, { id: nodeId, start: preview.start, end: preview.end },
@@ -1,7 +1,9 @@
import { import {
type AnyNodeId, type AnyNodeId,
emitter, emitter,
type FenceNode,
type GridEvent, type GridEvent,
type LevelNode,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type StairNode, type StairNode,
@@ -9,13 +11,16 @@ import {
sceneRegistry, sceneRegistry,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from '../fence/fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import type { WallPlanPoint } from '../wall/wall-drafting'
export const MoveRoofTool: React.FC<{ export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
@@ -118,6 +123,46 @@ export const MoveRoofTool: React.FC<{
} }
} }
const resolveLevelId = () => {
if (movingNode.type === 'roof' || movingNode.type === 'stair') {
return movingNode.parentId ?? null
}
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
return parentNode && 'parentId' in parentNode ? (parentNode.parentId ?? null) : null
}
return null
}
const levelId = resolveLevelId()
const levelNode =
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((node): node is WallNode => node?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((node): node is FenceNode => node?.type === 'fence')
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
if (buildingObj) {
const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1]))
return [worldPoint.x, worldPoint.y, worldPoint.z]
}
return [localPoint[0], y, localPoint[1]]
}
const computeLocal = ( const computeLocal = (
gridX: number, gridX: number,
gridZ: number, gridZ: number,
@@ -155,21 +200,21 @@ export const MoveRoofTool: React.FC<{
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1] const y = event.position[1]
if ( const snappedLocal = snapFenceDraftPoint({
previousGridPosRef.current && point: [event.localPosition[0], event.localPosition[2]],
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) walls: levelWalls,
) { fences: levelFences,
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
previousGridPosRef.current = [gridX, gridZ] previousGridPosRef.current = [gridX, gridZ]
// Cursor is inside the building-local ToolManager group — use local position const [lx, lz] = snappedLocal
const lx = Math.round(event.localPosition[0] * 2) / 2
const lz = Math.round(event.localPosition[2] * 2) / 2
setCursorWorldPos([lx, event.localPosition[1], lz]) setCursorWorldPos([lx, event.localPosition[1], lz])
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
@@ -189,11 +234,14 @@ export const MoveRoofTool: React.FC<{
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1] const y = event.position[1]
const lx = Math.round(event.localPosition[0] * 2) / 2 const snappedLocal = snapFenceDraftPoint({
const lz = Math.round(event.localPosition[2] * 2) / 2 point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
const [lx, lz] = snappedLocal
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three' import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { snapToHalf } from '../item/placement-math'
const Y_OFFSET = 0.02 const Y_OFFSET = 0.02
@@ -187,8 +188,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// Listen to grid:move events to track cursor position // Listen to grid:move events to track cursor position
useEffect(() => { useEffect(() => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2 const gridX = snapToHalf(event.localPosition[0])
const gridZ = Math.round(event.localPosition[2] * 2) / 2 const gridZ = snapToHalf(event.localPosition[2])
const newPosition: [number, number] = [gridX, gridZ] const newPosition: [number, number] = [gridX, gridZ]
// Play snap sound when cursor moves to a new grid cell during drag // Play snap sound when cursor moves to a new grid cell during drag
@@ -1,17 +1,23 @@
'use client' 'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type SlabNode } from '@pascal-app/core' import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
type SlabNode,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from '../fence/fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function translatePolygon( function translatePolygon(
polygon: Array<[number, number]>, polygon: Array<[number, number]>,
deltaX: number, deltaX: number,
@@ -56,6 +62,17 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
useEffect(() => { useEffect(() => {
const originalPolygon = originalPolygonRef.current const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current const originalHoles = originalHolesRef.current
const levelNode =
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is WallNode => child?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence')
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
let wasCommitted = false let wasCommitted = false
@@ -80,8 +97,11 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0]) const [localX, localZ] = snapFenceDraftPoint({
const localZ = snap(event.localPosition[2]) point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
if ( if (
previousGridPosRef.current && previousGridPosRef.current &&
@@ -11,7 +11,9 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool' import { CeilingTool } from './ceiling/ceiling-tool'
import { DoorTool } from './door/door-tool' import { DoorTool } from './door/door-tool'
import { CurveFenceTool } from './fence/curve-fence-tool'
import { FenceTool } from './fence/fence-tool' import { FenceTool } from './fence/fence-tool'
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
import { ItemTool } from './item/item-tool' import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool' import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool' import { RoofTool } from './roof/roof-tool'
@@ -54,7 +56,9 @@ export const ToolManager: React.FC = () => {
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall) const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const editingHole = useEditor((state) => state.editingHole) const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedZoneId = useViewer((state) => state.selection.zoneId)
const buildingId = useViewer((state) => state.selection.buildingId) const buildingId = useViewer((state) => state.selection.buildingId)
@@ -145,7 +149,9 @@ export const ToolManager: React.FC = () => {
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} /> <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)} )}
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />} {movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
{curvingWall && <CurveWallTool node={curvingWall} />} {curvingWall && <CurveWallTool node={curvingWall} />}
{curvingFence && <CurveFenceTool node={curvingFence} />}
{movingNode && movingNode.type !== 'building' && <MoveTool />} {movingNode && movingNode.type !== 'building' && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />} {!movingNode && BuildToolComponent && <BuildToolComponent />}
</group> </group>
@@ -1,6 +1,14 @@
'use client' 'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core' import {
type AnyNodeId,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
@@ -127,7 +135,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null), node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
) )
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
let wasCommitted = false let wasCommitted = false
const applyNodePreview = ( const applyNodePreview = (
@@ -209,7 +217,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
]) ])
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end }, { id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current ...(altPressedRef.current
@@ -222,7 +230,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
preview.end, preview.end,
)), )),
]) ])
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
} }
@@ -234,7 +242,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const onCancel = () => { const onCancel = () => {
restoreOriginal() restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
markToolCancelConsumed() markToolCancelConsumed()
exitMoveMode() exitMoveMode()
} }
@@ -279,7 +287,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
if (!wasCommitted) { if (!wasCommitted) {
restoreOriginal() restoreOriginal()
} }
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
@@ -1,6 +1,14 @@
'use client' 'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core' import {
type AnyNodeId,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
@@ -24,9 +32,9 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'
return meta return meta
} }
const nextMeta = { ...(meta as Record<string, unknown>) } const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
delete nextMeta.isNew delete nextMeta.isNew
return nextMeta return nextMeta as WallNode['metadata']
} }
type LinkedWallSnapshot = { type LinkedWallSnapshot = {
@@ -146,7 +154,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const originalCenter = originalCenterRef.current const originalCenter = originalCenterRef.current
const originalHalfVector = originalHalfVectorRef.current const originalHalfVector = originalHalfVectorRef.current
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
let wasCommitted = false let wasCommitted = false
const applyNodePreview = ( const applyNodePreview = (
@@ -237,7 +245,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
]) ])
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
const commitUpdates = [ const commitUpdates = [
{ {
@@ -266,7 +274,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
useScene.getState().markDirty(id) useScene.getState().markDirty(id)
} }
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -315,7 +323,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const onCancel = () => { const onCancel = () => {
restoreOriginal() restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
markToolCancelConsumed() markToolCancelConsumed()
exitMoveMode() exitMoveMode()
} }
@@ -331,7 +339,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
restoreOriginal() restoreOriginal()
} }
shiftPressedRef.current = false shiftPressedRef.current = false
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
@@ -36,6 +36,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useEffect } from 'react' import { useEffect } from 'react'
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection' import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
import { runRedo, runUndo } from '../../../lib/history'
import { useCommandRegistry } from '../../../store/use-command-registry' import { useCommandRegistry } from '../../../store/use-command-registry'
import type { StructureTool } from '../../../store/use-editor' import type { StructureTool } from '../../../store/use-editor'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -330,7 +331,7 @@ export function EditorCommands() {
group: 'History', group: 'History',
icon: <Undo2 className="h-4 w-4" />, icon: <Undo2 className="h-4 w-4" />,
keywords: ['undo', 'revert', 'back'], keywords: ['undo', 'revert', 'back'],
execute: () => run(() => useScene.temporal.getState().undo()), execute: () => run(() => runUndo()),
}, },
{ {
id: 'editor.history.redo', id: 'editor.history.redo',
@@ -338,7 +339,7 @@ export function EditorCommands() {
group: 'History', group: 'History',
icon: <Redo2 className="h-4 w-4" />, icon: <Redo2 className="h-4 w-4" />,
keywords: ['redo', 'forward', 'repeat'], keywords: ['redo', 'forward', 'repeat'],
execute: () => run(() => useScene.temporal.getState().redo()), execute: () => run(() => runRedo()),
}, },
// ── Export & Share ─────────────────────────────────────────────────── // ── Export & Share ───────────────────────────────────────────────────
@@ -1,8 +1,22 @@
'use client' 'use client'
import { type AnyNode, type AnyNodeId, type FenceNode, type MaterialSchema, useScene } from '@pascal-app/core' import {
type AnyNode,
type AnyNodeId,
type FenceNode,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
type MaterialSchema,
normalizeWallCurveOffset,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
@@ -28,6 +42,8 @@ export function FencePanel() {
const selectedCount = useViewer((s) => s.selection.selectedIds.length) const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
@@ -67,6 +83,20 @@ export function FencePanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleCurve = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setCurvingFence(node)
setSelection({ selectedIds: [] })
}, [node, setCurvingFence, setSelection])
const handleMaterialPresetChange = useCallback( const handleMaterialPresetChange = useCallback(
(materialPreset: string) => { (materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined }) handleUpdate({ materialPreset, material: undefined })
@@ -83,9 +113,9 @@ export function FencePanel() {
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
const dx = node.end[0] - node.start[0] const length = getWallCurveLength(node)
const dz = node.end[1] - node.start[1] const curveOffset = getClampedWallCurveOffset(node)
const length = Math.sqrt(dx * dx + dz * dz) const maxCurveOffset = getMaxWallCurveOffset(node)
return ( return (
<PanelWrapper <PanelWrapper
@@ -119,6 +149,16 @@ export function FencePanel() {
unit="m" unit="m"
value={length} value={length}
/> />
<SliderControl
label="Curve"
max={Math.max(0.01, maxCurveOffset)}
min={-Math.max(0.01, maxCurveOffset)}
onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
precision={2}
step={0.1}
unit="m"
value={Math.round(curveOffset * 100) / 100}
/>
<SliderControl <SliderControl
label="Height" label="Height"
max={4} max={4}
@@ -213,6 +253,17 @@ export function FencePanel() {
value={node.material} value={node.material}
/> />
</PanelSection> </PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
</ActionGroup>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -266,9 +266,11 @@ export function SlabPanel() {
value={node.material} value={node.material}
/> />
</PanelSection> </PanelSection>
<ActionGroup> <PanelSection title="Actions">
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionGroup>
</ActionGroup> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
+3 -2
View File
@@ -1,6 +1,7 @@
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { runRedo, runUndo } from '../lib/history'
import { sfxEmitter } from '../lib/sfx-bus' import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
@@ -98,11 +99,11 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useScene.temporal.getState().undo() runUndo()
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useScene.temporal.getState().redo() runRedo()
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
e.preventDefault() e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection const { buildingId, levelId } = useViewer.getState().selection
+20
View File
@@ -0,0 +1,20 @@
import { useLiveTransforms, useScene } from '@pascal-app/core'
function refreshSceneAfterHistoryJump() {
useLiveTransforms.getState().clearAll()
const state = useScene.getState()
for (const node of Object.values(state.nodes)) {
state.markDirty(node.id)
}
}
export function runUndo() {
useScene.temporal.getState().undo()
refreshSceneAfterHistoryJump()
}
export function runRedo() {
useScene.temporal.getState().redo()
refreshSceneAfterHistoryJump()
}
+14 -5
View File
@@ -91,11 +91,12 @@ export type MovingWallEndpoint = {
endpoint: 'start' | 'end' endpoint: 'start' | 'end'
} }
export type MaterialTargetRole = export type MovingFenceEndpoint = {
| WallSurfaceSide fence: FenceNode
| StairSurfaceMaterialRole endpoint: 'start' | 'end'
| RoofSurfaceMaterialRole }
| SingleSurfaceMaterialRole
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole
export type SelectedMaterialTarget = { export type SelectedMaterialTarget = {
nodeId: AnyNodeId nodeId: AnyNodeId
@@ -153,8 +154,12 @@ type EditorState = {
) => void ) => void
movingWallEndpoint: MovingWallEndpoint | null movingWallEndpoint: MovingWallEndpoint | null
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
movingFenceEndpoint: MovingFenceEndpoint | null
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
curvingWall: WallNode | null curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void setCurvingWall: (wall: WallNode | null) => void
curvingFence: FenceNode | null
setCurvingFence: (fence: FenceNode | null) => void
selectedMaterialTarget: SelectedMaterialTarget | null selectedMaterialTarget: SelectedMaterialTarget | null
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
activePaintMaterial: ActivePaintMaterial | null activePaintMaterial: ActivePaintMaterial | null
@@ -541,8 +546,12 @@ const useEditor = create<EditorState>()(
setMovingNode: (node) => set({ movingNode: node }), setMovingNode: (node) => set({ movingNode: node }),
movingWallEndpoint: null, movingWallEndpoint: null,
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }), setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
movingFenceEndpoint: null,
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
curvingWall: null, curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }), setCurvingWall: (wall) => set({ curvingWall: wall }),
curvingFence: null,
setCurvingFence: (fence) => set({ curvingFence: fence }),
selectedMaterialTarget: null, selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }), setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
activePaintMaterial: null, activePaintMaterial: null,
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/viewer", "name": "@pascal-app/viewer",
"version": "0.5.1", "version": "0.6.0",
"description": "3D viewer component for Pascal building editor", "description": "3D viewer component for Pascal building editor",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -22,7 +22,7 @@
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",