Merge branch 'main' into feat/door-improvement

# Conflicts:
#	packages/core/src/events/bus.ts
#	packages/viewer/src/systems/door/door-system.tsx
This commit is contained in:
sudhir
2026-05-07 10:40:51 +05:30
46 changed files with 6391 additions and 860 deletions
@@ -4,6 +4,7 @@ import {
type AnyNode,
type AnyNodeId,
type CeilingNode,
ColumnNode,
DoorNode,
FenceNode,
generateId,
@@ -25,8 +26,8 @@ import { Move } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import { sfxEmitter } from '../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from './node-action-menu'
@@ -40,6 +41,7 @@ const ALLOWED_TYPES = [
'stair-segment',
'wall',
'fence',
'column',
'slab',
'ceiling',
'spawn',
@@ -147,10 +149,7 @@ export function FloatingActionMenu() {
node.type === 'wall'
? obj.localToWorld(
new THREE.Vector3(
Math.hypot(
segment.end[0] - segment.start[0],
segment.end[1] - segment.start[1],
),
Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]),
0,
0,
),
@@ -186,6 +185,7 @@ export function FloatingActionMenu() {
node.type === 'door' ||
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'spawn' ||
@@ -263,6 +263,8 @@ export function FloatingActionMenu() {
duplicate = WindowNode.parse(duplicateInfo)
} else if (node.type === 'item') {
duplicate = ItemNode.parse(duplicateInfo)
} else if (node.type === 'column') {
duplicate = ColumnNode.parse(duplicateInfo)
} else if (node.type === 'wall') {
duplicate = WallNode.parse(duplicateInfo)
} else if (node.type === 'fence') {
@@ -323,6 +325,7 @@ export function FloatingActionMenu() {
}
if (
duplicate.type === 'item' ||
duplicate.type === 'column' ||
duplicate.type === 'wall' ||
duplicate.type === 'fence' ||
duplicate.type === 'window' ||
@@ -335,7 +338,7 @@ export function FloatingActionMenu() {
} else if (duplicate.type === 'stair') {
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
}
if (duplicate.type !== 'stair' && duplicate.type !== 'roof') {
if (duplicate.type !== 'stair') {
setSelection({ selectedIds: [] })
}
}
@@ -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'
@@ -5737,15 +5738,6 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({
// Renders an item's 2D floor-plan image (top-down view, object-fit:contain)
// inside its footprint rectangle. Placed at the same scene position/rotation
// as the polygon so it lines up exactly.
const FLOORPLAN_ITEM_ICON_OVERRIDES: Record<string, string> = {
'office-chair': '/items/office-chair/floor-plan.svg',
sofa: '/items/sofa/floor-plan.svg',
}
const FLOORPLAN_ITEMS_WITH_SELF_OUTLINED_ICON = new Set(['office-chair', 'sofa'])
function getFloorplanItemIconUrl(item: ItemNode) {
return FLOORPLAN_ITEM_ICON_OVERRIDES[item.asset.id] ?? item.asset.floorPlanUrl
}
function FloorplanItemImage({
url,
@@ -5864,8 +5856,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
: isHovered
? 0.58
: 0.52
const floorPlanUrl = getFloorplanItemIconUrl(item)
const shouldDrawFootprintBorder = !FLOORPLAN_ITEMS_WITH_SELF_OUTLINED_ICON.has(item.asset.id)
const floorPlanUrl = item.asset.floorPlanUrl
const diagonalAStart = polygon[0]
const diagonalAEnd = polygon[2]
const diagonalBStart = polygon[1]
@@ -5949,14 +5940,9 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
: 0.015
}
points={points}
stroke={shouldDrawFootprintBorder ? stroke : 'none'}
strokeOpacity={shouldDrawFootprintBorder ? 1 : 0}
stroke={stroke}
strokeWidth={
shouldDrawFootprintBorder
? isSelectionActive
? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH
: FLOORPLAN_WALL_STROKE_WIDTH
: 0
isSelectionActive ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : FLOORPLAN_WALL_STROKE_WIDTH
}
vectorEffect="non-scaling-stroke"
/>
@@ -9712,9 +9698,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])
@@ -9724,9 +9710,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)
}
}, [])
@@ -9741,9 +9727,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])
@@ -3,6 +3,7 @@ import {
type AnyNodeId,
type BuildingNode,
type CeilingNode,
type ColumnNode,
emitter,
type FenceNode,
getMaterialPresetByRef,
@@ -65,6 +66,7 @@ type SelectableNodeType =
| 'wall'
| 'fence'
| 'item'
| 'column'
| 'building'
| 'zone'
| 'slab'
@@ -333,7 +335,7 @@ function applyStairPaintPreview(
}
function applySingleSurfacePaintPreview(
node: FenceNode | SlabNode | CeilingNode,
node: FenceNode | ColumnNode | SlabNode | CeilingNode,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
if (node.type === 'ceiling') {
@@ -377,12 +379,32 @@ function applySingleSurfacePaintPreview(
}
}
const mesh = getRegisteredMesh(node.id)
if (!mesh) return null
const registeredObject = getRegisteredNodeObject(node.id)
const mesh =
registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null
const previewMaterial = getSingleSurfacePreviewMaterial(material)
if (!previewMaterial) return null
if (node.type === 'column') {
if (!registeredObject) return null
const restores: PaintPreviewCleanup[] = []
registeredObject.traverse((object) => {
if (!(object as Mesh).isMesh) return
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
})
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
if (!mesh) return null
if (node.type === 'slab') {
const slabMaterial = previewMaterial.clone()
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
@@ -542,6 +564,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
'wall',
'fence',
'item',
'column',
'zone',
'slab',
'ceiling',
@@ -595,6 +618,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
if (
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
@@ -658,6 +682,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
if (
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
@@ -857,7 +882,12 @@ export const SelectionManager = () => {
}
}
if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') {
if (
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling'
) {
const compatible = hasActivePaintMaterial(activePaintMaterial)
return {
@@ -870,17 +900,16 @@ export const SelectionManager = () => {
.getState()
.updateNode(
node.id as AnyNodeId,
buildSingleSurfaceMaterialPatch<FenceNode | SlabNode | CeilingNode>(
activePaintMaterial.material,
activePaintMaterial.materialPreset,
),
buildSingleSurfaceMaterialPatch<
FenceNode | ColumnNode | SlabNode | CeilingNode
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
)
}
: null,
preview: compatible
? () =>
applySingleSurfacePaintPreview(
node as FenceNode | SlabNode | CeilingNode,
node as FenceNode | ColumnNode | SlabNode | CeilingNode,
activePaintMaterial,
)
: () => previewCursor('not-allowed'),
@@ -963,6 +992,7 @@ export const SelectionManager = () => {
'wall',
'fence',
'item',
'column',
'slab',
'ceiling',
'roof',
@@ -1131,6 +1161,7 @@ export const SelectionManager = () => {
'wall',
'fence',
'item',
'column',
'building',
'zone',
'slab',
@@ -1227,6 +1258,7 @@ export const SelectionManager = () => {
} else if (
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
@@ -1279,6 +1311,7 @@ export const SelectionManager = () => {
'wall',
'fence',
'item',
'column',
'building',
'slab',
'ceiling',
@@ -1352,6 +1385,7 @@ export const SelectionManager = () => {
'wall',
'fence',
'item',
'column',
'slab',
'ceiling',
'roof',
@@ -4,10 +4,12 @@ import {
type AnyNodeId,
calculateLevelMiters,
DEFAULT_WALL_HEIGHT,
getScaledDimensions,
getWallCurveLength,
getWallMiterBoundaryPoints,
getWallPlanFootprint,
getWallSurfacePolygon,
type ItemNode,
isCurvedWall,
type Point2D,
pointToKey,
@@ -27,6 +29,8 @@ const GUIDE_Y_OFFSET = 0.08
const LABEL_LIFT = 0.08
const BAR_THICKNESS = 0.012
const LINE_OPACITY = 0.95
const HEIGHT_TICK_HALF_LENGTH = 0.14
const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16
const BAR_AXIS = new THREE.Vector3(0, 1, 0)
@@ -39,6 +43,18 @@ type MeasurementGuide = {
extEndStart: Vec3
extEndEnd: Vec3
labelPosition: Vec3
heightStart: Vec3
heightEnd: Vec3
heightBottomTickStart: Vec3
heightBottomTickEnd: Vec3
heightTopTickStart: Vec3
heightTopTickEnd: Vec3
heightLabelPosition: Vec3
}
type WallFaceLine = {
start: Point2D
end: Point2D
}
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
@@ -57,28 +73,28 @@ export function WallMeasurementLabel() {
const nodes = useScene((state) => state.nodes)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
const wall = selectedNode?.type === 'wall' ? selectedNode : null
const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
const measurableNode =
selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null
const [wallObjectState, setWallObjectState] = useState<{
id: WallNode['id']
const [objectState, setObjectState] = useState<{
id: AnyNodeId
object: THREE.Object3D
} | null>(null)
const wallObject =
selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null
const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null
useFrame(() => {
if (!selectedId || wallObject) return
if (!selectedId || selectedObject) return
const nextWallObject = sceneRegistry.nodes.get(selectedId)
if (nextWallObject) {
setWallObjectState({ id: selectedId as WallNode['id'], object: nextWallObject })
const nextObject = sceneRegistry.nodes.get(selectedId)
if (nextObject) {
setObjectState({ id: selectedId as AnyNodeId, object: nextObject })
}
})
if (!(wall && wallObject)) return null
if (!(measurableNode && selectedObject)) return null
return createPortal(<WallMeasurementAnnotation wall={wall} />, wallObject)
return createPortal(<SelectedMeasurementAnnotation node={measurableNode} />, selectedObject)
}
function getLevelWalls(
@@ -97,6 +113,114 @@ function getLevelWalls(
.filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
}
function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) {
if (!point) return false
return Math.abs(point.x - planPoint[0]) < 1e-6 && Math.abs(point.y - planPoint[1]) < 1e-6
}
function getWallFaceLines(
wall: WallNode,
miterData: WallMiterData,
): { left: WallFaceLine; right: WallFaceLine } | null {
if (isCurvedWall(wall)) return null
const footprint = getWallPlanFootprint(wall, miterData)
if (footprint.length < 4) return null
const startRight = footprint[0]
const endRight = footprint[1]
const hasEndCenterPoint = pointMatchesWallPlanPoint(footprint[2], wall.end)
const endLeft = footprint[hasEndCenterPoint ? 3 : 2]
const lastPoint = footprint[footprint.length - 1]
const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start)
const startLeft = footprint[hasStartCenterPoint ? footprint.length - 2 : footprint.length - 1]
if (!(startRight && endRight && endLeft && startLeft)) return null
return {
left: {
start: startLeft,
end: endLeft,
},
right: {
start: startRight,
end: endRight,
},
}
}
function getLineMidpoint(line: WallFaceLine): Point2D {
return {
x: (line.start.x + line.end.x) / 2,
y: (line.start.y + line.end.y) / 2,
}
}
function getLevelWallsCenter(levelWalls: WallNode[]): Point2D {
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const candidateWall of levelWalls) {
minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0])
maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0])
minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1])
maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1])
}
return {
x: minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2,
y: minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2,
}
}
function getWallOuterFaceLine(
wall: WallNode,
miterData: WallMiterData,
levelWalls: WallNode[],
): WallFaceLine | null {
const faceLines = getWallFaceLines(wall, miterData)
if (!faceLines) return null
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
return faceLines.left
}
if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') {
return faceLines.right
}
const dx = wall.end[0] - wall.start[0]
const dy = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dy)
if (length < 1e-6) return null
const wallMidpoint = {
x: (wall.start[0] + wall.end[0]) / 2,
y: (wall.start[1] + wall.end[1]) / 2,
}
const levelCenter = getLevelWallsCenter(levelWalls)
const normal = { x: -dy / length, y: dx / length }
const fromCenter = {
x: wallMidpoint.x - levelCenter.x,
y: wallMidpoint.y - levelCenter.y,
}
const outwardNormal =
fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y }
const rightMidpoint = getLineMidpoint(faceLines.right)
const leftMidpoint = getLineMidpoint(faceLines.left)
const rightScore =
(rightMidpoint.x - wallMidpoint.x) * outwardNormal.x +
(rightMidpoint.y - wallMidpoint.y) * outwardNormal.y
const leftScore =
(leftMidpoint.x - wallMidpoint.x) * outwardNormal.x +
(leftMidpoint.y - wallMidpoint.y) * outwardNormal.y
return rightScore >= leftScore ? faceLines.right : faceLines.left
}
function getWallMiddlePoints(
wall: WallNode,
miterData: WallMiterData,
@@ -136,7 +260,10 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
}
function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'>) {
function getWallExteriorOffsetSign(
wall: Pick<WallNode, 'start' | 'end' | 'frontSide' | 'backSide'>,
levelWalls: WallNode[],
) {
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
return 1
}
@@ -145,10 +272,31 @@ function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'
return -1
}
return 1
const dx = wall.end[0] - wall.start[0]
const dy = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dy)
if (length < 1e-6) return 1
const wallMidpoint = {
x: (wall.start[0] + wall.end[0]) / 2,
y: (wall.start[1] + wall.end[1]) / 2,
}
const levelCenter = getLevelWallsCenter(levelWalls)
const normal = { x: -dy / length, y: dx / length }
const fromCenter = {
x: wallMidpoint.x - levelCenter.x,
y: wallMidpoint.y - levelCenter.y,
}
return fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? 1 : -1
}
function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): Point2D[] | null {
function getCurvedWallMeasurementPath(
wall: WallNode,
miterData: WallMiterData,
levelWalls: WallNode[],
): Point2D[] | null {
const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
if (!boundaryPoints) return null
@@ -156,7 +304,7 @@ function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData):
const sidePointCount = 25
if (surface.length < sidePointCount * 2) return null
const offsetSign = getWallExteriorOffsetSign(wall)
const offsetSign = getWallExteriorOffsetSign(wall, levelWalls)
if (offsetSign >= 0) {
return surface.slice(sidePointCount).reverse()
}
@@ -170,14 +318,16 @@ function buildMeasurementGuide(
): MeasurementGuide | null {
const levelWalls = getLevelWalls(wall, nodes)
const miterData = calculateLevelMiters(levelWalls)
const middlePoints = getWallMiddlePoints(wall, miterData)
if (!middlePoints) return null
const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls)
const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData)
const measurementPoints = measurementLine ?? fallbackMiddlePoints
if (!measurementPoints) return null
const height = wall.height ?? DEFAULT_WALL_HEIGHT
const startLocal = worldPointToWallLocal(wall, middlePoints.start)
const endLocal = worldPointToWallLocal(wall, middlePoints.end)
const startLocal = worldPointToWallLocal(wall, measurementPoints.start)
const endLocal = worldPointToWallLocal(wall, measurementPoints.end)
const curvedMeasurementPath = isCurvedWall(wall)
? getCurvedWallMeasurementPath(wall, miterData)
? getCurvedWallMeasurementPath(wall, miterData, levelWalls)
: null
const guidePath: Vec3[] = curvedMeasurementPath
? curvedMeasurementPath.map((point) => {
@@ -224,6 +374,38 @@ function buildMeasurementGuide(
guideStart[1],
(guideStart[2] + guideEnd[2]) / 2,
] as Vec3)
const rawHeightGuidePosition = [guideEnd[0], 0, guideEnd[2]] as Vec3
const beforeGuideEnd = guidePath[guidePath.length - 2] ?? guideStart
const tickDx = guideEnd[0] - beforeGuideEnd[0]
const tickDz = guideEnd[2] - beforeGuideEnd[2]
const tickLength = Math.hypot(tickDx, tickDz)
const tangentX = tickLength > 1e-6 ? tickDx / tickLength : 1
const tangentZ = tickLength > 1e-6 ? tickDz / tickLength : 0
const tickUnitX = -tangentZ
const tickUnitZ = tangentX
const wallEndLocal = worldPointToWallLocal(wall, { x: wall.end[0], y: wall.end[1] })
const endOutwardX = rawHeightGuidePosition[0] - wallEndLocal[0]
const endOutwardZ = rawHeightGuidePosition[2] - wallEndLocal[2]
const outsideSign = endOutwardX * tickUnitX + endOutwardZ * tickUnitZ >= 0 ? 1 : -1
const heightGuidePosition = [
rawHeightGuidePosition[0] + tickUnitX * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET,
0,
rawHeightGuidePosition[2] + tickUnitZ * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET,
] as Vec3
const getHorizontalHeightTick = (y: number): { start: Vec3; end: Vec3 } => ({
start: [
heightGuidePosition[0] - tickUnitX * HEIGHT_TICK_HALF_LENGTH,
y,
heightGuidePosition[2] - tickUnitZ * HEIGHT_TICK_HALF_LENGTH,
],
end: [
heightGuidePosition[0] + tickUnitX * HEIGHT_TICK_HALF_LENGTH,
y,
heightGuidePosition[2] + tickUnitZ * HEIGHT_TICK_HALF_LENGTH,
],
})
const bottomHeightTick = getHorizontalHeightTick(0)
const topHeightTick = getHorizontalHeightTick(height)
return {
guidePath,
@@ -236,6 +418,37 @@ function buildMeasurementGuide(
extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]],
heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]],
heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]],
heightBottomTickStart: bottomHeightTick.start,
heightBottomTickEnd: bottomHeightTick.end,
heightTopTickStart: topHeightTick.start,
heightTopTickEnd: topHeightTick.end,
heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]],
}
}
type HeightGuide = {
start: Vec3
end: Vec3
labelPosition: Vec3
}
function buildItemHeightGuide(item: ItemNode): { guide: HeightGuide; height: number } | null {
const [width, height, depth] = getScaledDimensions(item)
if (!Number.isFinite(height) || height < 0.01) return null
const x = Number.isFinite(width) ? width / 2 + 0.18 : 0.18
const z = Number.isFinite(depth) ? depth / 2 + 0.18 : 0.18
return {
height,
guide: {
start: [x, 0, z],
end: [x, height, z],
labelPosition: [x, height / 2, z],
},
}
}
@@ -286,6 +499,45 @@ function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) {
)
}
function MeasurementLabel({
label,
position,
color,
shadowColor,
}: {
label: string
position: Vec3
color: string
shadowColor: string
}) {
return (
<Html
center
position={position}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[20, 0]}
>
<div
className="whitespace-nowrap font-bold font-mono text-[15px]"
style={{
color,
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
}}
>
{label}
</div>
</Html>
)
}
function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) {
if (node.type === 'wall') {
return <WallMeasurementAnnotation wall={node} />
}
return <ItemHeightMeasurementAnnotation item={node} />
}
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
const nodes = useScene((state) => state.nodes)
const theme = useViewer((state) => state.theme)
@@ -316,6 +568,7 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
return total
}, [guide, wall])
const label = formatMeasurement(length, unit)
const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
@@ -324,23 +577,50 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
<MeasurementPath color={color} path={guide.guidePath} />
<MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
<MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
<MeasurementBar color={color} end={guide.heightEnd} start={guide.heightStart} />
<MeasurementBar
color={color}
end={guide.heightBottomTickEnd}
start={guide.heightBottomTickStart}
/>
<MeasurementBar color={color} end={guide.heightTopTickEnd} start={guide.heightTopTickStart} />
<Html
center
<MeasurementLabel
color={color}
label={label}
position={guide.labelPosition}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[20, 0]}
>
<div
className="whitespace-nowrap font-bold font-mono text-[15px]"
style={{
color,
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
}}
>
{label}
</div>
</Html>
shadowColor={shadowColor}
/>
<MeasurementLabel
color={color}
label={heightLabel}
position={guide.heightLabelPosition}
shadowColor={shadowColor}
/>
</group>
)
}
function ItemHeightMeasurementAnnotation({ item }: { item: ItemNode }) {
const theme = useViewer((state) => state.theme)
const unit = useViewer((state) => state.unit)
const isNight = theme === 'dark'
const color = isNight ? '#ffffff' : '#111111'
const shadowColor = isNight ? '#111111' : '#ffffff'
const measurement = useMemo(() => buildItemHeightGuide(item), [item])
if (!measurement) return null
return (
<group>
<MeasurementBar color={color} end={measurement.guide.end} start={measurement.guide.start} />
<MeasurementLabel
color={color}
label={`H ${formatMeasurement(measurement.height, unit)}`}
position={measurement.guide.labelPosition}
shadowColor={shadowColor}
/>
</group>
)
}
@@ -0,0 +1,97 @@
import '../../../three-types'
import {
COLUMN_PRESETS,
ColumnNode,
type ColumnNode as ColumnNodeType,
type ColumnPresetId,
emitter,
type GridEvent,
type LevelNode,
useScene,
} from '@pascal-app/core'
import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const COLUMN_ICON = (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="Column"
src="/icons/column.png"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
)
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
const { label, ...preset } = COLUMN_PRESETS[presetId]
return ColumnNode.parse({
name: label,
position,
rotation: 0,
...preset,
})
}
type ColumnToolProps = {
currentLevelId: LevelNode['id'] | null
onPlaced?: (nodeId: ColumnNodeType['id']) => void
}
export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced }) => {
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
const cursorRef = useRef<Group>(null)
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
const nextPosition: [number, number, number] = [
roundToHalf(event.localPosition[0]),
0,
roundToHalf(event.localPosition[2]),
]
setCursorPosition(nextPosition)
cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2])
}
const onGridClick = (event: GridEvent) => {
const position: [number, number, number] = [
roundToHalf(event.localPosition[0]),
0,
roundToHalf(event.localPosition[2]),
]
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
useScene.getState().createNode(column, currentLevelId)
onPlaced?.(column.id)
sfxEmitter.emit('sfx:structure-build')
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
}
}, [currentLevelId, onPlaced])
if (!currentLevelId) return null
return (
<CursorSphere
color="#a78bfa"
height={2.8}
ref={cursorRef}
showTooltip
tooltipContent={COLUMN_ICON}
/>
)
}
@@ -0,0 +1,105 @@
import '../../../three-types'
import {
type AnyNodeId,
ColumnNode,
type ColumnNode as ColumnNodeType,
emitter,
type GridEvent,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useCallback, useEffect, 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'
const roundToHalf = (value: number) => Math.round(value * 2) / 2
export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
let committed = false
const applyPreview = (position: [number, number, number]) => {
setPreviewPosition(position)
useLiveTransforms.getState().set(node.id, {
position,
rotation: node.rotation,
})
sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2])
}
const onGridMove = (event: GridEvent) => {
applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])])
}
const onGridClick = (event: GridEvent) => {
const position: [number, number, number] = [
roundToHalf(event.localPosition[0]),
0,
roundToHalf(event.localPosition[2]),
]
const nodeId = (node as { id?: ColumnNodeType['id'] }).id
if (nodeId && useScene.getState().nodes[nodeId]) {
committed = true
useLiveTransforms.getState().clear(nodeId)
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, { position })
} else if (node.parentId) {
const column = ColumnNode.parse({
...node,
id: undefined,
metadata: {},
position,
})
committed = true
useScene.temporal.getState().resume()
useScene.getState().createNode(column, node.parentId as AnyNodeId)
}
useLiveTransforms.getState().clear(node.id)
sfxEmitter.emit('sfx:item-place')
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
sceneRegistry.nodes
.get(node.id)
?.position.set(node.position[0], node.position[1], node.position[2])
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useLiveTransforms.getState().clear(node.id)
if (!committed) {
sceneRegistry.nodes
.get(node.id)
?.position.set(node.position[0], node.position[1], node.position[2])
useScene.temporal.getState().resume()
}
}
}, [exitMoveMode, node])
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
}
@@ -1,14 +1,21 @@
import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, 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 { sfxEmitter } from '../../../lib/sfx-bus'
import {
findWallSnapTarget,
getWallAngleSnapStep,
getWallGridStep,
type WallPlanPoint,
findWallSnapTarget,
isWallLongEnough,
snapPointTo45Degrees,
snapPointToGrid,
type WallPlanPoint,
} from '../wall/wall-drafting'
export type FencePlanPoint = WallPlanPoint
@@ -7,19 +7,129 @@ import {
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import {
createFenceOnCurrentLevel,
snapFenceDraftPoint,
type FencePlanPoint,
snapFenceDraftPoint,
} from './fence-drafting'
const FENCE_PREVIEW_HEIGHT = 1.8
const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22
const DRAFT_ANGLE_LABEL_Y = 0.28
type DraftAngleLabel = {
id: string
label: string
position: [number, number, number]
}
type DraftMeasurementState = {
lengthLabel: string
lengthPosition: [number, number, number]
angleLabels: DraftAngleLabel[]
} | null
type SegmentLike = {
id: string
start: FencePlanPoint
end: FencePlanPoint
curveOffset?: number
}
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
function getDraftAngleLabels(
start: FencePlanPoint,
end: FencePlanPoint,
segments: SegmentLike[],
): DraftAngleLabel[] {
const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
const endpoints = [
{ id: 'start', point: start, draftVector: draftFromStart },
{ id: 'end', point: end, draftVector: draftFromEnd },
]
const labels: DraftAngleLabel[] = []
for (const endpoint of endpoints) {
const connectedSegment = segments.find((segment) =>
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
)
if (!connectedSegment) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
if (angle === null) continue
labels.push({
id: endpoint.id,
label: formatAngleRadians(angle),
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
})
}
return labels
}
function getDraftMeasurementState(
start: FencePlanPoint,
end: FencePlanPoint,
segments: SegmentLike[],
unit: 'metric' | 'imperial',
): DraftMeasurementState {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < 0.01) return null
return {
lengthLabel: formatMeasurement(length, unit),
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
angleLabels: getDraftAngleLabels(start, end, segments),
}
}
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
return [
...walls.map((wall) => ({
id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
})),
...fences.map((fence) => ({
id: fence.id,
start: fence.start,
end: fence.end,
curveOffset: fence.curveOffset,
})),
]
}
const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
@@ -70,12 +180,14 @@ const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } =
}
export const FenceTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const cursorRef = useRef<Group>(null)
const previewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
useEffect(() => {
let previousFenceEnd: [number, number] | null = null
@@ -107,9 +219,18 @@ export const FenceTool: React.FC = () => {
previousFenceEnd = currentFenceEnd
updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
setDraftMeasurement(
getDraftMeasurementState(
[startingPoint.current.x, startingPoint.current.z],
snappedLocal,
getReferenceSegments(walls, fences),
unit,
),
)
} else {
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
setDraftMeasurement(null)
}
}
@@ -123,6 +244,7 @@ export const FenceTool: React.FC = () => {
endingPoint.current.copy(startingPoint.current)
buildingState.current = 1
previewRef.current.visible = true
setDraftMeasurement(null)
} else {
const snappedEnd = snapFenceDraftPoint({
point: localClick,
@@ -137,6 +259,7 @@ export const FenceTool: React.FC = () => {
createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
previewRef.current.visible = false
buildingState.current = 0
setDraftMeasurement(null)
}
}
@@ -153,6 +276,7 @@ export const FenceTool: React.FC = () => {
markToolCancelConsumed()
buildingState.current = 0
previewRef.current.visible = false
setDraftMeasurement(null)
}
}
@@ -169,7 +293,7 @@ export const FenceTool: React.FC = () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [])
}, [unit])
return (
<group>
@@ -185,6 +309,38 @@ export const FenceTool: React.FC = () => {
transparent
/>
</mesh>
{draftMeasurement && (
<>
<DraftMeasurementLabel
label={draftMeasurement.lengthLabel}
position={draftMeasurement.lengthPosition}
/>
{draftMeasurement.angleLabels.map((angleLabel) => (
<DraftMeasurementLabel
key={angleLabel.id}
label={angleLabel.label}
position={angleLabel.position}
/>
))}
</>
)}
</group>
)
}
function DraftMeasurementLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -2,32 +2,113 @@
import {
type AnyNodeId,
type FenceNode,
type WallNode,
emitter,
type FenceNode,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
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 {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import { isWallLongEnough } from '../wall/wall-drafting'
import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
type SegmentLike = {
id: string
start: FencePlanPoint
end: FencePlanPoint
curveOffset?: number
}
type AngleLabelState = {
label: string
position: [number, number, number]
} | null
function getEndpointAngleLabel(args: {
preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number }
segments: SegmentLike[]
nodeId: FenceNode['id']
}): AngleLabelState {
const { preview, segments, nodeId } = args
const endpoints = [
{
point: preview.start,
},
{
point: preview.end,
},
]
const targetSegment: SegmentLike = {
id: nodeId,
start: preview.start,
end: preview.end,
curveOffset: preview.curveOffset,
}
for (const endpoint of endpoints) {
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
if (!targetReference) continue
const connectedSegment = segments.find(
(segment) =>
segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
)
if (!connectedSegment) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
if (angle === null) continue
return {
label: formatAngleRadians(angle),
position: [endpoint.point[0], 0.34, endpoint.point[1]],
}
}
return null
}
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
return [
...walls.map((wall) => ({
id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
})),
...fences.map((fence) => ({
id: fence.id,
start: fence.start,
end: fence.end,
curveOffset: fence.curveOffset,
})),
]
}
type LinkedFenceSnapshot = {
id: FenceNode['id']
start: FencePlanPoint
end: FencePlanPoint
curveOffset?: number
}
function getLinkedFenceSnapshots(args: {
@@ -62,6 +143,7 @@ function getLinkedFenceSnapshots(args: {
id: node.id,
start: [...node.start] as FencePlanPoint,
end: [...node.end] as FencePlanPoint,
curveOffset: node.curveOffset,
})
}
@@ -77,6 +159,7 @@ function getLinkedFenceUpdates(
) {
return linkedFences.map((fence) => ({
id: fence.id,
curveOffset: fence.curveOffset,
start: samePoint(fence.start, originalStart)
? nextStart
: samePoint(fence.start, originalEnd)
@@ -112,6 +195,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
}),
)
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
@@ -158,27 +242,35 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedFences
? []
: getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
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,
)),
])
setAngleLabel(
getEndpointAngleLabel({
preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
nodeId,
}),
)
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
}
const restoreOriginal = () => {
const restoreOriginal = (clearAngleLabel = true) => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
if (clearAngleLabel) {
setAngleLabel(null)
}
}
const onGridMove = (event: GridEvent) => {
@@ -240,6 +332,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
setAngleLabel(null)
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
@@ -248,6 +341,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
setAngleLabel(null)
markToolCancelConsumed()
exitMoveMode()
}
@@ -290,7 +384,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
return () => {
if (!wasCommitted) {
restoreOriginal()
restoreOriginal(false)
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
@@ -322,6 +416,23 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
</div>
</div>
</Html>
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
</group>
)
}
function EndpointAngleLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -1,6 +1,7 @@
import type {
BuildingNode,
CeilingNode,
ColumnNode,
DoorNode,
FenceNode,
ItemNode,
@@ -18,6 +19,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
import { MoveColumnTool } from '../column/move-column-tool'
import { MoveDoorTool } from '../door/move-door-tool'
import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRoofTool } from '../roof/move-roof-tool'
@@ -100,6 +102,7 @@ export const MoveTool: React.FC<{
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
@@ -0,0 +1,156 @@
import {
type FenceNode,
getWallCurveFrameAt,
getWallCurveLength,
isCurvedWall,
type WallNode,
} from '@pascal-app/core'
export type PlanPoint = [number, number]
export type SegmentAngleLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
export type SegmentAngleReference = {
vector: PlanPoint
orientation: 'directed' | 'axis'
}
const POINT_MATCH_TOLERANCE = 1e-5
const SEGMENT_POINT_TOLERANCE = 0.15
const CURVE_TANGENT_SAMPLE_SPACING = 0.08
function distanceSquared(a: PlanPoint, b: PlanPoint) {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
return dx * dx + dz * dz
}
function pointsMatch(a: PlanPoint, b: PlanPoint, tolerance = POINT_MATCH_TOLERANCE) {
return distanceSquared(a, b) <= tolerance * tolerance
}
function getProjectedPointOnSegment(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null {
const [x1, z1] = segment.start
const [x2, z2] = segment.end
const dx = x2 - x1
const dz = z2 - z1
const lengthSquared = dx * dx + dz * dz
if (lengthSquared < 1e-9) {
return null
}
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
if (t <= 0 || t >= 1) {
return null
}
return [x1 + dx * t, z1 + dz * t]
}
function getCurveTangentAtPoint(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null {
const curveLength = getWallCurveLength(segment)
const sampleCount = Math.max(24, Math.ceil(curveLength / CURVE_TANGENT_SAMPLE_SPACING))
let best: { distance: number; tangent: PlanPoint } | null = null
for (let index = 0; index <= sampleCount; index += 1) {
const frame = getWallCurveFrameAt(segment, index / sampleCount)
const candidate: PlanPoint = [frame.point.x, frame.point.y]
const distance = distanceSquared(point, candidate)
if (best && distance >= best.distance) {
continue
}
best = {
distance,
tangent: [frame.tangent.x, frame.tangent.y],
}
}
if (!best || best.distance > SEGMENT_POINT_TOLERANCE * SEGMENT_POINT_TOLERANCE) {
return null
}
return best.tangent
}
export function formatAngleRadians(angle: number) {
return `${Math.round((angle * 180) / Math.PI)}°`
}
export function getAngleBetweenVectors(first: PlanPoint, second: PlanPoint): number | null {
const firstLength = Math.hypot(first[0], first[1])
const secondLength = Math.hypot(second[0], second[1])
if (firstLength < 1e-6 || secondLength < 1e-6) return null
const dot = first[0] * second[0] + first[1] * second[1]
const cosine = Math.min(1, Math.max(-1, dot / (firstLength * secondLength)))
return Math.acos(cosine)
}
export function getAngleToSegmentReference(
vector: PlanPoint,
reference: SegmentAngleReference,
): number | null {
const angle = getAngleBetweenVectors(vector, reference.vector)
if (angle === null || reference.orientation === 'directed') {
return angle
}
const reverseAngle = getAngleBetweenVectors(vector, [-reference.vector[0], -reference.vector[1]])
if (reverseAngle === null) {
return angle
}
return Math.min(angle, reverseAngle)
}
export function getSegmentAngleReferenceAtPoint(
point: PlanPoint,
segment: SegmentAngleLike,
): SegmentAngleReference | null {
if (pointsMatch(point, segment.start)) {
const frame = getWallCurveFrameAt(segment, 0)
return {
vector: [frame.tangent.x, frame.tangent.y],
orientation: 'directed',
}
}
if (pointsMatch(point, segment.end)) {
const frame = getWallCurveFrameAt(segment, 1)
return {
vector: [-frame.tangent.x, -frame.tangent.y],
orientation: 'directed',
}
}
if (isCurvedWall(segment)) {
const tangent = getCurveTangentAtPoint(point, segment)
return tangent
? {
vector: tangent,
orientation: 'axis',
}
: null
}
const projected = getProjectedPointOnSegment(point, segment)
if (!projected || !pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE)) {
return null
}
return {
vector: [segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]],
orientation: 'axis',
}
}
@@ -10,6 +10,7 @@ import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool'
import { ColumnTool } from './column/column-tool'
import { DoorTool } from './door/door-tool'
import { CurveFenceTool } from './fence/curve-fence-tool'
import { FenceTool } from './fence/fence-tool'
@@ -126,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] })
}
@@ -134,7 +135,7 @@ export const ToolManager: React.FC = () => {
<>
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{/* World-space tools: site boundary and building movement operate in world coordinates */}
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handleSpawnSelected} />}
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
{/* Building-local group: all other tools are relative to the selected building.
Cursor visuals set positions in building-local space; this group applies the
@@ -159,12 +160,15 @@ export const ToolManager: React.FC = () => {
{curvingWall && <CurveWallTool node={curvingWall} />}
{curvingFence && <CurveFenceTool node={curvingFence} />}
{movingNode && movingNode.type !== 'building' && (
<MoveTool onSpawnMoved={handleSpawnSelected} />
<MoveTool onSpawnMoved={handlePlacedNodeSelected} />
)}
{!movingNode && showBuildTool && tool === 'spawn' && (
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handleSpawnSelected} />
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
{!movingNode && showBuildTool && tool === 'column' && (
<ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
</group>
</>
)
@@ -9,27 +9,87 @@ import {
useScene,
type WallNode,
} from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import {
isWallLongEnough,
snapWallDraftPoint,
type WallPlanPoint,
} from './wall-drafting'
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
type WallSegmentLike = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
curveOffset?: number
}
type AngleLabelState = {
label: string
position: [number, number, number]
} | null
function getEndpointAngleLabel(args: {
preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number }
walls: WallSegmentLike[]
nodeId: WallNode['id']
}): AngleLabelState {
const { preview, walls, nodeId } = args
const endpoints = [
{
point: preview.start,
},
{
point: preview.end,
},
]
const targetSegment: WallSegmentLike = {
id: nodeId,
start: preview.start,
end: preview.end,
curveOffset: preview.curveOffset,
}
for (const endpoint of endpoints) {
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
if (!targetReference) continue
const connectedWall = walls.find(
(wall) =>
wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
)
if (!connectedWall) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
if (angle === null) continue
return {
label: formatAngleRadians(angle),
position: [endpoint.point[0], 0.34, endpoint.point[1]],
}
}
return null
}
type LinkedWallSnapshot = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
curveOffset?: number
}
function getLinkedWallSnapshots(args: {
@@ -64,6 +124,7 @@ function getLinkedWallSnapshots(args: {
id: node.id,
start: [...node.start] as WallPlanPoint,
end: [...node.end] as WallPlanPoint,
curveOffset: node.curveOffset,
})
}
@@ -79,6 +140,7 @@ function getLinkedWallUpdates(
) {
return linkedWalls.map((wall) => ({
id: wall.id,
curveOffset: wall.curveOffset,
start: samePoint(wall.start, originalStart)
? nextStart
: samePoint(wall.start, originalEnd)
@@ -114,6 +176,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
}),
)
const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
@@ -155,24 +218,43 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedWalls
? []
: getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
applyNodePreview([
{ id: nodeId, start: nextStart, end: nextEnd },
...(detachLinkedWalls
? []
: getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)),
])
setAngleLabel(
getEndpointAngleLabel({
preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
walls: [
...levelWalls.map((wall) => ({
id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
})),
...linkedUpdates,
],
nodeId,
}),
)
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
}
const restoreOriginal = () => {
applyNodePreview([{ id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current])
const restoreOriginal = (clearAngleLabel = true) => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
if (clearAngleLabel) {
setAngleLabel(null)
}
}
const onGridMove = (event: GridEvent) => {
@@ -235,6 +317,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
setAngleLabel(null)
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
@@ -243,6 +326,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
setAngleLabel(null)
markToolCancelConsumed()
exitMoveMode()
}
@@ -285,7 +369,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
return () => {
if (!wasCommitted) {
restoreOriginal()
restoreOriginal(false)
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
@@ -317,6 +401,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
</div>
</div>
</Html>
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
</group>
)
}
function EndpointAngleLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -3,7 +3,10 @@ import {
type AnyNodeId,
type DoorNode,
getScaledDimensions,
getWallCurveFrameAt,
getWallCurveLength,
type ItemNode,
isCurvedWall,
useScene,
type WallNode,
WallNode as WallSchema,
@@ -62,10 +65,10 @@ export function snapPointTo45Degrees(
const snappedAngle = Math.round(angle / angleStep) * angleStep
const distance = Math.sqrt(dx * dx + dz * dz)
return snapPointToGrid([
start[0] + Math.cos(snappedAngle) * distance,
start[1] + Math.sin(snappedAngle) * distance,
], step)
return snapPointToGrid(
[start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance],
step,
)
}
export function getWallAngleSnapStep(step = getWallGridStep()): number {
@@ -336,11 +339,17 @@ export function findWallSnapTarget(
continue
}
const candidates: Array<WallPlanPoint | null> = [
wall.start,
wall.end,
projectPointOntoWall(point, wall),
]
const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
if (isCurvedWall(wall)) {
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
for (let index = 0; index <= sampleCount; index += 1) {
const frame = getWallCurveFrameAt(wall, index / sampleCount)
candidates.push([frame.point.x, frame.point.y])
}
} else {
candidates.push(projectPointOntoWall(point, wall))
}
for (const candidate of candidates) {
if (!candidate) {
continue
@@ -1,14 +1,100 @@
import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
} from '../shared/segment-angle'
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
const WALL_HEIGHT = 2.5
const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
const DRAFT_ANGLE_LABEL_Y = 0.28
type DraftAngleLabel = {
id: string
label: string
position: [number, number, number]
}
type DraftMeasurementState = {
lengthLabel: string
lengthPosition: [number, number, number]
angleLabels: DraftAngleLabel[]
} | null
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
function getDraftAngleLabels(
start: WallPlanPoint,
end: WallPlanPoint,
walls: WallNode[],
): DraftAngleLabel[] {
const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
const endpoints = [
{ id: 'start', point: start, draftVector: draftFromStart },
{ id: 'end', point: end, draftVector: draftFromEnd },
]
const labels: DraftAngleLabel[] = []
for (const endpoint of endpoints) {
const connectedWall = walls.find((wall) =>
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
)
if (!connectedWall) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
if (!connectedReference) continue
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
if (angle === null) continue
labels.push({
id: endpoint.id,
label: formatAngleRadians(angle),
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
})
}
return labels
}
function getDraftMeasurementState(
start: WallPlanPoint,
end: WallPlanPoint,
walls: WallNode[],
unit: 'metric' | 'imperial',
): DraftMeasurementState {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < 0.01) return null
return {
lengthLabel: formatMeasurement(length, unit),
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
angleLabels: getDraftAngleLabels(start, end, walls),
}
}
/**
* Update wall preview mesh geometry to create a vertical plane between two points
@@ -67,12 +153,14 @@ const getCurrentLevelWalls = (): WallNode[] => {
}
export const WallTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
useEffect(() => {
let gridPosition: WallPlanPoint = [0, 0]
@@ -109,9 +197,18 @@ export const WallTool: React.FC = () => {
previousWallEnd = currentWallEnd
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
setDraftMeasurement(
getDraftMeasurementState(
[startingPoint.current.x, startingPoint.current.z],
snappedLocal,
walls,
unit,
),
)
} else {
// Not drawing a wall yet, show the snapped anchor point.
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
setDraftMeasurement(null)
}
}
@@ -126,6 +223,7 @@ export const WallTool: React.FC = () => {
endingPoint.current.copy(startingPoint.current)
buildingState.current = 1
wallPreviewRef.current.visible = true
setDraftMeasurement(null)
} else if (buildingState.current === 1) {
const snappedEnd = snapWallDraftPoint({
point: localClick,
@@ -140,6 +238,7 @@ export const WallTool: React.FC = () => {
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
wallPreviewRef.current.visible = false
buildingState.current = 0
setDraftMeasurement(null)
}
}
@@ -160,6 +259,7 @@ export const WallTool: React.FC = () => {
markToolCancelConsumed()
buildingState.current = 0
wallPreviewRef.current.visible = false
setDraftMeasurement(null)
}
}
@@ -176,7 +276,7 @@ export const WallTool: React.FC = () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [])
}, [unit])
return (
<group>
@@ -195,6 +295,38 @@ export const WallTool: React.FC = () => {
transparent
/>
</mesh>
{draftMeasurement && (
<>
<DraftMeasurementLabel
label={draftMeasurement.lengthLabel}
position={draftMeasurement.lengthPosition}
/>
{draftMeasurement.angleLabels.map((angleLabel) => (
<DraftMeasurementLabel
key={angleLabel.id}
label={angleLabel.label}
position={angleLabel.position}
/>
))}
</>
)}
</group>
)
}
function DraftMeasurementLabel({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
)
}
@@ -24,6 +24,7 @@ export const tools: ToolConfig[] = [
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,715 @@
'use client'
import {
type AnyNode,
COLUMN_PRESETS,
type ColumnNode,
type ColumnPresetId,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-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 { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
const SELECT_CLASS =
'h-10 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground outline-none transition-colors hover:bg-[#3e3e3e] focus:ring-1 focus:ring-border'
const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset]) => ({
value: value as ColumnPresetId,
label: preset.label,
}))
const COLUMN_PROPORTION_PRESETS = {
slender: {
label: 'Slender',
height: 3.6,
width: 0.34,
baseHeight: 0.18,
capitalHeight: 0.16,
baseWidthScale: 1.18,
capitalWidthScale: 1.16,
edgeSoftness: 0.02,
},
standard: {
label: 'Standard',
height: 2.9,
width: 0.44,
baseHeight: 0.22,
capitalHeight: 0.2,
baseWidthScale: 1.24,
capitalWidthScale: 1.22,
edgeSoftness: 0.025,
},
heavy: {
label: 'Heavy',
height: 3,
width: 0.58,
baseHeight: 0.28,
capitalHeight: 0.26,
baseWidthScale: 1.34,
capitalWidthScale: 1.3,
edgeSoftness: 0.035,
},
stout: {
label: 'Short / Stout',
height: 2.2,
width: 0.62,
baseHeight: 0.3,
capitalHeight: 0.28,
baseWidthScale: 1.38,
capitalWidthScale: 1.34,
edgeSoftness: 0.04,
},
} as const
type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(([value, preset]) => ({
value: value as ColumnProportionPresetId,
label: preset.label,
}))
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function presetUpdates(presetId: ColumnPresetId): Partial<ColumnNode> {
const { label, ...preset } = COLUMN_PRESETS[presetId]
return {
name: label,
...preset,
}
}
function proportionUpdates(
node: ColumnNode,
presetId: ColumnProportionPresetId,
): Partial<ColumnNode> {
const preset = COLUMN_PROPORTION_PRESETS[presetId]
const depth =
node.crossSection === 'rectangular'
? clamp(preset.width * (node.depth / Math.max(node.width, 0.01)), 0.12, 1.6)
: preset.width
const shaftCornerRadius = Math.min(node.shaftCornerRadius ?? 0.035, preset.width * 0.18)
return {
height: preset.height,
width: preset.width,
depth,
radius: preset.width / 2,
baseHeight: preset.baseHeight,
capitalHeight: preset.capitalHeight,
baseWidthScale: preset.baseWidthScale,
baseDepthScale: preset.baseWidthScale,
capitalWidthScale: preset.capitalWidthScale,
capitalDepthScale: preset.capitalWidthScale,
edgeSoftness: preset.edgeSoftness,
shaftCornerRadius,
}
}
function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial<ColumnNode> {
if (shaftProfile === 'tapered') {
return {
shaftProfile,
shaftTaper: 0.14,
shaftBulge: 0,
shaftStartScale: 0.82,
shaftEndScale: 0.72,
shaftSegmentCount: 32,
}
}
if (shaftProfile === 'bulged') {
return {
shaftProfile,
shaftTaper: 0,
shaftBulge: 0.12,
shaftStartScale: 0.68,
shaftEndScale: 0.68,
shaftSegmentCount: 32,
}
}
if (shaftProfile === 'hourglass') {
return {
shaftProfile,
shaftTaper: 0,
shaftBulge: 0.12,
shaftStartScale: 0.84,
shaftEndScale: 0.84,
shaftSegmentCount: 32,
}
}
return {
shaftProfile,
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 0.72,
shaftEndScale: 0.72,
shaftSegmentCount: 1,
shaftTwistStep: 0,
}
}
export function ColumnPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ColumnNode | undefined) : undefined,
)
const handleUpdate = useCallback(
(updates: Partial<ColumnNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleDelete = useCallback(() => {
if (!selectedId) return
sfxEmitter.emit('sfx:structure-delete')
deleteNode(selectedId as AnyNode['id'])
setSelection({ selectedIds: [] })
}, [deleteNode, selectedId, setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null
const shaftProfile = node.shaftProfile ?? 'straight'
return (
<PanelWrapper icon="/icons/column.png" onClose={handleClose} title={node.name || 'Column'} width={300}>
<PanelSection title="Preset">
<select
className={SELECT_CLASS}
onChange={(event) => {
if (!event.target.value) return
handleUpdate(presetUpdates(event.target.value as ColumnPresetId))
}}
value=""
>
<option value="">Apply preset...</option>
{COLUMN_PRESET_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</PanelSection>
<PanelSection title="Shape">
<select
className={SELECT_CLASS}
onChange={(event) => handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })}
value={node.crossSection}
>
<option value="round">Round</option>
<option value="square">Square</option>
<option value="rectangular">Rectangular</option>
</select>
<SliderControl
label="Edge Softness"
max={0.12}
min={0}
onChange={(value) => handleUpdate({ edgeSoftness: value })}
precision={3}
step={0.005}
unit="m"
value={node.edgeSoftness ?? 0.025}
/>
{(node.crossSection === 'square' || node.crossSection === 'rectangular') && (
<SliderControl
label="Shaft Corner Radius"
max={0.3}
min={0}
onChange={(value) => handleUpdate({ shaftCornerRadius: value })}
precision={3}
step={0.005}
unit="m"
value={node.shaftCornerRadius ?? 0.035}
/>
)}
</PanelSection>
<PanelSection title="Dimensions">
<select
className={SELECT_CLASS}
onChange={(event) => {
if (!event.target.value) return
handleUpdate(proportionUpdates(node, event.target.value as ColumnProportionPresetId))
}}
value=""
>
<option value="">Apply proportion...</option>
{COLUMN_PROPORTION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<SliderControl
label="Height"
max={6}
min={0.8}
onChange={(value) => handleUpdate({ height: value })}
precision={2}
step={0.05}
unit="m"
value={node.height}
/>
<SliderControl
label="Width"
max={1.6}
min={0.12}
onChange={(value) =>
handleUpdate({
width: value,
radius: value / 2,
...(node.crossSection === 'rectangular' ? {} : { depth: value }),
})
}
precision={2}
step={0.02}
unit="m"
value={node.width}
/>
{node.crossSection === 'rectangular' && (
<SliderControl
label="Depth"
max={1.6}
min={0.12}
onChange={(value) => handleUpdate({ depth: value })}
precision={2}
step={0.02}
unit="m"
value={node.depth}
/>
)}
</PanelSection>
<PanelSection title="Shaft">
<select
className={SELECT_CLASS}
onChange={(event) => handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))}
value={shaftProfile}
>
<option value="straight">Straight</option>
<option value="tapered">Tapered</option>
<option value="bulged">Bulged</option>
<option value="hourglass">Hourglass</option>
</select>
{shaftProfile === 'straight' && (
<SliderControl
label="Shaft Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.72}
/>
)}
{shaftProfile === 'tapered' && (
<>
<SliderControl
label="Bottom Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.82}
/>
<SliderControl
label="Top Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftEndScale ?? 0.72}
/>
<SliderControl
label="Taper"
max={0.45}
min={0}
onChange={(value) => handleUpdate({ shaftTaper: value })}
precision={2}
step={0.01}
value={node.shaftTaper ?? 0.14}
/>
</>
)}
{shaftProfile === 'bulged' && (
<>
<SliderControl
label="End Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.68}
/>
<SliderControl
label="Bulge"
max={0.35}
min={0}
onChange={(value) => handleUpdate({ shaftBulge: value })}
precision={2}
step={0.01}
value={node.shaftBulge ?? 0.12}
/>
</>
)}
{shaftProfile === 'hourglass' && (
<>
<SliderControl
label="End Width"
max={1.2}
min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
precision={2}
step={0.02}
value={node.shaftStartScale ?? 0.84}
/>
<SliderControl
label="Waist"
max={0.35}
min={0}
onChange={(value) => handleUpdate({ shaftBulge: value })}
precision={2}
step={0.01}
value={node.shaftBulge ?? 0.12}
/>
</>
)}
<SliderControl
label="Segment Twist"
max={90}
min={-90}
onChange={(value) =>
handleUpdate({
shaftTwistStep: value,
...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8
? { shaftSegmentCount: 12 }
: {}),
})
}
precision={0}
step={5}
unit="°"
value={node.shaftTwistStep ?? 0}
/>
{Math.abs(node.shaftTwistStep ?? 0) > 0.001 && (
<SliderControl
label="Twist Segments"
max={48}
min={4}
onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })}
precision={0}
step={1}
value={node.shaftSegmentCount ?? 12}
/>
)}
<SliderControl
label="Ring Pairs"
max={4}
min={0}
onChange={(value) =>
handleUpdate({
ringCount: Math.round(value) * 2,
ringPlacement: 'ends',
ringSpread: node.ringSpread ?? 0.16,
ringThickness: node.ringThickness ?? 0.055,
})
}
precision={0}
step={1}
value={Math.ceil((node.ringCount ?? 0) / 2)}
/>
{(node.ringCount ?? 0) > 0 && (
<SliderControl
label="Ring Thickness"
max={0.14}
min={0.01}
onChange={(value) => handleUpdate({ ringThickness: value })}
precision={3}
step={0.005}
unit="m"
value={node.ringThickness ?? 0.055}
/>
)}
{(node.ringCount ?? 0) > 0 && (
<SliderControl
label="Ring Spread"
max={0.45}
min={0.04}
onChange={(value) => handleUpdate({ ringSpread: value, ringPlacement: 'ends' })}
precision={2}
step={0.01}
value={node.ringSpread ?? 0.16}
/>
)}
</PanelSection>
<PanelSection title="Ends">
<select
className={SELECT_CLASS}
onChange={(event) => {
const capitalStyle = event.target.value as ColumnNode['capitalStyle']
handleUpdate({
capitalStyle,
...(capitalStyle === 'none'
? {}
: {
capitalHeight: Math.max(node.capitalHeight, 0.12),
capitalTierCount: capitalStyle === 'stepped' ? Math.max(node.capitalTierCount ?? 3, 3) : node.capitalTierCount,
capitalWidthScale: Math.max(node.capitalWidthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
capitalDepthScale: Math.max(node.capitalDepthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
capitalStepSpread: capitalStyle === 'stepped' ? Math.max(node.capitalStepSpread ?? 0.34, 0.34) : node.capitalStepSpread,
}),
})
}}
value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')}
>
<option value="none">No Top</option>
<option value="simple">Simple Top</option>
<option value="stepped">Stepped Top</option>
<option value="rounded">Rounded Top</option>
</select>
{node.capitalStyle !== 'none' && (
<SliderControl
label="Top Height"
max={0.8}
min={0.06}
onChange={(value) => handleUpdate({ capitalHeight: value })}
precision={2}
step={0.02}
unit="m"
value={node.capitalHeight}
/>
)}
{node.capitalStyle !== 'none' && (
<SliderControl
label="Top Width"
max={2.4}
min={0.6}
onChange={(value) =>
handleUpdate({
capitalWidthScale: value,
...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }),
})
}
precision={2}
step={0.02}
value={node.capitalWidthScale ?? 1.28}
/>
)}
{node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && (
<SliderControl
label="Top Depth"
max={2.4}
min={0.6}
onChange={(value) => handleUpdate({ capitalDepthScale: value })}
precision={2}
step={0.02}
value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28}
/>
)}
{node.capitalStyle === 'stepped' && (
<SliderControl
label="Top Tiers"
max={8}
min={3}
onChange={(value) => handleUpdate({ capitalTierCount: Math.round(value) })}
precision={0}
step={1}
value={node.capitalTierCount ?? 3}
/>
)}
{node.capitalStyle === 'stepped' && (
<SliderControl
label="Top Step Spread"
max={0.9}
min={0.05}
onChange={(value) => handleUpdate({ capitalStepSpread: value })}
precision={2}
step={0.01}
value={node.capitalStepSpread ?? 0.34}
/>
)}
<select
className={`${SELECT_CLASS} mt-2`}
onChange={(event) => {
const baseStyle = event.target.value as ColumnNode['baseStyle']
handleUpdate({
baseStyle,
...(baseStyle === 'none'
? {}
: {
baseHeight: Math.max(node.baseHeight, 0.12),
baseTierCount: baseStyle === 'stepped-square' ? Math.max(node.baseTierCount ?? 3, 3) : node.baseTierCount,
baseWidthScale: Math.max(node.baseWidthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
baseDepthScale: Math.max(node.baseDepthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
baseStepSpread: baseStyle === 'stepped-square' ? Math.max(node.baseStepSpread ?? 0.34, 0.34) : node.baseStepSpread,
basePlinthHeightRatio: baseStyle === 'round-rings' ? (node.basePlinthHeightRatio ?? 0.44) : node.basePlinthHeightRatio,
baseRoundBandScale: baseStyle === 'round-rings' ? (node.baseRoundBandScale ?? 0.92) : node.baseRoundBandScale,
baseNeckScale: baseStyle === 'round-rings' ? (node.baseNeckScale ?? 0.72) : node.baseNeckScale,
}),
})
}}
value={node.baseStyle ?? 'square-plinth'}
>
<option value="none">No Bottom</option>
<option value="simple-square">Simple Block Bottom</option>
<option value="square-plinth">Square Plinth Bottom</option>
<option value="stepped-square">Stepped Bottom</option>
<option value="round-rings">Rounded Bottom</option>
</select>
{node.baseStyle !== 'none' && (
<SliderControl
label="Bottom Height"
max={0.8}
min={0.06}
onChange={(value) => handleUpdate({ baseHeight: value })}
precision={2}
step={0.02}
unit="m"
value={node.baseHeight}
/>
)}
{node.baseStyle !== 'none' && (
<SliderControl
label="Bottom Width"
max={2.4}
min={0.6}
onChange={(value) =>
handleUpdate({
baseWidthScale: value,
...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }),
})
}
precision={2}
step={0.02}
value={node.baseWidthScale ?? 1.24}
/>
)}
{node.baseStyle !== 'none' && node.crossSection === 'rectangular' && (
<SliderControl
label="Bottom Depth"
max={2.4}
min={0.6}
onChange={(value) => handleUpdate({ baseDepthScale: value })}
precision={2}
step={0.02}
value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24}
/>
)}
{node.baseStyle === 'round-rings' && (
<SliderControl
label="Plinth Thickness"
max={0.7}
min={0.2}
onChange={(value) => handleUpdate({ basePlinthHeightRatio: value })}
precision={2}
step={0.01}
value={node.basePlinthHeightRatio ?? 0.44}
/>
)}
{node.baseStyle === 'round-rings' && (
<SliderControl
label="Round Band Width"
max={1.2}
min={0.5}
onChange={(value) => handleUpdate({ baseRoundBandScale: value })}
precision={2}
step={0.01}
value={node.baseRoundBandScale ?? 0.92}
/>
)}
{node.baseStyle === 'round-rings' && (
<SliderControl
label="Neck Width"
max={1}
min={0.35}
onChange={(value) => handleUpdate({ baseNeckScale: value })}
precision={2}
step={0.01}
value={node.baseNeckScale ?? 0.72}
/>
)}
{node.baseStyle === 'stepped-square' && (
<SliderControl
label="Bottom Tiers"
max={8}
min={3}
onChange={(value) => handleUpdate({ baseTierCount: Math.round(value) })}
precision={0}
step={1}
value={node.baseTierCount ?? 3}
/>
)}
{node.baseStyle === 'stepped-square' && (
<SliderControl
label="Bottom Step Spread"
max={0.9}
min={0.05}
onChange={(value) => handleUpdate({ baseStepSpread: value })}
precision={2}
step={0.01}
value={node.baseStepSpread ?? 0.34}
/>
)}
</PanelSection>
<PanelSection title="Transform">
<SliderControl
label="Yaw"
max={180}
min={-180}
onChange={(value) => handleUpdate({ rotation: (value * Math.PI) / 180 })}
precision={0}
step={1}
unit="°"
value={Math.round((node.rotation * 180) / Math.PI)}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
<ActionButton
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
icon={<Trash2 className="h-4 w-4" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
@@ -333,6 +333,7 @@ export function DoorPanel() {
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle'
const doorShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const openingRadiusMode = node.openingRadiusMode ?? 'all'
const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15]
const cornerRadius = node.cornerRadius ?? 0.15
@@ -737,6 +738,108 @@ export function DoorPanel() {
/>
</PanelSection>
{!isOpening && (
<PanelSection title="Top Shape">
<div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl
onChange={(v) =>
handleUpdate({
openingShape: v as DoorNode['openingShape'],
...(v === 'rounded'
? {
openingRadiusMode,
openingTopRadii,
cornerRadius: Math.min(cornerRadius, maxRoundedRadius),
openingRevealRadius,
}
: {}),
...(v === 'arch' ? { archHeight } : {}),
})
}
options={[
{ label: 'Rect', value: 'rectangle' },
{ label: 'Rounded', value: 'rounded' },
{ label: 'Arch', value: 'arch' },
]}
value={doorShape}
/>
</div>
{doorShape === 'rounded' && (
<>
<div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl
onChange={(v) =>
handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] })
}
options={[
{ label: 'All', value: 'all' },
{ label: 'Individual', value: 'individual' },
]}
value={openingRadiusMode}
/>
</div>
{openingRadiusMode === 'all' ? (
<SliderControl
label="Corner Radius"
max={maxRoundedRadius}
min={0}
onChange={(v) => previewDoorUpdate('cornerRadius', v)}
onCommit={(v) => commitDoorPreview('cornerRadius', v)}
precision={2}
step={0.05}
unit="m"
value={Math.round(cornerRadius * 100) / 100}
/>
) : (
<>
{[
['Top Left', 0],
['Top Right', 1],
].map(([label, index]) => (
<SliderControl
key={label}
label={label}
max={maxRoundedRadius}
min={0}
onChange={(v) => setOpeningTopRadius(index as number, v)}
onCommit={(v) => setOpeningTopRadius(index as number, v, true)}
precision={2}
step={0.05}
unit="m"
value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100}
/>
))}
</>
)}
<SliderControl
label="Reveal Radius"
max={0.08}
min={0}
onChange={(v) => previewDoorUpdate('openingRevealRadius', v)}
onCommit={(v) => commitDoorPreview('openingRevealRadius', v)}
precision={3}
step={0.005}
unit="m"
value={Math.round(openingRevealRadius * 1000) / 1000}
/>
</>
)}
{doorShape === 'arch' && (
<SliderControl
label="Arch Height"
max={node.height}
min={0.05}
onChange={(v) => handleUpdate({ archHeight: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(archHeight * 100) / 100}
/>
)}
</PanelSection>
)}
{isOpening && (
<PanelSection title="Opening Shape">
<div className="flex flex-col gap-2 px-1 pb-1">
@@ -825,6 +928,7 @@ export function DoorPanel() {
min={0.05}
onChange={(v) => handleUpdate({ archHeight: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(archHeight * 100) / 100}
@@ -12,6 +12,7 @@ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
window: { icon: '/icons/window.png', label: 'Window' },
slab: { icon: '/icons/floor.png', label: 'Slab' },
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
column: { icon: '/icons/column.png', label: 'Column' },
fence: { icon: '/icons/fence.png', label: 'Fence' },
roof: { icon: '/icons/roof.png', label: 'Roof' },
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
@@ -5,6 +5,7 @@ import {
type AnyNodeId,
type BuildingNode,
type CeilingNode,
type ColumnNode,
type DoorNode,
type FenceNode,
type ItemNode,
@@ -23,6 +24,7 @@ import { useIsMobile } from '../../../hooks/use-mobile'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CeilingPanel } from './ceiling-panel'
import { ColumnPanel } from './column-panel'
import { DoorPanel } from './door-panel'
import { FencePanel } from './fence-panel'
import { ItemPanel } from './item-panel'
@@ -45,6 +47,7 @@ type MovableNode =
| WindowNode
| DoorNode
| CeilingNode
| ColumnNode
| SlabNode
| WallNode
| FenceNode
@@ -59,6 +62,7 @@ const MOVABLE_TYPES = new Set<string>([
'window',
'door',
'ceiling',
'column',
'slab',
'wall',
'fence',
@@ -90,6 +94,8 @@ function panelForType(type: string | null) {
return <SlabPanel />
case 'ceiling':
return <CeilingPanel />
case 'column':
return <ColumnPanel />
case 'wall':
return <WallPanel />
case 'fence':
@@ -250,6 +256,8 @@ export function PanelManager() {
return <SpawnPanel />
case 'ceiling':
return <CeilingPanel />
case 'column':
return <ColumnPanel />
case 'wall':
return <WallPanel />
case 'fence':
@@ -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(() => {
@@ -64,7 +64,7 @@ function isSameRadiusTuple(
current: [number, number, number, number],
next: [number, number, number, number],
) {
return current.every((value, index) => Math.abs(value - next[index]) < 1e-6)
return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6)
}
export function WindowPanel() {
@@ -267,6 +267,7 @@ export function WindowPanel() {
const normRows = node.rowRatios.map((r) => r / rowSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle'
const windowShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const openingRadiusMode = node.openingRadiusMode ?? 'all'
const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
const cornerRadius = node.cornerRadius ?? 0.15
@@ -457,6 +458,108 @@ export function WindowPanel() {
/>
</PanelSection>
{!isOpening && (
<PanelSection title="Corner Shape">
<SegmentedControl
onChange={(value) =>
handleUpdate({
openingShape: value as WindowNode['openingShape'],
...(value === 'rounded'
? {
openingRadiusMode,
openingCornerRadii,
cornerRadius: Math.min(cornerRadius, maxRoundedRadius),
openingRevealRadius,
}
: {}),
...(value === 'arch' ? { archHeight } : {}),
})
}
options={[
{ value: 'rectangle', label: 'Rect' },
{ value: 'rounded', label: 'Rounded' },
{ value: 'arch', label: 'Arch' },
]}
value={windowShape}
/>
{windowShape === 'rounded' && (
<div className="mt-2 flex flex-col gap-1">
<SegmentedControl
onChange={(value) =>
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
}
options={[
{ value: 'all', label: 'All' },
{ value: 'individual', label: 'Individual' },
]}
value={openingRadiusMode}
/>
{openingRadiusMode === 'all' ? (
<SliderControl
label="Corner Radius"
max={maxRoundedRadius}
min={0}
onChange={(value) => previewWindowUpdate('cornerRadius', value)}
onCommit={(value) => commitWindowPreview('cornerRadius', value)}
precision={2}
step={0.05}
unit="m"
value={Math.round(cornerRadius * 100) / 100}
/>
) : (
<>
{[
['Top Left', 0],
['Top Right', 1],
['Bottom Right', 2],
['Bottom Left', 3],
].map(([label, index]) => (
<SliderControl
key={label}
label={label}
max={maxRoundedRadius}
min={0}
onChange={(value) => setOpeningCornerRadius(index as number, value)}
onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
precision={2}
step={0.05}
unit="m"
value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
/>
))}
</>
)}
<SliderControl
label="Reveal Radius"
max={0.08}
min={0}
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
precision={3}
step={0.005}
unit="m"
value={Math.round(openingRevealRadius * 1000) / 1000}
/>
</div>
)}
{windowShape === 'arch' && (
<div className="mt-2 flex flex-col gap-1">
<SliderControl
label="Arch Height"
max={Math.max(0.05, node.height)}
min={0.05}
onChange={(value) => handleUpdate({ archHeight: value })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(archHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
{isOpening && (
<PanelSection title="Opening Shape">
<SegmentedControl
@@ -538,6 +641,7 @@ export function WindowPanel() {
min={0.05}
onChange={(value) => handleUpdate({ archHeight: value })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(archHeight * 100) / 100}
@@ -0,0 +1,77 @@
import { type AnyNodeId, type ColumnNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useState } from 'react'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
interface ColumnTreeNodeProps {
nodeId: AnyNodeId
depth: number
isLast?: boolean
}
export const ColumnTreeNode = memo(function ColumnTreeNode({
nodeId,
depth,
isLast,
}: ColumnTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const node = useScene((s) => s.nodes[nodeId] as ColumnNode | undefined)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(
e,
nodeId,
useViewer.getState().selection.selectedIds,
setSelection,
)
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
},
[nodeId, setSelection],
)
const defaultName = node?.name || 'Column'
return (
<TreeNodeWrapper
actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth}
expanded={false}
hasChildren={false}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/column.png" width={14} />
}
isHovered={isHovered}
isLast={isLast}
isSelected={isSelected}
isVisible={isVisible}
label={
<InlineRenameInput
defaultName={defaultName}
isEditing={isEditing}
nodeId={nodeId}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
}
nodeId={nodeId}
onClick={handleClick}
onDoubleClick={() => focusTreeNode(nodeId)}
onMouseEnter={() => setHoveredId(nodeId)}
onMouseLeave={() => setHoveredId(null)}
onToggle={() => {}}
/>
)
})
@@ -56,6 +56,7 @@ export function focusTreeNode(nodeId: AnyNodeId) {
import { cn } from '../../../../../lib/utils'
import { BuildingTreeNode } from './building-tree-node'
import { CeilingTreeNode } from './ceiling-tree-node'
import { ColumnTreeNode } from './column-tree-node'
import { DoorTreeNode } from './door-tree-node'
import { FenceTreeNode } from './fence-tree-node'
import { ItemTreeNode } from './item-tree-node'
@@ -84,6 +85,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
case 'ceiling':
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'column':
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'level':
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
case 'slab':
+10
View File
@@ -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<GuideEditorEvents>()
+13 -8
View File
@@ -2,6 +2,7 @@
import {
type CeilingNode,
type ColumnNode,
type FenceNode,
getCatalogMaterialById,
getEffectiveRoofSurfaceMaterial,
@@ -21,7 +22,7 @@ import {
export type PaintableMaterialTarget = Extract<
MaterialTarget,
'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling'
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling'
>
export type SingleSurfaceMaterialRole = 'surface'
@@ -131,10 +132,9 @@ export function buildStairSurfaceMaterialPatch(
}
}
export function buildSingleSurfaceMaterialPatch<TNode extends FenceNode | SlabNode | CeilingNode>(
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<TNode> {
export function buildSingleSurfaceMaterialPatch<
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode,
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
return {
material,
materialPreset,
@@ -170,7 +170,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
materialPreset: surface.materialPreset,
sourceTarget: 'wall',
})
? {
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'wall',
@@ -190,7 +190,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
materialPreset: surface.materialPreset,
sourceTarget: 'roof',
})
? {
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'roof',
@@ -210,7 +210,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
materialPreset: surface.materialPreset,
sourceTarget: 'stair',
})
? {
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'stair',
@@ -220,6 +220,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
if (
(selectedNode.type === 'fence' ||
selectedNode.type === 'column' ||
selectedNode.type === 'slab' ||
selectedNode.type === 'ceiling') &&
selectedMaterialTarget.role === 'surface'
@@ -267,6 +268,10 @@ export function resolvePaintTargetFromSelection(params: {
return 'fence'
}
if (selectedNode.type === 'column') {
return 'column'
}
if (selectedNode.type === 'slab') {
return 'slab'
}
+3
View File
@@ -5,6 +5,7 @@ import {
type AssetInput,
type BuildingNode,
type CeilingNode,
type ColumnNode,
type DoorNode,
type FenceNode,
type ItemNode,
@@ -139,6 +140,7 @@ type EditorState = {
| DoorNode
| FenceNode
| CeilingNode
| ColumnNode
| SlabNode
| WallNode
| RoofNode
@@ -155,6 +157,7 @@ type EditorState = {
| DoorNode
| FenceNode
| CeilingNode
| ColumnNode
| SlabNode
| WallNode
| RoofNode