Improve roof node editing
This commit is contained in:
@@ -316,7 +316,9 @@ export type TapActionHandle<N = any> = {
|
|||||||
* the hit into the node's parent-local frame, and reports the new local XZ
|
* the hit into the node's parent-local frame, and reports the new local XZ
|
||||||
* (optionally grid-snapped via `snapExtents`) to `apply`. Press-drag-release
|
* (optionally grid-snapped via `snapExtents`) to `apply`. Press-drag-release
|
||||||
* with the same live-override → commit-on-release flow as the resize / rotate
|
* with the same live-override → commit-on-release flow as the resize / rotate
|
||||||
* handles. Rendered as a 4-way cross of double-headed arrows.
|
* handles. Rendered as a 4-way cross of double-headed arrows. Pure translation
|
||||||
|
* does not require geometry dirtying; renderers consume the live position
|
||||||
|
* override directly.
|
||||||
*/
|
*/
|
||||||
export type TranslateHandle<N = any> = {
|
export type TranslateHandle<N = any> = {
|
||||||
kind: 'translate'
|
kind: 'translate'
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export {
|
|||||||
getActiveRoofHeight,
|
getActiveRoofHeight,
|
||||||
getEffectiveSegmentSurfaceMaterial,
|
getEffectiveSegmentSurfaceMaterial,
|
||||||
getPitchFromActiveRoofHeight,
|
getPitchFromActiveRoofHeight,
|
||||||
|
getRoofSegmentSurfaceY,
|
||||||
getSegmentSlopeFrame,
|
getSegmentSlopeFrame,
|
||||||
hasSegmentMaterialOverride,
|
hasSegmentMaterialOverride,
|
||||||
ROOF_SHAPE_DEFAULTS,
|
ROOF_SHAPE_DEFAULTS,
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ function getPrimarySlopeRun(input: PitchInputs & ShapeRatios): number {
|
|||||||
return min * input.mansardSteepWidthRatio
|
return min * input.mansardSteepWidthRatio
|
||||||
case 'dutch':
|
case 'dutch':
|
||||||
return min * input.dutchHipWidthRatio
|
return min * input.dutchHipWidthRatio
|
||||||
case 'hip':
|
|
||||||
default:
|
default:
|
||||||
return min / 2
|
return min / 2
|
||||||
}
|
}
|
||||||
@@ -253,6 +252,42 @@ export function getActiveRoofHeight(node: Parameters<typeof getSegmentSlopeFrame
|
|||||||
return getSegmentSlopeFrame(node).activeRh
|
return getSegmentSlopeFrame(node).activeRh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Segment-local surface height used by roof accessory placement and hit disambiguation. */
|
||||||
|
export function getRoofSegmentSurfaceY(
|
||||||
|
node: Pick<RoofSegmentNode, 'roofType' | 'width' | 'depth' | 'wallHeight'> &
|
||||||
|
Parameters<typeof getSegmentSlopeFrame>[0],
|
||||||
|
localX: number,
|
||||||
|
localZ: number,
|
||||||
|
): number {
|
||||||
|
const activeRh = getActiveRoofHeight(node)
|
||||||
|
const peakY = node.wallHeight + activeRh
|
||||||
|
if (activeRh === 0) return node.wallHeight
|
||||||
|
|
||||||
|
if (
|
||||||
|
node.roofType === 'gable' ||
|
||||||
|
node.roofType === 'gambrel' ||
|
||||||
|
node.roofType === 'mansard' ||
|
||||||
|
node.roofType === 'dutch'
|
||||||
|
) {
|
||||||
|
const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0
|
||||||
|
return peakY - t * activeRh
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.roofType === 'shed') {
|
||||||
|
const t = (localZ + node.depth / 2) / (node.depth || 1)
|
||||||
|
return peakY - t * activeRh
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.roofType === 'hip') {
|
||||||
|
const fx = node.width > 0 ? Math.abs(localX) / (node.width / 2) : 0
|
||||||
|
const fz = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0
|
||||||
|
return peakY - Math.max(fx, fz) * activeRh
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0
|
||||||
|
return peakY - t * activeRh
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inverse of `getActiveRoofHeight` — recover the pitch a legacy
|
* Inverse of `getActiveRoofHeight` — recover the pitch a legacy
|
||||||
* `roofHeight` value would correspond to. Used by the scene migration.
|
* `roofHeight` value would correspond to. Used by the scene migration.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export type HandleDragMoveContext = {
|
|||||||
|
|
||||||
type HandleDragSession = {
|
type HandleDragSession = {
|
||||||
move: (context: HandleDragMoveContext) => Partial<AnyNode> | null
|
move: (context: HandleDragMoveContext) => Partial<AnyNode> | null
|
||||||
|
markDirty?: boolean
|
||||||
onBegin?: () => void
|
onBegin?: () => void
|
||||||
onEnd?: () => void
|
onEnd?: () => void
|
||||||
overrideId?: AnyNodeId
|
overrideId?: AnyNodeId
|
||||||
@@ -122,6 +123,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
const overrideId = session.overrideId ?? nodeId
|
const overrideId = session.overrideId ?? nodeId
|
||||||
|
const markDirty = session.markDirty !== false
|
||||||
document.body.style.cursor = cursor
|
document.body.style.cursor = cursor
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
useViewer.getState().setInputDragging(true)
|
useViewer.getState().setInputDragging(true)
|
||||||
@@ -137,7 +139,9 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|||||||
if (!patch) return
|
if (!patch) return
|
||||||
lastPatch = patch
|
lastPatch = patch
|
||||||
useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
|
useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
|
||||||
useScene.getState().markDirty(overrideId)
|
if (markDirty) {
|
||||||
|
useScene.getState().markDirty(overrideId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
@@ -157,7 +161,9 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|||||||
|
|
||||||
const clearOverride = () => {
|
const clearOverride = () => {
|
||||||
useLiveNodeOverrides.getState().clear(overrideId)
|
useLiveNodeOverrides.getState().clear(overrideId)
|
||||||
useScene.getState().markDirty(overrideId)
|
if (markDirty) {
|
||||||
|
useScene.getState().markDirty(overrideId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
|
|||||||
@@ -1185,6 +1185,7 @@ function TranslateArrow({
|
|||||||
.position ?? [0, 0, 0]
|
.position ?? [0, 0, 0]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
markDirty: false,
|
||||||
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
||||||
const hit = new Vector3()
|
const hit = new Vector3()
|
||||||
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
|
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getEffectiveRoofSurfaceMaterial,
|
getEffectiveRoofSurfaceMaterial,
|
||||||
getEffectiveSegmentSurfaceMaterial,
|
getEffectiveSegmentSurfaceMaterial,
|
||||||
getMaterialPresetByRef,
|
getMaterialPresetByRef,
|
||||||
|
getRoofSegmentSurfaceY,
|
||||||
getSelectableKinds,
|
getSelectableKinds,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
isRegistrySelectable,
|
isRegistrySelectable,
|
||||||
@@ -40,7 +41,7 @@ import {
|
|||||||
useViewer,
|
useViewer,
|
||||||
} from '@pascal-app/viewer'
|
} from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
|
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
|
||||||
import {
|
import {
|
||||||
type ActivePaintMaterial,
|
type ActivePaintMaterial,
|
||||||
buildRoofSegmentSurfaceMaterialPatch,
|
buildRoofSegmentSurfaceMaterialPatch,
|
||||||
@@ -205,6 +206,59 @@ function getRegisteredMesh(nodeId: string): Mesh | null {
|
|||||||
return object && (object as Mesh).isMesh ? (object as Mesh) : null
|
return object && (object as Mesh).isMesh ? (object as Mesh) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roofSelectionWorldPoint = new Vector3()
|
||||||
|
|
||||||
|
function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
|
||||||
|
const roof = event.node
|
||||||
|
if (roof.type !== 'roof') return null
|
||||||
|
|
||||||
|
roofSelectionWorldPoint.set(...event.position)
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
let firstSegment: RoofSegmentNode | null = null
|
||||||
|
let bestSegment: { node: RoofSegmentNode; score: number } | null = null
|
||||||
|
|
||||||
|
for (const childId of roof.children ?? []) {
|
||||||
|
const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
|
||||||
|
if (segment?.type !== 'roof-segment') continue
|
||||||
|
|
||||||
|
const object = getRegisteredNodeObject(segment.id)
|
||||||
|
if (!object) continue
|
||||||
|
|
||||||
|
if (!firstSegment) firstSegment = segment
|
||||||
|
|
||||||
|
object.updateWorldMatrix(true, false)
|
||||||
|
const local = object.worldToLocal(roofSelectionWorldPoint.clone())
|
||||||
|
const overhang = segment.overhang ?? 0
|
||||||
|
const halfWidth = segment.width / 2 + overhang
|
||||||
|
const halfDepth = segment.depth / 2 + overhang
|
||||||
|
|
||||||
|
if (Math.abs(local.x) > halfWidth || Math.abs(local.z) > halfDepth) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = Math.abs(local.y - getRoofSegmentSurfaceY(segment, local.x, local.z))
|
||||||
|
if (!bestSegment || score < bestSegment.score) {
|
||||||
|
bestSegment = { node: segment, score }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestSegment?.node ?? firstSegment
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInActiveRoofContext(
|
||||||
|
segment: RoofSegmentNode,
|
||||||
|
selectedIds: readonly string[],
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
): boolean {
|
||||||
|
if (!segment.parentId) return false
|
||||||
|
if (selectedIds.includes(segment.id) || selectedIds.includes(segment.parentId)) return true
|
||||||
|
|
||||||
|
return selectedIds.some((selectedId) => {
|
||||||
|
const selectedNode = nodes[selectedId]
|
||||||
|
return selectedNode?.type === 'roof-segment' && selectedNode.parentId === segment.parentId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
|
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
|
||||||
const previousMaterial = mesh.material
|
const previousMaterial = mesh.material
|
||||||
mesh.material = material
|
mesh.material = material
|
||||||
@@ -1251,8 +1305,14 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
let nodeToSelect = node
|
let nodeToSelect = node
|
||||||
if (node.type === 'roof-segment' && node.parentId) {
|
if (node.type === 'roof-segment' && node.parentId) {
|
||||||
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
|
const nodes = useScene.getState().nodes
|
||||||
if (parentNode && parentNode.type === 'roof') {
|
const parentNode = nodes[node.parentId as AnyNodeId]
|
||||||
|
const selectedIds = useViewer.getState().selection.selectedIds
|
||||||
|
if (
|
||||||
|
parentNode &&
|
||||||
|
parentNode.type === 'roof' &&
|
||||||
|
!isInActiveRoofContext(node, selectedIds, nodes)
|
||||||
|
) {
|
||||||
nodeToSelect = parentNode
|
nodeToSelect = parentNode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1439,7 +1499,11 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onDoubleClick = (event: NodeEvent) => {
|
const onDoubleClick = (event: NodeEvent) => {
|
||||||
const node = event.node
|
let node = event.node
|
||||||
|
if (node.type === 'roof') {
|
||||||
|
node = resolveRoofSegmentSelectionTarget(event) ?? node
|
||||||
|
}
|
||||||
|
|
||||||
const currentPhase = useEditor.getState().phase
|
const currentPhase = useEditor.getState().phase
|
||||||
|
|
||||||
let targetPhase: 'site' | 'structure' | 'furnish' | null = null
|
let targetPhase: 'site' | 'structure' | 'furnish' | null = null
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
resolveAlignment,
|
resolveAlignment,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
|
snapScalar,
|
||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -28,6 +29,10 @@ const GRID_OFFSET = 0.02
|
|||||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||||
|
|
||||||
|
function snapToActiveGrid(value: number): number {
|
||||||
|
return snapScalar(value, useEditor.getState().gridSnapStep)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a roof group with one default gable segment
|
* Creates a roof group with one default gable segment
|
||||||
*/
|
*/
|
||||||
@@ -233,8 +238,8 @@ export const RoofTool: React.FC = () => {
|
|||||||
if (!cursorRef.current) return
|
if (!cursorRef.current) return
|
||||||
|
|
||||||
const [gridX, gridZ] = alignPoint(
|
const [gridX, gridZ] = alignPoint(
|
||||||
Math.round(event.localPosition[0] * 2) / 2,
|
snapToActiveGrid(event.localPosition[0]),
|
||||||
Math.round(event.localPosition[2] * 2) / 2,
|
snapToActiveGrid(event.localPosition[2]),
|
||||||
event.localPosition[0],
|
event.localPosition[0],
|
||||||
event.localPosition[2],
|
event.localPosition[2],
|
||||||
event.nativeEvent?.altKey === true,
|
event.nativeEvent?.altKey === true,
|
||||||
@@ -271,8 +276,8 @@ export const RoofTool: React.FC = () => {
|
|||||||
if (!currentLevelId) return
|
if (!currentLevelId) return
|
||||||
|
|
||||||
const [gridX, gridZ] = alignPoint(
|
const [gridX, gridZ] = alignPoint(
|
||||||
Math.round(event.localPosition[0] * 2) / 2,
|
snapToActiveGrid(event.localPosition[0]),
|
||||||
Math.round(event.localPosition[2] * 2) / 2,
|
snapToActiveGrid(event.localPosition[2]),
|
||||||
event.localPosition[0],
|
event.localPosition[0],
|
||||||
event.localPosition[2],
|
event.localPosition[2],
|
||||||
event.nativeEvent?.altKey === true,
|
event.nativeEvent?.altKey === true,
|
||||||
|
|||||||
@@ -1,8 +1,84 @@
|
|||||||
import { type NodeDefinition, RoofNode as RoofNodeSchema } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type HandleDescriptor,
|
||||||
|
type NodeDefinition,
|
||||||
|
RoofNode as RoofNodeSchema,
|
||||||
|
type RoofNode as RoofNodeType,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
type SceneApi,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { buildRoofFloorplan } from './floorplan'
|
import { buildRoofFloorplan } from './floorplan'
|
||||||
import { roofParametrics } from './parametrics'
|
import { roofParametrics } from './parametrics'
|
||||||
import { RoofNode } from './schema'
|
import { RoofNode } from './schema'
|
||||||
|
|
||||||
|
const MOVE_FRONT_OFFSET = 0.35
|
||||||
|
const MIN_ROOF_FOOTPRINT = 1
|
||||||
|
|
||||||
|
type RoofFootprintBounds = {
|
||||||
|
maxX: number
|
||||||
|
maxZ: number
|
||||||
|
minX: number
|
||||||
|
minZ: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoofFootprintBounds(node: RoofNodeType, sceneApi: SceneApi): RoofFootprintBounds {
|
||||||
|
let bounds: RoofFootprintBounds | null = null
|
||||||
|
|
||||||
|
for (const childId of node.children ?? []) {
|
||||||
|
const segment = sceneApi.get<RoofSegmentNode>(childId as AnyNodeId)
|
||||||
|
if (segment?.type !== 'roof-segment') continue
|
||||||
|
|
||||||
|
const halfWidth = Math.max(segment.width, MIN_ROOF_FOOTPRINT) / 2
|
||||||
|
const halfDepth = Math.max(segment.depth, MIN_ROOF_FOOTPRINT) / 2
|
||||||
|
const cos = Math.cos(segment.rotation ?? 0)
|
||||||
|
const sin = Math.sin(segment.rotation ?? 0)
|
||||||
|
const corners = [
|
||||||
|
[-halfWidth, -halfDepth],
|
||||||
|
[halfWidth, -halfDepth],
|
||||||
|
[halfWidth, halfDepth],
|
||||||
|
[-halfWidth, halfDepth],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
for (const [x, z] of corners) {
|
||||||
|
const localX = segment.position[0] + x * cos + z * sin
|
||||||
|
const localZ = segment.position[2] - x * sin + z * cos
|
||||||
|
bounds =
|
||||||
|
bounds === null
|
||||||
|
? { maxX: localX, maxZ: localZ, minX: localX, minZ: localZ }
|
||||||
|
: {
|
||||||
|
maxX: Math.max(bounds.maxX, localX),
|
||||||
|
maxZ: Math.max(bounds.maxZ, localZ),
|
||||||
|
minX: Math.min(bounds.minX, localX),
|
||||||
|
minZ: Math.min(bounds.minZ, localZ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bounds ?? { maxX: 0.5, maxZ: 0.5, minX: -0.5, minZ: -0.5 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function roofMoveHandle(): HandleDescriptor<RoofNodeType> {
|
||||||
|
return {
|
||||||
|
kind: 'translate',
|
||||||
|
placement: {
|
||||||
|
position: (node, sceneApi) => {
|
||||||
|
const bounds = getRoofFootprintBounds(node, sceneApi)
|
||||||
|
return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + MOVE_FRONT_OFFSET]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
apply: (_node, position) => ({ position: [position[0], position[1], position[2]] }),
|
||||||
|
snapExtents: (node, sceneApi) => {
|
||||||
|
const bounds = getRoofFootprintBounds(node, sceneApi)
|
||||||
|
const width = Math.max(bounds.maxX - bounds.minX, MIN_ROOF_FOOTPRINT)
|
||||||
|
const depth = Math.max(bounds.maxZ - bounds.minZ, MIN_ROOF_FOOTPRINT)
|
||||||
|
const swap = Math.abs(Math.sin(node.rotation ?? 0)) > 0.9
|
||||||
|
return [swap ? depth : width, swap ? width : depth]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roofHandles: HandleDescriptor<RoofNodeType>[] = [roofMoveHandle()]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer`
|
* Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer`
|
||||||
* + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` +
|
* + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` +
|
||||||
@@ -43,6 +119,7 @@ export const roofDefinition: NodeDefinition<typeof RoofNode> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
parametrics: roofParametrics,
|
parametrics: roofParametrics,
|
||||||
|
handles: roofHandles,
|
||||||
floorplan: buildRoofFloorplan,
|
floorplan: buildRoofFloorplan,
|
||||||
|
|
||||||
renderer: {
|
renderer: {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
hasSegmentMaterialOverride,
|
hasSegmentMaterialOverride,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
|
useLiveNodeOverrides,
|
||||||
useRegistry,
|
useRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -14,8 +15,13 @@ import * as THREE from 'three'
|
|||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials'
|
import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials'
|
||||||
|
|
||||||
export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => {
|
||||||
const ref = useRef<THREE.Group>(null!)
|
const ref = useRef<THREE.Group>(null!)
|
||||||
|
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(rawNode.id))
|
||||||
|
const node = useMemo<RoofNode>(
|
||||||
|
() => (liveOverride ? ({ ...rawNode, ...liveOverride } as RoofNode) : rawNode),
|
||||||
|
[rawNode, liveOverride],
|
||||||
|
)
|
||||||
|
|
||||||
useRegistry(node.id, 'roof', ref)
|
useRegistry(node.id, 'roof', ref)
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
getActiveRoofHeight,
|
getRoofSegmentSurfaceY,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
@@ -17,40 +17,6 @@ export type RoofSegmentHit = {
|
|||||||
localZ: number
|
localZ: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Analytical surface Y for `seg` at segment-local (lx, lz). Mirrors
|
|
||||||
* the per-roof-type slope math in `shared/roof-surface.ts` so the
|
|
||||||
* disambiguator below stays free of cross-kind imports. Returns the
|
|
||||||
* roof's local surface height; the value is only used to compare
|
|
||||||
* candidates, never written to the scene.
|
|
||||||
*/
|
|
||||||
function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): number {
|
|
||||||
const rh = getActiveRoofHeight(seg)
|
|
||||||
const peakY = seg.wallHeight + rh
|
|
||||||
if (rh === 0) return seg.wallHeight
|
|
||||||
|
|
||||||
if (
|
|
||||||
seg.roofType === 'gable' ||
|
|
||||||
seg.roofType === 'gambrel' ||
|
|
||||||
seg.roofType === 'mansard' ||
|
|
||||||
seg.roofType === 'dutch'
|
|
||||||
) {
|
|
||||||
const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0
|
|
||||||
return peakY - t * rh
|
|
||||||
}
|
|
||||||
if (seg.roofType === 'shed') {
|
|
||||||
const t = (lz + seg.depth / 2) / (seg.depth || 1)
|
|
||||||
return peakY - t * rh
|
|
||||||
}
|
|
||||||
if (seg.roofType === 'hip') {
|
|
||||||
const fx = seg.width > 0 ? Math.abs(lx) / (seg.width / 2) : 0
|
|
||||||
const fz = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0
|
|
||||||
return peakY - Math.max(fx, fz) * rh
|
|
||||||
}
|
|
||||||
const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0
|
|
||||||
return peakY - t * rh
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve which roof-segment the user clicked. Used by every placement
|
* Resolve which roof-segment the user clicked. Used by every placement
|
||||||
* tool that drops a new node onto a roof (box-vent, ridge-vent,
|
* tool that drops a new node onto a roof (box-vent, ridge-vent,
|
||||||
@@ -61,7 +27,7 @@ function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): numbe
|
|||||||
* point's (x, z) lies inside *every* segment's axis-aligned half-
|
* point's (x, z) lies inside *every* segment's axis-aligned half-
|
||||||
* extents, so a naive first-match returns the wrong slope (typically
|
* extents, so a naive first-match returns the wrong slope (typically
|
||||||
* segments[0]). We instead score each candidate by
|
* segments[0]). We instead score each candidate by
|
||||||
* `|localY − analyticalSurfaceY(localX, localZ)|` and pick the
|
* `|localY − getRoofSegmentSurfaceY(localX, localZ)|` and pick the
|
||||||
* smallest — the slope the user actually clicked is the one whose
|
* smallest — the slope the user actually clicked is the one whose
|
||||||
* sloped surface passes through the hit point.
|
* sloped surface passes through the hit point.
|
||||||
*
|
*
|
||||||
@@ -101,7 +67,7 @@ export function resolveRoofSegmentHit(
|
|||||||
const halfW = seg.width / 2 + overhang
|
const halfW = seg.width / 2 + overhang
|
||||||
const halfD = seg.depth / 2 + overhang
|
const halfD = seg.depth / 2 + overhang
|
||||||
if (Math.abs(local.x) <= halfW && Math.abs(local.z) <= halfD) {
|
if (Math.abs(local.x) <= halfW && Math.abs(local.z) <= halfD) {
|
||||||
const surfaceY = analyticalSurfaceY(seg, local.x, local.z)
|
const surfaceY = getRoofSegmentSurfaceY(seg, local.x, local.z)
|
||||||
const score = Math.abs(local.y - surfaceY)
|
const score = Math.abs(local.y - surfaceY)
|
||||||
if (!best || score < best.score) {
|
if (!best || score < best.score) {
|
||||||
best = {
|
best = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
getActiveRoofHeight,
|
getRoofSegmentSurfaceY,
|
||||||
getSegmentSlopeFrame,
|
getSegmentSlopeFrame,
|
||||||
ROOF_SHAPE_DEFAULTS,
|
ROOF_SHAPE_DEFAULTS,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
@@ -13,26 +13,7 @@ import * as THREE from 'three'
|
|||||||
// accessories don't reach across into a sibling kind for it.
|
// accessories don't reach across into a sibling kind for it.
|
||||||
|
|
||||||
export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number {
|
export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number {
|
||||||
const { roofType, wallHeight, depth, width } = seg
|
return getRoofSegmentSurfaceY(seg, lx, lz)
|
||||||
const rh = getActiveRoofHeight(seg)
|
|
||||||
const peakY = wallHeight + rh
|
|
||||||
if (rh === 0) return wallHeight
|
|
||||||
|
|
||||||
if (roofType === 'gable') {
|
|
||||||
const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
|
|
||||||
return peakY - t * rh
|
|
||||||
}
|
|
||||||
if (roofType === 'shed') {
|
|
||||||
const t = (lz + depth / 2) / (depth || 1)
|
|
||||||
return peakY - t * rh
|
|
||||||
}
|
|
||||||
if (roofType === 'hip') {
|
|
||||||
const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0
|
|
||||||
const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
|
|
||||||
return peakY - Math.max(fx, fz) * rh
|
|
||||||
}
|
|
||||||
const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
|
|
||||||
return peakY - t * rh
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outward normal for a roof surface tilting at angle θ in the horizontal
|
// Outward normal for a roof surface tilting at angle θ in the horizontal
|
||||||
|
|||||||
Reference in New Issue
Block a user