Files
editor/packages/nodes/src/shared/roof-surface-placement-guides.ts
T
bf25af6add viewer: Fix Dutch roof trim artifacts (#452)
* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* Fix spiral stair openings and fence handle arrows

* Implement roof trim planes and ridge vent clipping

* Fix mansard roof and ridge vent placement

* Fix mansard merged roof cutouts

* Fix Dutch roof gable overhang

* Refactor roof segment, ridge vent, and surface geometry

Remove Dutch ridge axis abstraction and rework roof edit system,
ridge vent clipping geometry, and roof surface placement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Simplify Dutch roof shape

* Add Dutch roof gable top geometry controls

* Fix Dutch roof slope material slots

* Render dutch roof tops as double-sided faces

* Add auto ridge vent toggle to roof segments

Track ridge vent auto-generation via an `autoRidgeVent` metadata flag so
geometry changes only regenerate default vents when enabled, treating
legacy segments with generated vents as auto-enabled for back-compat.
Expose a panel toggle to opt in/out per segment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Snap new walls to the floor below

Feed the walls of the level directly beneath the active one into the
draft snap pipeline as extra references, so a new wall can align with
the floor below. They share the same local XZ origin, and the list is
kept separate from the current-level walls so the measurement HUD and
wall splitting only act on the active level.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Set Dutch roof shape defaults on type switch

Seed the Dutch shape parameters (waist width/height/length, top rake
thickness/length) with sensible defaults whenever a segment is created
as or switched to Dutch, so the gablet is well-formed regardless of
leftover values from the previous roof type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Use green accent for corner and endpoint snap markers

Color the corner/endpoint snap markers and the vertical cursor pillar
green across the 2D floorplan beacon, the 3D alignment guide dots, and
the wall snap beacon so snap targets read as a consistent accent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add magnetic wall snapping to the roof tool

Snap roof draft corners onto wall corners, midpoints, crossings, and
bodies on the active level and the floor below, reusing the wall tool's
snap pipeline so the beacon and coloring match. The cursor's ground
dot/ring is hidden while a wall snap is active to avoid overlapping the
beacon glyph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Update auto-generated Next.js route types path

Regenerated next-env.d.ts now references ./.next/dev/types/routes.d.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Show cutaway outline while dragging roof trim

Slice an untrimmed segment volume generated from the live node instead of
the registry mesh, whose CSG rebuild lags a few frames behind the drag and
may still hold placeholder geometry — so the section outline now renders
deterministically. Use LineBasicNodeMaterial so the outline draws under the
WebGPU pipeline, and export generateRoofSegmentGeometry for the slice source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Fill and clip roof trim cutaway, gate it to active drag

Add a violet silhouette fill behind the cutaway outline, extend the
section slicing to angled diagonal/corner trims via a generic vertical
cut plane, and clip each slice to its footprint span so the infinite
plane no longer sprouts stray lines across the rest of the roof. The
cutaway now renders only while a trim handle is being dragged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Separate and extend Dutch roof end slopes

Pull the Dutch hip end slopes out of the watertight shingle shell into
their own slab wedge so they can be reshaped independently, and extend
each end slope inward up its own hip plane until the top edge meets the
gablet's inner triangle. Refactor roof-segment shape geometry into a
shared roof-segment-shape module.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Render roof trim cutaway as a material-only section cut

Replace the triangle-mesh slicer with a CSG intersection of a thin slab
against the untrimmed roof shell, so the cutaway shows red only on real
material (wall + deck bands) and leaves the hollow attic empty. Add an
analytic surface-edge outline, style both solid red like a SketchUp
section, and make the cutaway persist whenever a segment is trimmed.

Keep the merged roof shell visible during trim editing (re-trimmed live
from each segment's drag override) instead of swapping in the per-segment
meshes, whose abutting end-cap faces showed as stray white planes the
commit never had. Extend each slab past free cut-line ends only — trimmed
ends clamp to the cut line — so the red section stays inside the trim box.

Re-export INTERSECTION from the viewer CSG surface for the editor.

* Outline roof cutaway by fill silhouette, restyle to destructive red

Derive the section-cut outline from the fill geometry's edges
(EdgesGeometry) so it traces the real cut shape — wall/deck band
boundaries and the hollow-attic edge — instead of just the top surface
line. Drop the fill to 85% opacity and recolor both fill and outline to
the app's destructive red, matching the delete/destructive UI.

* Include roof accessories in trim clipping and red cutaway

Roof accessories (chimney, vents, skylight, dormer, gutter, downspout,
solar-panel, cupola) now slice at the trim plane like the roof shell and
appear in the red section-cut while dragging a trim handle:

- Export clipGeometryBySegmentTrim from the viewer as a reusable
  segment-local trim-clip primitive.
- Add a shared useSegmentTrimClippedGeometry hook + TrimClippedMesh
  wrapper (nodes) that slice accessory geometry by the host segment's
  live trim override, so the cut tracks the drag.
- Wire the clip into all 11 accessory renderers, including skylight
  glass panes and dormer window glass/frame/sill.
- Feed every hosted accessory mesh into the editor's red cutaway, welding
  triangle-soup geometry (e.g. ridge vent) so CSG INTERSECTION yields a
  cross-section.
- Register skylight in the scene-graph tree-node map so it shows in the
  outliner when placed on a roof.

Co-Authored-By: Claude <noreply@anthropic.com>

* Add smooth spline fences with editable curve handles

Fences can now be drawn as one continuous Catmull-Rom/Bezier curve via an
optional `path` (+ per-point `tangents`), selectable in a Straight/Curved
mode toggle. Selected spline fences expose draggable control-point dots
(hexagon) and symmetric tangent handles (circle) joined by a violet line,
editable in both 2D plan and 3D. Side-move arrows are dropped for splines.

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix dutch roof ridge vent handling

* Fix Dutch ridge vent placement and support

* Fix Dutch roof trim artifacts

* Fix Dutch roof trim preview geometry

* Tag roof trim overlay meshes with EDITOR_LAYER

Child meshes relied on a parent group's layer, which three.js does not
propagate, so the trim section/rail/plane overlays rendered on the scene
layer — getting inked/SSGI-darkened and leaking into thumbnail exports.

Co-Authored-By: Claude <noreply@anthropic.com>

* Apply Biome cleanup

* fix(core): address Dutch roof review feedback

* chore: apply biome check cleanup

* fix(core): relax Dutch roof surface helper input

* fix

* Fix biome checks and dev verification

* fixes

* Remove unsupported Biome noShadow override

* Improve roof interactions and fence editing

* Fix fence drag and ridge vent default handling

* editor: drop wall-snap debug log, gate curved-fence finish hint on draft start

Remove the leftover TEMP DIAGNOSTIC console.log in the wall tool's onMove
hot path.

Curved fences commit on a closing gesture (double-click / Enter) rather than
per-click, so surface a 'Finish curve' hint in the fence HUD — but only once a
point has been placed and a curve is actually in flight. The draft point count
is published from SplineFenceDraft into a small ephemeral editor store
(useFenceCurveDraft) that the contextual helper reads, mirroring the existing
useSegmentDraftChain pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
2026-06-30 16:41:13 -04:00

902 lines
29 KiB
TypeScript

import {
type AnyNode,
type AnyNodeId,
type RoofNode,
type RoofSegmentNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import * as THREE from 'three'
import { buildBoxVentGeometry } from '../box-vent/geometry'
import { buildChimneyGeometry } from '../chimney/geometry'
import { buildCupolaGeometry } from '../cupola/geometry'
import { buildDormerGhostGeometry } from '../dormer/geometry'
import { buildEyebrowVentGeometry } from '../eyebrow-vent/geometry'
import { buildGutterGeometry } from '../gutter/geometry'
import { buildRidgeVentGeometry } from '../ridge-vent/geometry'
import { buildFrameGeometry } from '../skylight/frame-csg'
import { buildSolarPanelGeometry } from '../solar-panel/geometry'
import { buildTurbineVentGeometry } from '../turbine-vent/geometry'
import type { RelativeRoofDragTarget } from './relative-roof-drag'
import { getRoofSurfaceFaceBoundsAt, getSurfaceY } from './roof-surface'
const MIN_DIMENSION_M = 0.02
const ALIGNMENT_THRESHOLD_M = 0.08
const EQUAL_SPACING_THRESHOLD_M = 0.03
const tmp = new THREE.Vector3()
const tmpA = new THREE.Vector3()
const tmpB = new THREE.Vector3()
const ROOF_SURFACE_FOOTPRINT_CACHE_MAX = 160
const roofSurfaceFootprintCache = new Map<
string,
Pick<RoofSurfaceGuideFootprint, 'width' | 'depth'> | null
>()
export type RoofSurfaceGuideMode = 'side-center' | 'linear-edge'
export type RoofSurfaceGuideFootprint = {
width: number
depth: number
rotation?: number
}
type RoofGuideBounds = {
centerX: number
centerZ: number
minX: number
maxX: number
minZ: number
maxZ: number
}
type RoofGuideSide = 'left' | 'right' | 'bottom' | 'top'
type RoofSiblingSpacingResult<T> = {
guides: T[]
blockedSides: Record<RoofGuideSide, boolean>
}
type RoofAlignmentFeature = 'min' | 'center' | 'max'
type RoofAlignmentCandidate = {
axis: 'x' | 'z'
coord: number
gap: number
from: [number, number]
to: [number, number]
}
type RoofEqualSpacingItem = {
bounds: RoofGuideBounds
moving: boolean
}
type RoofEqualSpacingGap = {
value: number
from: [number, number]
to: [number, number]
}
export function roofSurfaceFootprintFromNode(
node: unknown,
options?: { segment?: RoofSegmentNode },
): RoofSurfaceGuideFootprint {
const n = node as Record<string, unknown>
const geometryBounds = cachedGeometryFootprintForNode(n, options?.segment)
if (geometryBounds) {
return {
...geometryBounds,
rotation: numberField(n.rotation, 0),
}
}
if (n.type === 'solar-panel') {
const columns = numberField(n.columns, 1)
const rows = numberField(n.rows, 1)
const panelWidth = numberField(n.panelWidth, 1)
const panelHeight = numberField(n.panelHeight, 1)
const gapX = numberField(n.gapX, 0)
const gapY = numberField(n.gapY, 0)
return {
width: columns * panelWidth + Math.max(0, columns - 1) * gapX,
depth: rows * panelHeight + Math.max(0, rows - 1) * gapY,
rotation: numberField(n.rotation, 0),
}
}
if (n.type === 'ridge-vent') {
return {
width: numberField(n.length, 1),
depth: numberField(n.width, 0.3),
rotation: numberField(n.rotation, 0),
}
}
if (n.type === 'gutter') {
return {
width: numberField(n.length, 1),
depth: numberField(n.size, 0.13),
rotation: numberField(n.rotation, 0),
}
}
const width = numberField(n.width, numberField(n.diameter, 1))
const depth = numberField(n.depth, width)
return {
width,
depth,
rotation: numberField(n.rotation, 0),
}
}
function cachedGeometryFootprintForNode(
node: Record<string, unknown>,
segment: RoofSegmentNode | undefined,
): Pick<RoofSurfaceGuideFootprint, 'width' | 'depth'> | null {
const key = geometryFootprintCacheKey(node, segment)
if (roofSurfaceFootprintCache.has(key)) {
const cached = roofSurfaceFootprintCache.get(key)
return cached ? { ...cached } : null
}
const footprint = geometryFootprintForNode(node, segment)
roofSurfaceFootprintCache.set(key, footprint ? { ...footprint } : null)
if (roofSurfaceFootprintCache.size > ROOF_SURFACE_FOOTPRINT_CACHE_MAX) {
const oldestKey = roofSurfaceFootprintCache.keys().next().value
if (oldestKey) roofSurfaceFootprintCache.delete(oldestKey)
}
return footprint
}
function geometryFootprintCacheKey(
node: Record<string, unknown>,
segment: RoofSegmentNode | undefined,
): string {
const type = typeof node.type === 'string' ? node.type : 'unknown'
const segmentKey = type === 'chimney' && segment ? `|segment:${stableCacheKey(segment)}` : ''
return `${type}|node:${stableCacheKey(node)}${segmentKey}`
}
function stableCacheKey(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map((item) => stableCacheKey(item)).join(',')}]`
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, entryValue]) => typeof entryValue !== 'function' && entryValue !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableCacheKey(entryValue)}`).join(',')}}`
}
function geometryFootprintForNode(
node: Record<string, unknown>,
segment: RoofSegmentNode | undefined,
): Pick<RoofSurfaceGuideFootprint, 'width' | 'depth'> | null {
const bounds = new THREE.Box3()
const geometries: THREE.BufferGeometry[] = []
const add = (geometry: THREE.BufferGeometry | null | undefined) => {
if (geometry) geometries.push(geometry)
}
try {
switch (node.type) {
case 'box-vent':
add(buildBoxVentGeometry(node as Parameters<typeof buildBoxVentGeometry>[0]))
break
case 'turbine-vent':
add(buildTurbineVentGeometry(node as Parameters<typeof buildTurbineVentGeometry>[0]))
break
case 'eyebrow-vent':
add(buildEyebrowVentGeometry(node as Parameters<typeof buildEyebrowVentGeometry>[0]))
break
case 'solar-panel':
add(buildSolarPanelGeometry(node as Parameters<typeof buildSolarPanelGeometry>[0]))
break
case 'skylight':
add(
buildFrameGeometry({
curb: node.curb as never,
curbHeight: node.curbHeight as never,
frameDepth: node.frameDepth as never,
frameThickness: node.frameThickness as never,
height: node.height as never,
width: node.width as never,
}),
)
add(buildSkylightGlassBounds(node))
break
case 'cupola':
add(buildCupolaGeometry(node as Parameters<typeof buildCupolaGeometry>[0]))
break
case 'chimney':
if (segment) {
const geo = buildChimneyGeometry(
node as Parameters<typeof buildChimneyGeometry>[0],
segment,
)
add(geo.body)
add(geo.cap)
add(geo.flues)
add(geo.cricket)
add(geo.bands)
}
break
case 'ridge-vent':
add(buildRidgeVentGeometry(node as Parameters<typeof buildRidgeVentGeometry>[0]))
break
case 'gutter':
add(buildGutterGeometry(node as Parameters<typeof buildGutterGeometry>[0]))
break
case 'dormer':
add(buildDormerGhostGeometry(node as Parameters<typeof buildDormerGhostGeometry>[0]))
break
}
if (geometries.length === 0) return null
bounds.makeEmpty()
for (const geometry of geometries) {
geometry.computeBoundingBox()
if (geometry.boundingBox) bounds.union(geometry.boundingBox)
}
if (bounds.isEmpty()) return null
if (
!Number.isFinite(bounds.min.x) ||
!Number.isFinite(bounds.max.x) ||
!Number.isFinite(bounds.min.z) ||
!Number.isFinite(bounds.max.z)
) {
return null
}
return {
width: Math.max(0, bounds.max.x - bounds.min.x),
depth: Math.max(0, bounds.max.z - bounds.min.z),
}
} catch {
return null
} finally {
for (const geometry of geometries) geometry.dispose()
}
}
function buildSkylightGlassBounds(node: Record<string, unknown>): THREE.BufferGeometry {
const width = numberField(node.width, 1)
const height = numberField(node.height, 1)
const glassThickness = numberField(node.glassThickness, 0.01)
const curbHeight = node.curb ? Math.max(0, numberField(node.curbHeight, 0.1)) : 0
const geometry = new THREE.BoxGeometry(width, glassThickness, height)
geometry.translate(0, curbHeight + glassThickness / 2, 0)
return geometry
}
export function publishRoofSurfacePlacementGuides(args: {
roof: RoofNode
segment: RoofSegmentNode
center: readonly [number, number, number]
footprint: RoofSurfaceGuideFootprint
mode?: RoofSurfaceGuideMode
movingId?: string
}): void {
const { segment, center, footprint, mode = 'side-center', movingId } = args
const segObj = sceneRegistry.nodes.get(segment.id as AnyNodeId)
if (!segObj) return
const bounds = roofGuideBounds(center, footprint)
const halfW = Math.max(0, footprint.width) / 2
const cos = Math.cos(footprint.rotation ?? 0)
const sin = Math.sin(footprint.rotation ?? 0)
const faceBounds = getRoofSurfaceFaceBoundsAt(segment, center[0], center[2])
const faceKey = roofFaceKey(faceBounds.polygon)
const toBuilding = (x: number, z: number): [number, number, number] => {
const y = faceBounds.surfaceYAt(x, z) + 0.035
tmp.set(x, y, z)
segObj.localToWorld(tmp)
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
if (buildingObj) buildingObj.worldToLocal(tmp)
return [tmp.x, tmp.y, tmp.z]
}
const dimension = (
id: string,
from: [number, number],
to: [number, number],
): OpeningGuide3D | null => {
const from3 = toBuilding(from[0], from[1])
const to3 = toBuilding(to[0], to[1])
const value = tmpA.set(...from3).distanceTo(tmpB.set(...to3))
if (value <= MIN_DIMENSION_M) return null
return {
kind: 'dimension',
id,
from: from3,
to: to3,
value,
}
}
const alignLine = (
id: string,
from: [number, number],
to: [number, number],
): OpeningGuide3D | null => {
const from3 = toBuilding(from[0], from[1])
const to3 = toBuilding(to[0], to[1])
const value = tmpA.set(...from3).distanceTo(tmpB.set(...to3))
if (value <= MIN_DIMENSION_M) return null
return {
kind: 'align-line',
id,
from: from3,
to: to3,
}
}
const measure = (from: [number, number], to: [number, number]): number => {
const from3 = toBuilding(from[0], from[1])
const to3 = toBuilding(to[0], to[1])
return tmpA.set(...from3).distanceTo(tmpB.set(...to3))
}
const badge = (id: string, at: [number, number], value: number): OpeningGuide3D | null => {
if (value <= MIN_DIMENSION_M) return null
return {
kind: 'badge',
id,
at: toBuilding(at[0], at[1]),
value,
}
}
const guides: OpeningGuide3D[] = []
const siblingSpacing =
mode === 'linear-edge'
? null
: roofSiblingSpacing({
segment,
movingId,
movingBounds: bounds,
faceKey,
dimension,
alignLine,
badge,
measure,
})
if (mode === 'linear-edge') {
const useX = Math.abs(cos) >= Math.abs(sin)
if (useX) {
const interval = faceBounds.xIntervalAtZ(center[2])
if (interval) {
const [faceMinX, faceMaxX] = interval
const startX = clamp(bounds.centerX - halfW, faceMinX, faceMaxX)
const endX = clamp(bounds.centerX + halfW, faceMinX, faceMaxX)
const left = dimension('roof-gap:left', [faceMinX, center[2]], [startX, center[2]])
const right = dimension('roof-gap:right', [endX, center[2]], [faceMaxX, center[2]])
if (left) guides.push(left)
if (right) guides.push(right)
}
} else {
const interval = faceBounds.zIntervalAtX(center[0])
if (interval) {
const [faceMinZ, faceMaxZ] = interval
const startZ = clamp(bounds.centerZ - halfW, faceMinZ, faceMaxZ)
const endZ = clamp(bounds.centerZ + halfW, faceMinZ, faceMaxZ)
const bottom = dimension('roof-gap:bottom', [center[0], faceMinZ], [center[0], startZ])
const top = dimension('roof-gap:top', [center[0], endZ], [center[0], faceMaxZ])
if (bottom) guides.push(bottom)
if (top) guides.push(top)
}
}
} else {
const xInterval = faceBounds.xIntervalAtZ(center[2])
const zInterval = faceBounds.zIntervalAtX(center[0])
if (xInterval) {
const [faceMinX, faceMaxX] = xInterval
const itemMinX = clamp(bounds.minX, faceMinX, faceMaxX)
const itemMaxX = clamp(bounds.maxX, faceMinX, faceMaxX)
if (!siblingSpacing?.blockedSides.left) {
const left = dimension('roof-gap:left', [faceMinX, center[2]], [itemMinX, center[2]])
if (left) guides.push(left)
}
if (!siblingSpacing?.blockedSides.right) {
const right = dimension('roof-gap:right', [itemMaxX, center[2]], [faceMaxX, center[2]])
if (right) guides.push(right)
}
}
if (zInterval) {
const [faceMinZ, faceMaxZ] = zInterval
const itemMinZ = clamp(bounds.minZ, faceMinZ, faceMaxZ)
const itemMaxZ = clamp(bounds.maxZ, faceMinZ, faceMaxZ)
if (!siblingSpacing?.blockedSides.bottom) {
const bottom = dimension('roof-gap:bottom', [center[0], faceMinZ], [center[0], itemMinZ])
if (bottom) guides.push(bottom)
}
if (!siblingSpacing?.blockedSides.top) {
const top = dimension('roof-gap:top', [center[0], itemMaxZ], [center[0], faceMaxZ])
if (top) guides.push(top)
}
}
}
if (siblingSpacing) guides.push(...siblingSpacing.guides)
useOpeningGuides.getState().set(guides)
}
export function publishRoofSurfaceNodePlacementGuides(args: {
roof: RoofNode
segment: RoofSegmentNode
center: readonly [number, number, number]
node: unknown
mode?: RoofSurfaceGuideMode
movingId?: string
}): void {
const movingId =
args.movingId ??
((args.node as { id?: unknown }).id && typeof (args.node as { id?: unknown }).id === 'string'
? (args.node as { id: string }).id
: undefined)
publishRoofSurfacePlacementGuides({
roof: args.roof,
segment: args.segment,
center: args.center,
footprint: roofSurfaceFootprintFromNode(args.node, { segment: args.segment }),
mode: args.mode,
movingId,
})
}
export function snapRoofSurfaceNodeTarget(args: {
target: RelativeRoofDragTarget
node: unknown
movingId?: string
bypass?: boolean
}): RelativeRoofDragTarget {
if (args.bypass) return args.target
const movingId =
args.movingId ??
((args.node as { id?: unknown }).id && typeof (args.node as { id?: unknown }).id === 'string'
? (args.node as { id: string }).id
: undefined)
const movingBounds = roofGuideBounds(
[args.target.localX, args.target.localY, args.target.localZ],
roofSurfaceFootprintFromNode(args.node, { segment: args.target.segment }),
)
const faceKey = roofFaceKey(
getRoofSurfaceFaceBoundsAt(args.target.segment, args.target.localX, args.target.localZ).polygon,
)
const snap = roofAlignmentSnap({
segment: args.target.segment,
movingId,
movingBounds,
faceKey,
})
if (!snap) return args.target
const localX = args.target.localX + (snap.dx ?? 0)
const localZ = args.target.localZ + (snap.dz ?? 0)
const surfaceOffsetY =
args.target.localY - getSurfaceY(args.target.localX, args.target.localZ, args.target.segment)
const localY = getSurfaceY(localX, localZ, args.target.segment) + surfaceOffsetY
return {
...args.target,
localX,
localY,
localZ,
}
}
export function clearRoofSurfacePlacementGuides(): void {
useOpeningGuides.getState().clear()
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function roofGuideBounds(
center: readonly [number, number, number],
footprint: RoofSurfaceGuideFootprint,
): RoofGuideBounds {
const halfW = Math.max(0, footprint.width) / 2
const halfD = Math.max(0, footprint.depth) / 2
const rot = footprint.rotation ?? 0
const cos = Math.cos(rot)
const sin = Math.sin(rot)
const halfX = Math.abs(cos) * halfW + Math.abs(sin) * halfD
const halfZ = Math.abs(sin) * halfW + Math.abs(cos) * halfD
return {
centerX: center[0],
centerZ: center[2],
minX: center[0] - halfX,
maxX: center[0] + halfX,
minZ: center[2] - halfZ,
maxZ: center[2] + halfZ,
}
}
export function roofSiblingSpacingGuides<T>(args: {
segment: RoofSegmentNode
movingId?: string
movingBounds: RoofGuideBounds
faceKey: string
dimension: (id: string, from: [number, number], to: [number, number]) => T | null
}): T[] {
return roofSiblingSpacing(args).guides
}
export function roofSiblingSpacing<T>(args: {
segment: RoofSegmentNode
movingId?: string
movingBounds: RoofGuideBounds
faceKey: string
dimension: (id: string, from: [number, number], to: [number, number]) => T | null
alignLine?: (id: string, from: [number, number], to: [number, number]) => T | null
badge?: (id: string, at: [number, number], value: number) => T | null
measure?: (from: [number, number], to: [number, number]) => number
}): RoofSiblingSpacingResult<T> {
const out: T[] = []
const nodes = useScene.getState().nodes
let left: { bounds: RoofGuideBounds; gap: number } | null = null
let right: { bounds: RoofGuideBounds; gap: number } | null = null
let bottom: { bounds: RoofGuideBounds; gap: number } | null = null
let top: { bounds: RoofGuideBounds; gap: number } | null = null
let xAlign: RoofAlignmentCandidate | null = null
let zAlign: RoofAlignmentCandidate | null = null
const xLane: RoofGuideBounds[] = []
const zLane: RoofGuideBounds[] = []
for (const childId of args.segment.children ?? []) {
if (childId === args.movingId) continue
const sibling = nodes[childId as AnyNodeId]
if (!isRoofGuideSibling(sibling)) continue
const position = sibling.position
if (!Array.isArray(position)) continue
const siblingFace = getRoofSurfaceFaceBoundsAt(args.segment, position[0] ?? 0, position[2] ?? 0)
if (roofFaceKey(siblingFace.polygon) !== args.faceKey) continue
const footprint = roofSurfaceFootprintFromNode(sibling, { segment: args.segment })
const bounds = roofGuideBounds(position as [number, number, number], footprint)
xAlign = nearerAlignment(xAlign, detectRoofAlignment(args.movingBounds, bounds, 'x'))
zAlign = nearerAlignment(zAlign, detectRoofAlignment(args.movingBounds, bounds, 'z'))
if (sameGuideLane(args.movingBounds, bounds, 'x')) {
xLane.push(bounds)
const gapToLeft = args.movingBounds.minX - bounds.maxX
if (gapToLeft > MIN_DIMENSION_M && (!left || gapToLeft < left.gap)) {
left = { bounds, gap: gapToLeft }
}
const gapToRight = bounds.minX - args.movingBounds.maxX
if (gapToRight > MIN_DIMENSION_M && (!right || gapToRight < right.gap)) {
right = { bounds, gap: gapToRight }
}
}
if (sameGuideLane(args.movingBounds, bounds, 'z')) {
zLane.push(bounds)
const gapToBottom = args.movingBounds.minZ - bounds.maxZ
if (gapToBottom > MIN_DIMENSION_M && (!bottom || gapToBottom < bottom.gap)) {
bottom = { bounds, gap: gapToBottom }
}
const gapToTop = bounds.minZ - args.movingBounds.maxZ
if (gapToTop > MIN_DIMENSION_M && (!top || gapToTop < top.gap)) {
top = { bounds, gap: gapToTop }
}
}
}
if (left) {
const guide = args.dimension(
'roof-sibling:left',
[left.bounds.maxX, args.movingBounds.centerZ],
[args.movingBounds.minX, args.movingBounds.centerZ],
)
if (guide) out.push(guide)
}
if (right) {
const guide = args.dimension(
'roof-sibling:right',
[args.movingBounds.maxX, args.movingBounds.centerZ],
[right.bounds.minX, args.movingBounds.centerZ],
)
if (guide) out.push(guide)
}
if (bottom) {
const guide = args.dimension(
'roof-sibling:bottom',
[args.movingBounds.centerX, bottom.bounds.maxZ],
[args.movingBounds.centerX, args.movingBounds.minZ],
)
if (guide) out.push(guide)
}
if (top) {
const guide = args.dimension(
'roof-sibling:top',
[args.movingBounds.centerX, args.movingBounds.maxZ],
[args.movingBounds.centerX, top.bounds.minZ],
)
if (guide) out.push(guide)
}
if (args.alignLine) {
if (xAlign) {
const guide = args.alignLine('roof-align:x', xAlign.from, xAlign.to)
if (guide) out.push(guide)
}
if (zAlign) {
const guide = args.alignLine('roof-align:z', zAlign.from, zAlign.to)
if (guide) out.push(guide)
}
}
if (args.badge) {
pushRoofEqualSpacingBadges({
axis: 'x',
movingBounds: args.movingBounds,
siblings: xLane,
badge: args.badge,
measure: args.measure,
out,
})
pushRoofEqualSpacingBadges({
axis: 'z',
movingBounds: args.movingBounds,
siblings: zLane,
badge: args.badge,
measure: args.measure,
out,
})
}
return {
guides: out,
blockedSides: {
left: !!left,
right: !!right,
bottom: !!bottom,
top: !!top,
},
}
}
function pushRoofEqualSpacingBadges<T>(args: {
axis: 'x' | 'z'
movingBounds: RoofGuideBounds
siblings: RoofGuideBounds[]
badge: (id: string, at: [number, number], value: number) => T | null
measure?: (from: [number, number], to: [number, number]) => number
out: T[]
}): void {
if (args.siblings.length < 2) return
const items: RoofEqualSpacingItem[] = [
{ bounds: args.movingBounds, moving: true },
...args.siblings.map((bounds) => ({ bounds, moving: false })),
].sort((a, b) =>
args.axis === 'x' ? a.bounds.centerX - b.bounds.centerX : a.bounds.centerZ - b.bounds.centerZ,
)
const movingIndex = items.findIndex((item) => item.moving)
if (movingIndex < 0) return
const gaps: RoofEqualSpacingGap[] = []
for (let i = 0; i < items.length - 1; i++) {
const a = items[i]
const b = items[i + 1]
if (!a || !b) continue
const from: [number, number] =
args.axis === 'x'
? [a.bounds.maxX, args.movingBounds.centerZ]
: [args.movingBounds.centerX, a.bounds.maxZ]
const to: [number, number] =
args.axis === 'x'
? [b.bounds.minX, args.movingBounds.centerZ]
: [args.movingBounds.centerX, b.bounds.minZ]
const value = args.measure?.(from, to) ?? Math.hypot(to[0] - from[0], to[1] - from[1])
gaps.push({ value, from, to })
}
let best: { value: number; gaps: RoofEqualSpacingGap[] } | null = null
for (let lo = 0; lo < gaps.length; lo++) {
let min = Number.POSITIVE_INFINITY
let max = Number.NEGATIVE_INFINITY
for (let hi = lo; hi < gaps.length; hi++) {
const gap = gaps[hi]
if (!gap || gap.value < MIN_DIMENSION_M) break
min = Math.min(min, gap.value)
max = Math.max(max, gap.value)
if (max - min > EQUAL_SPACING_THRESHOLD_M) break
const gapCount = hi - lo + 1
if (gapCount < 2) continue
const firstItem = lo
const lastItem = hi + 1
if (movingIndex < firstItem || movingIndex > lastItem) continue
if (best !== null && gapCount <= best.gaps.length) continue
const run = gaps.slice(lo, hi + 1)
best = {
value: run.reduce((sum, g) => sum + g.value, 0) / run.length,
gaps: run,
}
}
}
best?.gaps.forEach((gap, index) => {
const guide = args.badge(
`roof-spacing:${args.axis}:${index}`,
mid2(gap.from, gap.to),
best.value,
)
if (guide) args.out.push(guide)
})
}
function mid2(a: [number, number], b: [number, number]): [number, number] {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
}
function sameGuideLane(a: RoofGuideBounds, b: RoofGuideBounds, axis: 'x' | 'z'): boolean {
if (axis === 'x') {
return valueWithinRange(a.centerZ, b.minZ, b.maxZ)
}
return valueWithinRange(a.centerX, b.minX, b.maxX)
}
function valueWithinRange(value: number, min: number, max: number): boolean {
return value >= min - ALIGNMENT_THRESHOLD_M && value <= max + ALIGNMENT_THRESHOLD_M
}
function roofAlignmentSnap(args: {
segment: RoofSegmentNode
movingId?: string
movingBounds: RoofGuideBounds
faceKey: string
}): { dx?: number; dz?: number } | null {
const nodes = useScene.getState().nodes
let bestX: { delta: number; gap: number } | null = null
let bestZ: { delta: number; gap: number } | null = null
for (const childId of args.segment.children ?? []) {
if (childId === args.movingId) continue
const sibling = nodes[childId as AnyNodeId]
if (!isRoofGuideSibling(sibling)) continue
const position = sibling.position
if (!Array.isArray(position)) continue
const siblingFace = getRoofSurfaceFaceBoundsAt(args.segment, position[0] ?? 0, position[2] ?? 0)
if (roofFaceKey(siblingFace.polygon) !== args.faceKey) continue
const footprint = roofSurfaceFootprintFromNode(sibling, { segment: args.segment })
const siblingBounds = roofGuideBounds(position as [number, number, number], footprint)
bestX = nearerSnap(bestX, detectRoofAlignmentSnap(args.movingBounds, siblingBounds, 'x'))
bestZ = nearerSnap(bestZ, detectRoofAlignmentSnap(args.movingBounds, siblingBounds, 'z'))
}
if (!bestX && !bestZ) return null
return {
dx: bestX?.delta,
dz: bestZ?.delta,
}
}
function detectRoofAlignmentSnap(
moving: RoofGuideBounds,
sibling: RoofGuideBounds,
axis: 'x' | 'z',
): { delta: number; gap: number } | null {
let best: { delta: number; gap: number } | null = null
for (const movingFeature of ROOF_ALIGNMENT_FEATURES) {
const movingCoord = roofFeatureCoord(moving, axis, movingFeature)
for (const siblingFeature of ROOF_ALIGNMENT_FEATURES) {
const siblingCoord = roofFeatureCoord(sibling, axis, siblingFeature)
const delta = siblingCoord - movingCoord
const gap = Math.abs(delta)
if (gap <= ALIGNMENT_THRESHOLD_M && (!best || gap < best.gap)) {
best = { delta, gap }
}
}
}
return best
}
function nearerSnap(
current: { delta: number; gap: number } | null,
candidate: { delta: number; gap: number } | null,
): { delta: number; gap: number } | null {
if (!candidate) return current
if (!current || candidate.gap < current.gap) return candidate
return current
}
function detectRoofAlignment(
moving: RoofGuideBounds,
sibling: RoofGuideBounds,
axis: 'x' | 'z',
): RoofAlignmentCandidate | null {
let best: RoofAlignmentCandidate | null = null
for (const movingFeature of ROOF_ALIGNMENT_FEATURES) {
const movingCoord = roofFeatureCoord(moving, axis, movingFeature)
for (const siblingFeature of ROOF_ALIGNMENT_FEATURES) {
const siblingCoord = roofFeatureCoord(sibling, axis, siblingFeature)
const gap = Math.abs(siblingCoord - movingCoord)
if (gap > ALIGNMENT_THRESHOLD_M || (best && gap >= best.gap)) continue
const coord = siblingCoord
if (axis === 'x') {
best = {
axis,
coord,
gap,
from: [coord, Math.min(moving.minZ, sibling.minZ)],
to: [coord, Math.max(moving.maxZ, sibling.maxZ)],
}
} else {
best = {
axis,
coord,
gap,
from: [Math.min(moving.minX, sibling.minX), coord],
to: [Math.max(moving.maxX, sibling.maxX), coord],
}
}
}
}
return best
}
const ROOF_ALIGNMENT_FEATURES: RoofAlignmentFeature[] = ['center', 'min', 'max']
function roofFeatureCoord(
bounds: RoofGuideBounds,
axis: 'x' | 'z',
feature: RoofAlignmentFeature,
): number {
if (axis === 'x') {
if (feature === 'min') return bounds.minX
if (feature === 'max') return bounds.maxX
return bounds.centerX
}
if (feature === 'min') return bounds.minZ
if (feature === 'max') return bounds.maxZ
return bounds.centerZ
}
function nearerAlignment(
current: RoofAlignmentCandidate | null,
candidate: RoofAlignmentCandidate | null,
): RoofAlignmentCandidate | null {
if (!candidate) return current
if (!current || candidate.gap < current.gap) return candidate
return current
}
function isRoofGuideSibling(node: AnyNode | undefined): node is AnyNode & {
position: readonly [number, number, number]
} {
if (!node || !Array.isArray((node as { position?: unknown }).position)) return false
switch (node.type) {
case 'box-vent':
case 'turbine-vent':
case 'eyebrow-vent':
case 'solar-panel':
case 'skylight':
case 'cupola':
case 'chimney':
case 'ridge-vent':
case 'gutter':
case 'dormer':
return true
default:
return false
}
}
export function roofFaceKey(polygon: readonly (readonly [number, number])[]): string {
return polygon.map(([x, z]) => `${roundKey(x)}:${roundKey(z)}`).join('|')
}
function roundKey(value: number): string {
return value.toFixed(4)
}
function numberField(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}