Fix curved wall and fence angle measurements
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): Point2D[] | null {
|
||||
return fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? 1 : -1
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,11 +242,7 @@ 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
|
||||
previewRef.current = { start: nextStart, end: nextEnd }
|
||||
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: nextStart, end: nextEnd },
|
||||
...(detachLinkedFences
|
||||
const linkedUpdates = detachLinkedFences
|
||||
? []
|
||||
: getLinkedFenceUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
@@ -170,15 +250,27 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)),
|
||||
])
|
||||
)
|
||||
previewRef.current = { start: nextStart, end: nextEnd }
|
||||
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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,11 +218,7 @@ 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
|
||||
previewRef.current = { start: nextStart, end: nextEnd }
|
||||
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: nextStart, end: nextEnd },
|
||||
...(detachLinkedWalls
|
||||
const linkedUpdates = detachLinkedWalls
|
||||
? []
|
||||
: getLinkedWallUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
@@ -167,12 +226,35 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)),
|
||||
])
|
||||
)
|
||||
previewRef.current = { start: nextStart, end: nextEnd }
|
||||
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -254,6 +254,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
|
||||
@@ -380,6 +381,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">
|
||||
@@ -468,6 +571,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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { baseMaterial, glassMaterial } from '../../lib/materials'
|
||||
|
||||
@@ -55,6 +50,300 @@ function addBox(
|
||||
parent.add(m)
|
||||
}
|
||||
|
||||
function addShape(
|
||||
parent: THREE.Object3D,
|
||||
material: THREE.Material,
|
||||
shape: THREE.Shape,
|
||||
depth: number,
|
||||
) {
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
depth,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 24,
|
||||
})
|
||||
geometry.translate(0, 0, -depth / 2)
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
parent.add(mesh)
|
||||
}
|
||||
|
||||
function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) {
|
||||
return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01))
|
||||
}
|
||||
|
||||
function createArchShape(
|
||||
left: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
top: number,
|
||||
archHeight: number,
|
||||
) {
|
||||
const centerX = (left + right) / 2
|
||||
const halfWidth = (right - left) / 2
|
||||
const clampedArchHeight = getClampedArchHeight(right - left, top - bottom, archHeight)
|
||||
const springY = top - clampedArchHeight
|
||||
const shape = new THREE.Shape()
|
||||
const segments = 32
|
||||
|
||||
shape.moveTo(left, bottom)
|
||||
shape.lineTo(right, bottom)
|
||||
shape.lineTo(right, springY)
|
||||
for (let index = 1; index <= segments; index += 1) {
|
||||
const x = right + (left - right) * (index / segments)
|
||||
shape.lineTo(x, getArchBoundaryY(x - centerX, halfWidth, springY, clampedArchHeight))
|
||||
}
|
||||
shape.lineTo(left, bottom)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHeight: number) {
|
||||
if (halfWidth <= 1e-6) return springY
|
||||
const t = Math.min(Math.abs(x) / halfWidth, 1)
|
||||
return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0))
|
||||
}
|
||||
|
||||
function createArchBandShape(
|
||||
width: number,
|
||||
outerSpringY: number,
|
||||
outerTopY: number,
|
||||
innerSpringY: number,
|
||||
innerTopY: number,
|
||||
insetX: number,
|
||||
) {
|
||||
const halfWidth = width / 2
|
||||
const innerHalfWidth = Math.max(halfWidth - insetX, 0)
|
||||
const outerArchHeight = Math.max(outerTopY - outerSpringY, 0)
|
||||
const safeInnerTopY = Math.min(innerTopY, outerTopY - 0.001)
|
||||
const safeInnerSpringY = Math.min(innerSpringY, safeInnerTopY - 0.001)
|
||||
const innerArchHeight = Math.max(safeInnerTopY - safeInnerSpringY, 0)
|
||||
const shape = new THREE.Shape()
|
||||
const segments = 32
|
||||
const getSafeInnerBoundaryY = (x: number) =>
|
||||
Math.min(
|
||||
getArchBoundaryY(x, innerHalfWidth, safeInnerSpringY, innerArchHeight),
|
||||
getArchBoundaryY(x, halfWidth, outerSpringY, outerArchHeight) - 0.001,
|
||||
)
|
||||
|
||||
shape.moveTo(-halfWidth, outerSpringY)
|
||||
for (let index = 1; index <= segments; index += 1) {
|
||||
const x = -halfWidth + width * (index / segments)
|
||||
shape.lineTo(x, getArchBoundaryY(x, halfWidth, outerSpringY, outerArchHeight))
|
||||
}
|
||||
|
||||
if (innerHalfWidth <= 0.001 || safeInnerTopY <= safeInnerSpringY + 0.001) {
|
||||
shape.lineTo(halfWidth, outerSpringY)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
shape.lineTo(innerHalfWidth, outerSpringY)
|
||||
shape.lineTo(innerHalfWidth, getSafeInnerBoundaryY(innerHalfWidth))
|
||||
for (let index = segments - 1; index >= 0; index -= 1) {
|
||||
const x = -innerHalfWidth + innerHalfWidth * 2 * (index / segments)
|
||||
shape.lineTo(x, getSafeInnerBoundaryY(x))
|
||||
}
|
||||
shape.lineTo(-innerHalfWidth, outerSpringY)
|
||||
shape.lineTo(-halfWidth, outerSpringY)
|
||||
shape.closePath()
|
||||
|
||||
return shape
|
||||
}
|
||||
|
||||
function createArchHeadBarShape(width: number, bottomY: number, springY: number, topY: number) {
|
||||
const halfWidth = width / 2
|
||||
const archHeight = Math.max(topY - springY, 0)
|
||||
const shape = new THREE.Shape()
|
||||
const segments = 32
|
||||
|
||||
shape.moveTo(-halfWidth, bottomY)
|
||||
shape.lineTo(halfWidth, bottomY)
|
||||
shape.lineTo(halfWidth, springY)
|
||||
for (let index = 1; index <= segments; index += 1) {
|
||||
const x = halfWidth - width * (index / segments)
|
||||
shape.lineTo(x, getArchBoundaryY(x, halfWidth, springY, archHeight))
|
||||
}
|
||||
shape.lineTo(-halfWidth, bottomY)
|
||||
shape.closePath()
|
||||
|
||||
return shape
|
||||
}
|
||||
|
||||
type TopCornerRadii = {
|
||||
topLeft: number
|
||||
topRight: number
|
||||
}
|
||||
|
||||
function normalizeTopCornerRadii(
|
||||
radii: TopCornerRadii,
|
||||
width: number,
|
||||
height: number,
|
||||
): TopCornerRadii {
|
||||
const next = { ...radii }
|
||||
const scale = Math.min(
|
||||
1,
|
||||
width / Math.max(next.topLeft + next.topRight, 1e-6),
|
||||
height / Math.max(next.topLeft, 1e-6),
|
||||
height / Math.max(next.topRight, 1e-6),
|
||||
)
|
||||
|
||||
if (scale < 1) {
|
||||
next.topLeft *= scale
|
||||
next.topRight *= scale
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function getDoorTopRadii(node: DoorNode, width: number, height: number): TopCornerRadii {
|
||||
if (node.openingRadiusMode === 'individual') {
|
||||
const [topLeft = 0, topRight = 0] = node.openingTopRadii ?? [0.15, 0.15]
|
||||
return normalizeTopCornerRadii(
|
||||
{
|
||||
topLeft: Math.max(topLeft, 0),
|
||||
topRight: Math.max(topRight, 0),
|
||||
},
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
const maxRadius = Math.min(width / 2, height)
|
||||
const radius = Math.min(Math.max(node.cornerRadius ?? 0.15, 0), maxRadius)
|
||||
return { topLeft: radius, topRight: radius }
|
||||
}
|
||||
|
||||
function createRoundedTopShape(
|
||||
left: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
top: number,
|
||||
radii: TopCornerRadii,
|
||||
) {
|
||||
const shape = new THREE.Shape()
|
||||
const { topLeft, topRight } = normalizeTopCornerRadii(radii, right - left, top - bottom)
|
||||
|
||||
shape.moveTo(left, bottom)
|
||||
shape.lineTo(right, bottom)
|
||||
shape.lineTo(right, top - topRight)
|
||||
if (topRight > 1e-6) {
|
||||
shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false)
|
||||
} else {
|
||||
shape.lineTo(right, top)
|
||||
}
|
||||
|
||||
shape.lineTo(left + topLeft, top)
|
||||
if (topLeft > 1e-6) {
|
||||
shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false)
|
||||
} else {
|
||||
shape.lineTo(left, top)
|
||||
}
|
||||
|
||||
shape.lineTo(left, bottom)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
function createRoundedDoorFrameShape(
|
||||
width: number,
|
||||
height: number,
|
||||
frameThickness: number,
|
||||
radii: TopCornerRadii,
|
||||
) {
|
||||
const halfWidth = width / 2
|
||||
const bottom = -height / 2
|
||||
const top = height / 2
|
||||
const outerRadii = normalizeTopCornerRadii(radii, width, height)
|
||||
const outer = createRoundedTopShape(-halfWidth, halfWidth, bottom, top, outerRadii)
|
||||
const inset = Math.min(frameThickness, width / 2 - 0.005, height - 0.005)
|
||||
|
||||
if (inset <= 0.001) return outer
|
||||
|
||||
const innerLeft = -halfWidth + inset
|
||||
const innerRight = halfWidth - inset
|
||||
const innerTop = top - inset
|
||||
const innerRadii = normalizeTopCornerRadii(
|
||||
{
|
||||
topLeft: Math.max(outerRadii.topLeft - inset, 0),
|
||||
topRight: Math.max(outerRadii.topRight - inset, 0),
|
||||
},
|
||||
innerRight - innerLeft,
|
||||
innerTop - bottom,
|
||||
)
|
||||
const holeShape = createRoundedTopShape(innerLeft, innerRight, bottom, innerTop, innerRadii)
|
||||
const hole = new THREE.Path(holeShape.getPoints(32).reverse())
|
||||
outer.holes.push(hole)
|
||||
|
||||
return outer
|
||||
}
|
||||
|
||||
function shapeToReversedPath(shape: THREE.Shape) {
|
||||
return new THREE.Path(shape.getPoints(40).reverse())
|
||||
}
|
||||
|
||||
function createRoundedLeafFrameShape(
|
||||
width: number,
|
||||
bottom: number,
|
||||
top: number,
|
||||
radii: TopCornerRadii,
|
||||
insetX: number,
|
||||
insetY: number,
|
||||
) {
|
||||
const halfWidth = width / 2
|
||||
const outerRadii = normalizeTopCornerRadii(radii, width, top - bottom)
|
||||
const outer = createRoundedTopShape(-halfWidth, halfWidth, bottom, top, outerRadii)
|
||||
const innerLeft = -halfWidth + insetX
|
||||
const innerRight = halfWidth - insetX
|
||||
const innerBottom = bottom + insetY
|
||||
const innerTop = top - insetY
|
||||
|
||||
if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer
|
||||
|
||||
const innerRadii = normalizeTopCornerRadii(
|
||||
{
|
||||
topLeft: Math.max(outerRadii.topLeft - Math.max(insetX, insetY), 0),
|
||||
topRight: Math.max(outerRadii.topRight - Math.max(insetX, insetY), 0),
|
||||
},
|
||||
innerRight - innerLeft,
|
||||
innerTop - innerBottom,
|
||||
)
|
||||
outer.holes.push(
|
||||
shapeToReversedPath(
|
||||
createRoundedTopShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii),
|
||||
),
|
||||
)
|
||||
|
||||
return outer
|
||||
}
|
||||
|
||||
function createTopClippedRectShape(
|
||||
left: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
top: number,
|
||||
getBoundaryY: (x: number) => number,
|
||||
) {
|
||||
const segments = 20
|
||||
const points: { x: number; y: number }[] = []
|
||||
|
||||
for (let index = 0; index <= segments; index += 1) {
|
||||
const t = index / segments
|
||||
const x = right + (left - right) * t
|
||||
const y = Math.min(top, getBoundaryY(x))
|
||||
if (y > bottom + 0.001) points.push({ x, y })
|
||||
}
|
||||
|
||||
if (points.length < 2) return null
|
||||
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(left, bottom)
|
||||
shape.lineTo(right, bottom)
|
||||
for (const point of points) {
|
||||
shape.lineTo(point.x, point.y)
|
||||
}
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
function disposeObject(object: THREE.Object3D) {
|
||||
object.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) child.geometry.dispose()
|
||||
@@ -82,6 +371,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
width,
|
||||
height,
|
||||
openingKind,
|
||||
openingShape,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
threshold,
|
||||
@@ -129,8 +419,77 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
y: number,
|
||||
z: number,
|
||||
) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z)
|
||||
const addLeafShape = (shape: THREE.Shape, material: THREE.Material, depth: number, z = 0) => {
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
depth,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 24,
|
||||
})
|
||||
geometry.translate(-hingeX, 0, -depth / 2 + z)
|
||||
const leafMesh = new THREE.Mesh(geometry, material)
|
||||
leafGroup.add(leafMesh)
|
||||
}
|
||||
|
||||
// ── Frame members ──
|
||||
if (openingShape === 'arch') {
|
||||
const frameBottom = -height / 2
|
||||
const frameTop = height / 2
|
||||
const frameArchHeight = getClampedArchHeight(width, height, node.archHeight)
|
||||
const frameSpringY = frameTop - frameArchHeight
|
||||
const frameInnerTopY = frameTop - frameThickness
|
||||
const frameInnerSpringY = Math.min(frameSpringY + frameThickness, frameInnerTopY)
|
||||
const useShallowHeadBar = frameArchHeight <= frameThickness * 2
|
||||
const frameHeadBottomY = useShallowHeadBar ? frameSpringY - frameThickness : frameSpringY
|
||||
const postHeight = Math.max(frameHeadBottomY - frameBottom, 0.01)
|
||||
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
frameThickness,
|
||||
postHeight,
|
||||
frameDepth,
|
||||
-width / 2 + frameThickness / 2,
|
||||
frameBottom + postHeight / 2,
|
||||
0,
|
||||
)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
frameThickness,
|
||||
postHeight,
|
||||
frameDepth,
|
||||
width / 2 - frameThickness / 2,
|
||||
frameBottom + postHeight / 2,
|
||||
0,
|
||||
)
|
||||
addShape(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
useShallowHeadBar
|
||||
? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop)
|
||||
: createArchBandShape(
|
||||
width,
|
||||
frameSpringY,
|
||||
frameTop,
|
||||
frameInnerSpringY,
|
||||
frameInnerTopY,
|
||||
frameThickness,
|
||||
),
|
||||
frameDepth,
|
||||
)
|
||||
} else if (openingShape === 'rounded') {
|
||||
addShape(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
createRoundedDoorFrameShape(
|
||||
width,
|
||||
height,
|
||||
frameThickness,
|
||||
getDoorTopRadii(node, width, height),
|
||||
),
|
||||
frameDepth,
|
||||
)
|
||||
} else {
|
||||
// Left post — full height
|
||||
addBox(
|
||||
mesh,
|
||||
@@ -164,6 +523,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
height / 2 - frameThickness / 2,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Threshold (inside the frame) ──
|
||||
if (threshold) {
|
||||
@@ -179,16 +539,139 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
|
||||
const usesShapedLeaf = openingShape === 'arch' || openingShape === 'rounded'
|
||||
const leafBottom = leafCenterY - leafH / 2
|
||||
const leafTop = leafCenterY + leafH / 2
|
||||
const leafArchHeight = getClampedArchHeight(
|
||||
leafW,
|
||||
leafH,
|
||||
Math.max((node.archHeight ?? leafW / 2) - frameThickness, 0.01),
|
||||
)
|
||||
const leafArchSpringY = leafTop - leafArchHeight
|
||||
const frameRadii = getDoorTopRadii(node, width, height)
|
||||
const leafTopRadii = normalizeTopCornerRadii(
|
||||
{
|
||||
topLeft: Math.max(frameRadii.topLeft - frameThickness, 0),
|
||||
topRight: Math.max(frameRadii.topRight - frameThickness, 0),
|
||||
},
|
||||
leafW,
|
||||
leafH,
|
||||
)
|
||||
const cpX = contentPadding[0]
|
||||
const cpY = contentPadding[1]
|
||||
if (hasLeafContent && cpY > 0) {
|
||||
const useShallowLeafHeadBar = openingShape === 'arch' && cpY > 0 && leafArchHeight <= cpY * 2
|
||||
const shallowLeafHeadBottomY = leafArchSpringY - cpY
|
||||
const getLeafBoundaryY = (x: number) => {
|
||||
if (openingShape === 'arch') {
|
||||
if (useShallowLeafHeadBar) return shallowLeafHeadBottomY
|
||||
|
||||
const innerTop = leafTop - cpY
|
||||
const innerSpringY = Math.min(Math.max(leafArchSpringY + cpY, leafBottom + cpY), innerTop)
|
||||
const innerArchHeight = Math.max(innerTop - innerSpringY, 0.001)
|
||||
const halfContentW = Math.max((leafW - 2 * cpX) / 2, 0.001)
|
||||
const outerBoundaryY = getArchBoundaryY(x, leafW / 2, leafArchSpringY, leafArchHeight)
|
||||
return Math.min(
|
||||
getArchBoundaryY(x, halfContentW, innerSpringY, innerArchHeight),
|
||||
outerBoundaryY - 0.001,
|
||||
)
|
||||
}
|
||||
|
||||
if (openingShape === 'rounded') {
|
||||
const left = -leafW / 2 + cpX
|
||||
const right = leafW / 2 - cpX
|
||||
const top = leafTop - cpY
|
||||
const innerRadii = normalizeTopCornerRadii(
|
||||
{
|
||||
topLeft: Math.max(leafTopRadii.topLeft - Math.max(cpX, cpY), 0),
|
||||
topRight: Math.max(leafTopRadii.topRight - Math.max(cpX, cpY), 0),
|
||||
},
|
||||
right - left,
|
||||
top - (leafBottom + cpY),
|
||||
)
|
||||
|
||||
if (innerRadii.topLeft > 1e-6 && x < left + innerRadii.topLeft) {
|
||||
const centerX = left + innerRadii.topLeft
|
||||
const centerY = top - innerRadii.topLeft
|
||||
const dx = x - centerX
|
||||
return centerY + Math.sqrt(Math.max(innerRadii.topLeft * innerRadii.topLeft - dx * dx, 0))
|
||||
}
|
||||
|
||||
if (innerRadii.topRight > 1e-6 && x > right - innerRadii.topRight) {
|
||||
const centerX = right - innerRadii.topRight
|
||||
const centerY = top - innerRadii.topRight
|
||||
const dx = x - centerX
|
||||
return centerY + Math.sqrt(Math.max(innerRadii.topRight * innerRadii.topRight - dx * dx, 0))
|
||||
}
|
||||
|
||||
return top
|
||||
}
|
||||
|
||||
return leafTop
|
||||
}
|
||||
const createLeafCellShape = (left: number, right: number, bottom: number, top: number) =>
|
||||
createTopClippedRectShape(left, right, bottom, top, getLeafBoundaryY)
|
||||
|
||||
// ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
|
||||
if (hasLeafContent && openingShape === 'arch') {
|
||||
const leafInnerTopY = leafTop - cpY
|
||||
const leafInnerSpringY = Math.min(
|
||||
Math.max(leafArchSpringY + cpY, leafBottom + cpY),
|
||||
leafInnerTopY,
|
||||
)
|
||||
const sideBottom = leafBottom + cpY
|
||||
const sideTop = useShallowLeafHeadBar ? shallowLeafHeadBottomY : leafArchSpringY
|
||||
const sideHeight = Math.max(sideTop - sideBottom, 0)
|
||||
|
||||
if (cpY > 0) {
|
||||
addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafBottom + cpY / 2, 0)
|
||||
}
|
||||
if (cpX > 0 && sideHeight > 0.01) {
|
||||
addLeafBox(
|
||||
baseMaterial,
|
||||
cpX,
|
||||
sideHeight,
|
||||
leafDepth,
|
||||
-leafW / 2 + cpX / 2,
|
||||
sideBottom + sideHeight / 2,
|
||||
0,
|
||||
)
|
||||
addLeafBox(
|
||||
baseMaterial,
|
||||
cpX,
|
||||
sideHeight,
|
||||
leafDepth,
|
||||
leafW / 2 - cpX / 2,
|
||||
sideBottom + sideHeight / 2,
|
||||
0,
|
||||
)
|
||||
}
|
||||
addLeafShape(
|
||||
useShallowLeafHeadBar
|
||||
? createArchHeadBarShape(leafW, shallowLeafHeadBottomY, leafArchSpringY, leafTop)
|
||||
: createArchBandShape(
|
||||
leafW,
|
||||
leafArchSpringY,
|
||||
leafTop,
|
||||
leafInnerSpringY,
|
||||
leafInnerTopY,
|
||||
cpX,
|
||||
),
|
||||
baseMaterial,
|
||||
leafDepth,
|
||||
)
|
||||
} else if (hasLeafContent && openingShape === 'rounded') {
|
||||
addLeafShape(
|
||||
createRoundedLeafFrameShape(leafW, leafBottom, leafTop, leafTopRadii, cpX, cpY),
|
||||
baseMaterial,
|
||||
leafDepth,
|
||||
)
|
||||
} else if (hasLeafContent && cpY > 0) {
|
||||
// Top strip
|
||||
addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0)
|
||||
// Bottom strip
|
||||
addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0)
|
||||
}
|
||||
if (hasLeafContent && cpX > 0) {
|
||||
if (hasLeafContent && !usesShapedLeaf && cpX > 0) {
|
||||
const innerH = leafH - 2 * cpY
|
||||
// Left strip
|
||||
addLeafBox(baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0)
|
||||
@@ -205,9 +688,12 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
const contentTop = leafCenterY + contentH / 2
|
||||
|
||||
let segY = contentTop
|
||||
for (const seg of segments) {
|
||||
for (let segIndex = 0; segIndex < segments.length; segIndex += 1) {
|
||||
const seg = segments[segIndex]!
|
||||
const segH = (seg.heightRatio / totalRatio) * contentH
|
||||
const segCenterY = segY - segH / 2
|
||||
const segTop = segY
|
||||
const segBottom = segY - segH
|
||||
|
||||
const numCols = seg.columnRatios.length
|
||||
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||
@@ -228,6 +714,14 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
cx = -contentW / 2
|
||||
for (let c = 0; c < numCols - 1; c++) {
|
||||
cx += colWidths[c]!
|
||||
if (usesShapedLeaf) {
|
||||
const dividerLeft = cx
|
||||
const dividerRight = cx + seg.dividerThickness
|
||||
const dividerShape = createLeafCellShape(dividerLeft, dividerRight, segBottom, segTop)
|
||||
if (dividerShape) {
|
||||
addLeafShape(dividerShape, baseMaterial, 0.012, leafDepth / 2 + 0.006)
|
||||
}
|
||||
} else {
|
||||
addLeafBox(
|
||||
baseMaterial,
|
||||
seg.dividerThickness,
|
||||
@@ -237,6 +731,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
segCenterY,
|
||||
0,
|
||||
)
|
||||
}
|
||||
cx += seg.dividerThickness
|
||||
}
|
||||
}
|
||||
@@ -245,27 +740,61 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
const colW = colWidths[c]!
|
||||
const colX = colXCenters[c]!
|
||||
const cellLeft = colX - colW / 2
|
||||
const cellRight = colX + colW / 2
|
||||
|
||||
if (seg.type === 'glass') {
|
||||
// Glass only — no opaque backing so it's truly transparent
|
||||
const glassDepth = Math.max(0.004, leafDepth * 0.15)
|
||||
if (usesShapedLeaf) {
|
||||
const shape = createLeafCellShape(cellLeft, cellRight, segBottom, segTop)
|
||||
if (shape)
|
||||
addLeafShape(shape, glassMaterial, glassDepth, leafDepth / 2 + glassDepth / 2 + 0.004)
|
||||
} else {
|
||||
// Glass only — no opaque backing so it's truly transparent
|
||||
addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0)
|
||||
}
|
||||
} else if (seg.type === 'panel') {
|
||||
if (usesShapedLeaf) {
|
||||
const shape = createLeafCellShape(cellLeft, cellRight, segBottom, segTop)
|
||||
if (shape) addLeafShape(shape, baseMaterial, leafDepth)
|
||||
} else {
|
||||
// Opaque leaf backing for this column
|
||||
addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)
|
||||
}
|
||||
// Raised panel detail
|
||||
const panelW = colW - 2 * seg.panelInset
|
||||
const panelH = segH - 2 * seg.panelInset
|
||||
if (panelW > 0.01 && panelH > 0.01) {
|
||||
const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth)
|
||||
const panelZ = leafDepth / 2 + effectiveDepth / 2
|
||||
if (usesShapedLeaf) {
|
||||
const shape = createLeafCellShape(
|
||||
colX - panelW / 2,
|
||||
colX + panelW / 2,
|
||||
segCenterY - panelH / 2,
|
||||
segCenterY + panelH / 2,
|
||||
)
|
||||
if (shape) addLeafShape(shape, baseMaterial, effectiveDepth, panelZ)
|
||||
} else {
|
||||
addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 'empty' leaves the opening unfilled
|
||||
}
|
||||
}
|
||||
|
||||
if (usesShapedLeaf && segIndex < segments.length - 1) {
|
||||
const railThickness = Math.min(Math.max(cpY, 0.02), Math.max(segH * 0.35, 0.02))
|
||||
const railShape = createLeafCellShape(
|
||||
-contentW / 2,
|
||||
contentW / 2,
|
||||
segBottom - railThickness / 2,
|
||||
segBottom + railThickness / 2,
|
||||
)
|
||||
if (railShape) addLeafShape(railShape, baseMaterial, 0.012, leafDepth / 2 + 0.006)
|
||||
}
|
||||
|
||||
segY -= segH
|
||||
}
|
||||
|
||||
@@ -308,8 +837,6 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
const hingeW = 0.024
|
||||
const hingeD = leafDepth + 0.016
|
||||
// Bottom hinge ~0.25m from floor, middle hinge, top hinge ~0.25m from top
|
||||
const leafBottom = leafCenterY - leafH / 2
|
||||
const leafTop = leafCenterY + leafH / 2
|
||||
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafBottom + 0.25, hingeZ)
|
||||
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ)
|
||||
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ)
|
||||
@@ -327,6 +854,40 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
|
||||
mesh.add(cutout)
|
||||
}
|
||||
cutout.geometry.dispose()
|
||||
if (node.openingShape === 'arch') {
|
||||
cutout.geometry = new THREE.ExtrudeGeometry(
|
||||
createArchShape(
|
||||
-node.width / 2,
|
||||
node.width / 2,
|
||||
-node.height / 2,
|
||||
node.height / 2,
|
||||
getClampedArchHeight(node.width, node.height, node.archHeight),
|
||||
),
|
||||
{
|
||||
depth: 1,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 24,
|
||||
},
|
||||
)
|
||||
cutout.geometry.translate(0, 0, -0.5)
|
||||
} else if (node.openingShape === 'rounded') {
|
||||
cutout.geometry = new THREE.ExtrudeGeometry(
|
||||
createRoundedTopShape(
|
||||
-node.width / 2,
|
||||
node.width / 2,
|
||||
-node.height / 2,
|
||||
node.height / 2,
|
||||
getDoorTopRadii(node, node.width, node.height),
|
||||
),
|
||||
{
|
||||
depth: 1,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 24,
|
||||
},
|
||||
)
|
||||
cutout.geometry.translate(0, 0, -0.5)
|
||||
} else {
|
||||
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
|
||||
}
|
||||
cutout.visible = false
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
||||
import { computeBoundsTree } from 'three-mesh-bvh'
|
||||
import {
|
||||
calculateLevelMiters,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
calculateLevelMiters,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
type DoorNode,
|
||||
getAdjacentWallIds,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getWallCurveFrameAt,
|
||||
getWallMiterBoundaryPoints,
|
||||
getWallPlanFootprint,
|
||||
@@ -21,10 +17,14 @@ import {
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
||||
import { computeBoundsTree } from 'three-mesh-bvh'
|
||||
|
||||
// Reusable CSG evaluator for better performance
|
||||
const csgEvaluator = new Evaluator()
|
||||
@@ -560,7 +560,13 @@ function collectCutoutBrushes(
|
||||
|
||||
if (
|
||||
(child.type === 'door' && child.openingKind === 'opening') ||
|
||||
(child.type === 'window' && child.openingKind === 'opening')
|
||||
(child.type === 'door' &&
|
||||
child.openingKind === 'door' &&
|
||||
(child.openingShape === 'arch' || child.openingShape === 'rounded')) ||
|
||||
(child.type === 'window' && child.openingKind === 'opening') ||
|
||||
(child.type === 'window' &&
|
||||
child.openingKind === 'window' &&
|
||||
(child.openingShape === 'arch' || child.openingShape === 'rounded'))
|
||||
) {
|
||||
brushes.push(createShapedOpeningCutoutBrush(child, wallThickness))
|
||||
continue
|
||||
@@ -668,11 +674,17 @@ function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape
|
||||
if (opening.openingShape === 'arch') {
|
||||
const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height)
|
||||
const springY = top - archHeight
|
||||
const segments = 32
|
||||
|
||||
shape.moveTo(left, bottom)
|
||||
shape.lineTo(right, bottom)
|
||||
shape.lineTo(right, springY)
|
||||
shape.quadraticCurveTo(centerX, top, left, springY)
|
||||
for (let index = 1; index <= segments; index += 1) {
|
||||
const x = right + (left - right) * (index / segments)
|
||||
const normalizedX = Math.min(Math.abs((x - centerX) / halfWidth), 1)
|
||||
const y = springY + archHeight * Math.sqrt(Math.max(1 - normalizedX * normalizedX, 0))
|
||||
shape.lineTo(x, y)
|
||||
}
|
||||
shape.lineTo(left, bottom)
|
||||
shape.closePath()
|
||||
return shape
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { type AnyNodeId, sceneRegistry, useScene, type WindowNode } from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { baseMaterial, glassMaterial } from '../../lib/materials'
|
||||
|
||||
@@ -55,6 +50,521 @@ function addBox(
|
||||
parent.add(m)
|
||||
}
|
||||
|
||||
function addShape(
|
||||
parent: THREE.Object3D,
|
||||
material: THREE.Material,
|
||||
shape: THREE.Shape,
|
||||
depth: number,
|
||||
z = 0,
|
||||
) {
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
depth,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 24,
|
||||
})
|
||||
geometry.translate(0, 0, -depth / 2 + z)
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
parent.add(mesh)
|
||||
}
|
||||
|
||||
function createRectShape(left: number, right: number, bottom: number, top: number) {
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(left, bottom)
|
||||
shape.lineTo(right, bottom)
|
||||
shape.lineTo(right, top)
|
||||
shape.lineTo(left, top)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
type CornerRadii = {
|
||||
topLeft: number
|
||||
topRight: number
|
||||
bottomRight: number
|
||||
bottomLeft: number
|
||||
}
|
||||
|
||||
function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii {
|
||||
const next = { ...radii }
|
||||
const scale = Math.min(
|
||||
1,
|
||||
width / Math.max(next.topLeft + next.topRight, 1e-6),
|
||||
width / Math.max(next.bottomLeft + next.bottomRight, 1e-6),
|
||||
height / Math.max(next.topLeft + next.bottomLeft, 1e-6),
|
||||
height / Math.max(next.topRight + next.bottomRight, 1e-6),
|
||||
)
|
||||
|
||||
if (scale < 1) {
|
||||
next.topLeft *= scale
|
||||
next.topRight *= scale
|
||||
next.bottomRight *= scale
|
||||
next.bottomLeft *= scale
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function getWindowRoundedRadii(node: WindowNode, width: number, height: number): CornerRadii {
|
||||
if (node.openingRadiusMode === 'individual') {
|
||||
const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] =
|
||||
node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
|
||||
return normalizeCornerRadii(
|
||||
{
|
||||
topLeft: Math.max(topLeft, 0),
|
||||
topRight: Math.max(topRight, 0),
|
||||
bottomRight: Math.max(bottomRight, 0),
|
||||
bottomLeft: Math.max(bottomLeft, 0),
|
||||
},
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
const maxRadius = Math.min(width / 2, height / 2)
|
||||
const radius = Math.min(Math.max(node.cornerRadius ?? 0.15, 0), maxRadius)
|
||||
return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius }
|
||||
}
|
||||
|
||||
function insetCornerRadii(radii: CornerRadii, inset: number, width: number, height: number) {
|
||||
return normalizeCornerRadii(
|
||||
{
|
||||
topLeft: Math.max(radii.topLeft - inset, 0),
|
||||
topRight: Math.max(radii.topRight - inset, 0),
|
||||
bottomRight: Math.max(radii.bottomRight - inset, 0),
|
||||
bottomLeft: Math.max(radii.bottomLeft - inset, 0),
|
||||
},
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
function createRoundedShape(
|
||||
left: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
top: number,
|
||||
radii: CornerRadii,
|
||||
) {
|
||||
const shape = new THREE.Shape()
|
||||
const { topLeft, topRight, bottomRight, bottomLeft } = radii
|
||||
|
||||
shape.moveTo(left + bottomLeft, bottom)
|
||||
shape.lineTo(right - bottomRight, bottom)
|
||||
if (bottomRight > 1e-6) {
|
||||
shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false)
|
||||
} else {
|
||||
shape.lineTo(right, bottom)
|
||||
}
|
||||
|
||||
shape.lineTo(right, top - topRight)
|
||||
if (topRight > 1e-6) {
|
||||
shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false)
|
||||
} else {
|
||||
shape.lineTo(right, top)
|
||||
}
|
||||
|
||||
shape.lineTo(left + topLeft, top)
|
||||
if (topLeft > 1e-6) {
|
||||
shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false)
|
||||
} else {
|
||||
shape.lineTo(left, top)
|
||||
}
|
||||
|
||||
shape.lineTo(left, bottom + bottomLeft)
|
||||
if (bottomLeft > 1e-6) {
|
||||
shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false)
|
||||
} else {
|
||||
shape.lineTo(left, bottom)
|
||||
}
|
||||
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
function createRoundedFrameShape(
|
||||
width: number,
|
||||
height: number,
|
||||
frameThickness: number,
|
||||
outerRadii: CornerRadii,
|
||||
) {
|
||||
const halfWidth = width / 2
|
||||
const bottom = -height / 2
|
||||
const top = height / 2
|
||||
const outer = createRoundedShape(-halfWidth, halfWidth, bottom, top, outerRadii)
|
||||
const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)
|
||||
|
||||
if (inset <= 0.001) return outer
|
||||
|
||||
const innerLeft = -halfWidth + inset
|
||||
const innerRight = halfWidth - inset
|
||||
const innerBottom = bottom + inset
|
||||
const innerTop = top - inset
|
||||
const innerRadii = insetCornerRadii(
|
||||
outerRadii,
|
||||
inset,
|
||||
innerRight - innerLeft,
|
||||
innerTop - innerBottom,
|
||||
)
|
||||
const holeShape = createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii)
|
||||
const hole = new THREE.Path(holeShape.getPoints(32).reverse())
|
||||
outer.holes.push(hole)
|
||||
|
||||
return outer
|
||||
}
|
||||
|
||||
function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) {
|
||||
return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01))
|
||||
}
|
||||
|
||||
function createArchShape(
|
||||
left: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
top: number,
|
||||
archHeight: number,
|
||||
) {
|
||||
const centerX = (left + right) / 2
|
||||
const halfWidth = (right - left) / 2
|
||||
const clampedArchHeight = getClampedArchHeight(right - left, top - bottom, archHeight)
|
||||
const springY = top - clampedArchHeight
|
||||
const shape = new THREE.Shape()
|
||||
const segments = 32
|
||||
|
||||
shape.moveTo(left, bottom)
|
||||
shape.lineTo(right, bottom)
|
||||
shape.lineTo(right, springY)
|
||||
for (let index = 1; index <= segments; index += 1) {
|
||||
const x = right + (left - right) * (index / segments)
|
||||
shape.lineTo(x, getArchBoundaryY(x - centerX, halfWidth, springY, clampedArchHeight))
|
||||
}
|
||||
shape.lineTo(left, bottom)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
function createArchedFrameShape(
|
||||
width: number,
|
||||
height: number,
|
||||
archHeight: number,
|
||||
frameThickness: number,
|
||||
) {
|
||||
const halfWidth = width / 2
|
||||
const bottom = -height / 2
|
||||
const top = height / 2
|
||||
const outer = createArchShape(-halfWidth, halfWidth, bottom, top, archHeight)
|
||||
const inset = Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005)
|
||||
|
||||
if (inset <= 0.001) return outer
|
||||
|
||||
const innerLeft = -halfWidth + inset
|
||||
const innerRight = halfWidth - inset
|
||||
const innerBottom = bottom + inset
|
||||
const innerTop = top - inset
|
||||
const innerArchHeight = getClampedArchHeight(
|
||||
innerRight - innerLeft,
|
||||
innerTop - innerBottom,
|
||||
archHeight - inset,
|
||||
)
|
||||
const hole = new THREE.Path(
|
||||
createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight)
|
||||
.getPoints(32)
|
||||
.reverse(),
|
||||
)
|
||||
outer.holes.push(hole)
|
||||
|
||||
return outer
|
||||
}
|
||||
|
||||
function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHeight: number) {
|
||||
if (halfWidth <= 1e-6) return springY
|
||||
const t = Math.min(Math.abs(x) / halfWidth, 1)
|
||||
return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0))
|
||||
}
|
||||
|
||||
function getArchedOpeningHalfWidthAtY(
|
||||
y: number,
|
||||
halfWidth: number,
|
||||
springY: number,
|
||||
archHeight: number,
|
||||
) {
|
||||
if (y <= springY || archHeight <= 1e-6) return halfWidth
|
||||
const normalizedY = Math.min(Math.max((y - springY) / archHeight, 0), 1)
|
||||
return halfWidth * Math.sqrt(Math.max(1 - normalizedY * normalizedY, 0))
|
||||
}
|
||||
|
||||
function getRoundedBoundaryYAtX(
|
||||
x: number,
|
||||
left: number,
|
||||
right: number,
|
||||
top: number,
|
||||
radii: CornerRadii,
|
||||
) {
|
||||
if (radii.topLeft > 1e-6 && x < left + radii.topLeft) {
|
||||
const centerX = left + radii.topLeft
|
||||
const centerY = top - radii.topLeft
|
||||
const dx = x - centerX
|
||||
return centerY + Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dx * dx, 0))
|
||||
}
|
||||
|
||||
if (radii.topRight > 1e-6 && x > right - radii.topRight) {
|
||||
const centerX = right - radii.topRight
|
||||
const centerY = top - radii.topRight
|
||||
const dx = x - centerX
|
||||
return centerY + Math.sqrt(Math.max(radii.topRight * radii.topRight - dx * dx, 0))
|
||||
}
|
||||
|
||||
return top
|
||||
}
|
||||
|
||||
function getRoundedHorizontalBoundsAtY(
|
||||
y: number,
|
||||
left: number,
|
||||
right: number,
|
||||
top: number,
|
||||
radii: CornerRadii,
|
||||
) {
|
||||
let minX = left
|
||||
let maxX = right
|
||||
|
||||
if (radii.topLeft > 1e-6 && y > top - radii.topLeft) {
|
||||
const centerX = left + radii.topLeft
|
||||
const centerY = top - radii.topLeft
|
||||
const dy = y - centerY
|
||||
minX = centerX - Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dy * dy, 0))
|
||||
}
|
||||
|
||||
if (radii.topRight > 1e-6 && y > top - radii.topRight) {
|
||||
const centerX = right - radii.topRight
|
||||
const centerY = top - radii.topRight
|
||||
const dy = y - centerY
|
||||
maxX = centerX + Math.sqrt(Math.max(radii.topRight * radii.topRight - dy * dy, 0))
|
||||
}
|
||||
|
||||
return { minX, maxX }
|
||||
}
|
||||
|
||||
function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
frameDepth,
|
||||
frameThickness,
|
||||
columnRatios,
|
||||
rowRatios,
|
||||
columnDividerThickness,
|
||||
rowDividerThickness,
|
||||
sill,
|
||||
sillDepth,
|
||||
sillThickness,
|
||||
} = node
|
||||
const halfWidth = width / 2
|
||||
const bottom = -height / 2
|
||||
const top = height / 2
|
||||
const outerRadii = getWindowRoundedRadii(node, width, height)
|
||||
const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005))
|
||||
const innerLeft = -halfWidth + inset
|
||||
const innerRight = halfWidth - inset
|
||||
const innerBottom = bottom + inset
|
||||
const innerTop = top - inset
|
||||
const innerW = innerRight - innerLeft
|
||||
const innerH = innerTop - innerBottom
|
||||
const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH)
|
||||
|
||||
addShape(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
createRoundedFrameShape(width, height, inset, outerRadii),
|
||||
frameDepth,
|
||||
)
|
||||
|
||||
if (innerW > 0.01 && innerH > 0.01) {
|
||||
const glassDepth = Math.max(0.004, frameDepth * 0.08)
|
||||
addShape(
|
||||
mesh,
|
||||
glassMaterial,
|
||||
createRoundedShape(innerLeft, innerRight, innerBottom, innerTop, innerRadii),
|
||||
glassDepth,
|
||||
)
|
||||
|
||||
const numCols = columnRatios.length
|
||||
const numRows = rowRatios.length
|
||||
const usableW = innerW - (numCols - 1) * columnDividerThickness
|
||||
const usableH = innerH - (numRows - 1) * rowDividerThickness
|
||||
const colSum = columnRatios.reduce((a, b) => a + b, 0)
|
||||
const rowSum = rowRatios.reduce((a, b) => a + b, 0)
|
||||
const colWidths = columnRatios.map((r) => (r / colSum) * usableW)
|
||||
const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH)
|
||||
|
||||
let x = innerLeft
|
||||
for (let c = 0; c < numCols - 1; c++) {
|
||||
x += colWidths[c]!
|
||||
const x1 = x
|
||||
const x2 = x + columnDividerThickness
|
||||
const dividerTop = Math.min(
|
||||
getRoundedBoundaryYAtX(x1, innerLeft, innerRight, innerTop, innerRadii),
|
||||
getRoundedBoundaryYAtX(x2, innerLeft, innerRight, innerTop, innerRadii),
|
||||
)
|
||||
if (dividerTop > innerBottom + 0.01) {
|
||||
addShape(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
createRectShape(x1, x2, innerBottom, dividerTop),
|
||||
frameDepth + 0.001,
|
||||
)
|
||||
}
|
||||
x += columnDividerThickness
|
||||
}
|
||||
|
||||
let y = innerTop
|
||||
for (let r = 0; r < numRows - 1; r++) {
|
||||
y -= rowHeights[r]!
|
||||
const yTop = y
|
||||
const yBottom = y - rowDividerThickness
|
||||
const { minX, maxX } = getRoundedHorizontalBoundsAtY(
|
||||
yTop,
|
||||
innerLeft,
|
||||
innerRight,
|
||||
innerTop,
|
||||
innerRadii,
|
||||
)
|
||||
if (maxX - minX > 0.01 && yTop > innerBottom) {
|
||||
addShape(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
createRectShape(minX, maxX, Math.max(yBottom, innerBottom), yTop),
|
||||
frameDepth + 0.001,
|
||||
)
|
||||
}
|
||||
y -= rowDividerThickness
|
||||
}
|
||||
}
|
||||
|
||||
if (sill) {
|
||||
const sillW = width + sillDepth * 0.4
|
||||
const sillZ = frameDepth / 2 + sillDepth / 2
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
sillW,
|
||||
sillThickness,
|
||||
sillDepth,
|
||||
0,
|
||||
-height / 2 - sillThickness / 2,
|
||||
sillZ,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
frameDepth,
|
||||
frameThickness,
|
||||
columnRatios,
|
||||
rowRatios,
|
||||
columnDividerThickness,
|
||||
rowDividerThickness,
|
||||
sill,
|
||||
sillDepth,
|
||||
sillThickness,
|
||||
} = node
|
||||
const halfWidth = width / 2
|
||||
const bottom = -height / 2
|
||||
const top = height / 2
|
||||
const archHeight = getClampedArchHeight(width, height, node.archHeight)
|
||||
const inset = Math.max(0, Math.min(frameThickness, width / 2 - 0.005, height / 2 - 0.005))
|
||||
const innerLeft = -halfWidth + inset
|
||||
const innerRight = halfWidth - inset
|
||||
const innerBottom = bottom + inset
|
||||
const innerTop = top - inset
|
||||
const innerW = innerRight - innerLeft
|
||||
const innerH = innerTop - innerBottom
|
||||
const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset)
|
||||
const innerSpringY = innerTop - innerArchHeight
|
||||
|
||||
addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth)
|
||||
|
||||
if (innerW > 0.01 && innerH > 0.01) {
|
||||
const glassDepth = Math.max(0.004, frameDepth * 0.08)
|
||||
addShape(
|
||||
mesh,
|
||||
glassMaterial,
|
||||
createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight),
|
||||
glassDepth,
|
||||
)
|
||||
|
||||
const numCols = columnRatios.length
|
||||
const numRows = rowRatios.length
|
||||
const usableW = innerW - (numCols - 1) * columnDividerThickness
|
||||
const usableH = innerH - (numRows - 1) * rowDividerThickness
|
||||
const colSum = columnRatios.reduce((a, b) => a + b, 0)
|
||||
const rowSum = rowRatios.reduce((a, b) => a + b, 0)
|
||||
const colWidths = columnRatios.map((r) => (r / colSum) * usableW)
|
||||
const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH)
|
||||
const innerHalfWidth = innerW / 2
|
||||
|
||||
let x = innerLeft
|
||||
for (let c = 0; c < numCols - 1; c++) {
|
||||
x += colWidths[c]!
|
||||
const x1 = x
|
||||
const x2 = x + columnDividerThickness
|
||||
const dividerTop = Math.min(
|
||||
getArchBoundaryY(x1, innerHalfWidth, innerSpringY, innerArchHeight),
|
||||
getArchBoundaryY(x2, innerHalfWidth, innerSpringY, innerArchHeight),
|
||||
)
|
||||
if (dividerTop > innerBottom + 0.01) {
|
||||
addShape(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
createRectShape(x1, x2, innerBottom, dividerTop),
|
||||
frameDepth + 0.001,
|
||||
)
|
||||
}
|
||||
x += columnDividerThickness
|
||||
}
|
||||
|
||||
let y = innerTop
|
||||
for (let r = 0; r < numRows - 1; r++) {
|
||||
y -= rowHeights[r]!
|
||||
const yTop = y
|
||||
const yBottom = y - rowDividerThickness
|
||||
const halfAtTop = getArchedOpeningHalfWidthAtY(
|
||||
yTop,
|
||||
innerHalfWidth,
|
||||
innerSpringY,
|
||||
innerArchHeight,
|
||||
)
|
||||
const x1 = -halfAtTop
|
||||
const x2 = halfAtTop
|
||||
if (x2 - x1 > 0.01 && yTop > innerBottom) {
|
||||
addShape(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
createRectShape(x1, x2, Math.max(yBottom, innerBottom), yTop),
|
||||
frameDepth + 0.001,
|
||||
)
|
||||
}
|
||||
y -= rowDividerThickness
|
||||
}
|
||||
}
|
||||
|
||||
if (sill) {
|
||||
const sillW = width + sillDepth * 0.4
|
||||
const sillZ = frameDepth / 2 + sillDepth / 2
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
sillW,
|
||||
sillThickness,
|
||||
sillDepth,
|
||||
0,
|
||||
-height / 2 - sillThickness / 2,
|
||||
sillZ,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
// Root mesh is an invisible hitbox; all visuals live in child meshes
|
||||
mesh.geometry.dispose()
|
||||
@@ -85,6 +595,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
sillDepth,
|
||||
sillThickness,
|
||||
openingKind,
|
||||
openingShape,
|
||||
} = node
|
||||
|
||||
if (openingKind === 'opening') {
|
||||
@@ -92,6 +603,18 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
return
|
||||
}
|
||||
|
||||
if (openingShape === 'arch') {
|
||||
addArchedWindowVisuals(node, mesh)
|
||||
syncWindowCutout(node, mesh)
|
||||
return
|
||||
}
|
||||
|
||||
if (openingShape === 'rounded') {
|
||||
addRoundedWindowVisuals(node, mesh)
|
||||
syncWindowCutout(node, mesh)
|
||||
return
|
||||
}
|
||||
|
||||
const innerW = width - 2 * frameThickness
|
||||
const innerH = height - 2 * frameThickness
|
||||
|
||||
@@ -252,6 +775,40 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
|
||||
mesh.add(cutout)
|
||||
}
|
||||
cutout.geometry.dispose()
|
||||
if (node.openingShape === 'arch') {
|
||||
cutout.geometry = new THREE.ExtrudeGeometry(
|
||||
createArchShape(
|
||||
-node.width / 2,
|
||||
node.width / 2,
|
||||
-node.height / 2,
|
||||
node.height / 2,
|
||||
getClampedArchHeight(node.width, node.height, node.archHeight),
|
||||
),
|
||||
{
|
||||
depth: 1,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 24,
|
||||
},
|
||||
)
|
||||
cutout.geometry.translate(0, 0, -0.5)
|
||||
} else if (node.openingShape === 'rounded') {
|
||||
cutout.geometry = new THREE.ExtrudeGeometry(
|
||||
createRoundedShape(
|
||||
-node.width / 2,
|
||||
node.width / 2,
|
||||
-node.height / 2,
|
||||
node.height / 2,
|
||||
getWindowRoundedRadii(node, node.width, node.height),
|
||||
),
|
||||
{
|
||||
depth: 1,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 24,
|
||||
},
|
||||
)
|
||||
cutout.geometry.translate(0, 0, -0.5)
|
||||
} else {
|
||||
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
|
||||
}
|
||||
cutout.visible = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user