Skip to content
Merged
7 changes: 7 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ export {
getWallPlanFootprint,
getWallThickness,
} from './systems/wall/wall-footprint'
export { planWallMerge } from './systems/wall/wall-merge'
export {
calculateLevelMiters,
getAdjacentWallIds,
Expand All @@ -475,6 +476,12 @@ export {
type WallMoveLinkedWallTargetPlan,
type WallPlanPoint,
} from './systems/wall/wall-move'
export {
planWallDivision,
planWallDivisions,
planWallRectangle,
wallRectangleCorners,
} from './systems/wall/wall-operations'
export {
MIN_WALL_HEIGHT,
resolveWallEffectiveHeight,
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1411,6 +1411,13 @@ export type NodeDefinition<S extends ZodObject<any>> = {
*/
toolHints?: ToolHint[]

/**
* HUD hints for the kind's own reshapes, keyed by reshape name (see
* `affordanceTools`): shown while a node of this kind is in that `reshaping`
* scope, with the same chips and visibility rules as `toolHints`.
*/
affordanceHints?: Record<string, ToolHint[]>

/**
* Pick-one option rows for this kind's build tool, rendered by the shared
* `<ToolOptionsPanel>` in whichever sidebar the host mounts it (see
Expand Down
148 changes: 9 additions & 139 deletions packages/core/src/store/actions/node-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ import {
type GutterNode,
generateId,
getDefaultGutterSide,
getEffectiveWallSurfaceMaterial,
getWallSurfaceMaterialSignature,
isAutoGutterEnabled,
isAutoRidgeVentEnabled,
isDefaultDownspoutNode,
Expand All @@ -29,6 +27,14 @@ import {
} from '../../schema'
import type { CollectionId } from '../../schema/collections'
import { constrainWallCurveOffsetToAvoidIntersections } from '../../systems/wall/wall-curve'
import {
areWallStylesCompatible,
areWallsCollinearAcrossPoint,
buildMergedWallAttachmentUpdates,
getWallEndpointAtPoint,
resolveMergedWallEndpoints,
type WallAttachmentUpdate,
} from '../../systems/wall/wall-merge'
import {
activeSceneCommitNodeIds,
addActiveSceneCommitNodeIds,
Expand All @@ -40,7 +46,6 @@ type AnyContainerNode = AnyNode & { children: string[] }
type NodeCreateOp = { node: AnyNode; parentId?: AnyNodeId }
type NodeUpdateOp = { id: AnyNodeId; data: Partial<AnyNode> }
type NodeDeleteOp = AnyNodeId
type WallAttachmentUpdate = { id: AnyNodeId; data: Partial<AnyNode> }
type WallMergePlan = {
primaryWallId: AnyNodeId
secondaryWallId: AnyNodeId
Expand Down Expand Up @@ -1008,141 +1013,6 @@ function refreshDefaultGuttersForRoofIds(
let pendingRafId: number | null = null
let pendingUpdates: Set<AnyNodeId> = new Set()

function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
return dx * dx + dz * dz <= tolerance * tolerance
}

function wallLength(wall: Pick<WallNode, 'start' | 'end'>) {
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
}

function getWallEndpointAtPoint(
wall: Pick<WallNode, 'start' | 'end'>,
point: [number, number],
): 'start' | 'end' | null {
if (pointsEqual(wall.start, point)) return 'start'
if (pointsEqual(wall.end, point)) return 'end'
return null
}

function getWallFreeEndpoint(wall: Pick<WallNode, 'start' | 'end'>, sharedPoint: [number, number]) {
return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start
}

function areWallStylesCompatible(a: WallNode, b: WallNode) {
const aInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'interior'))
const bInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'interior'))
const aExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'exterior'))
const bExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'exterior'))

return (
(a.parentId ?? null) === (b.parentId ?? null) &&
Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 &&
Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 &&
// Absent height means plane-bound (follows the storey), which must never
// merge with an explicit height — even one that currently matches the plane.
(a.height == null) === (b.height == null) &&
Math.abs((a.height ?? 0) - (b.height ?? 0)) <= 1e-6 &&
aInterior === bInterior &&
aExterior === bExterior &&
a.frontSide === b.frontSide &&
a.backSide === b.backSide &&
a.visible === b.visible
)
}

function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) {
const freeA = getWallFreeEndpoint(a, sharedPoint)
const freeB = getWallFreeEndpoint(b, sharedPoint)
const ax = freeA[0] - sharedPoint[0]
const az = freeA[1] - sharedPoint[1]
const bx = freeB[0] - sharedPoint[0]
const bz = freeB[1] - sharedPoint[1]
const lenA = Math.hypot(ax, az)
const lenB = Math.hypot(bx, bz)

if (lenA < 1e-6 || lenB < 1e-6) return false

const cross = (ax * bz - az * bx) / (lenA * lenB)
const dot = (ax * bx + az * bz) / (lenA * lenB)
return Math.abs(cross) <= 1e-4 && dot < -0.999
}

function resolveMergedWallEndpoints(
primary: WallNode,
secondary: WallNode,
sharedPoint: [number, number],
): { start: [number, number]; end: [number, number] } {
const primaryEndpoint = getWallEndpointAtPoint(primary, sharedPoint)
const secondaryEndpoint = getWallEndpointAtPoint(secondary, sharedPoint)

if (primaryEndpoint === 'end' && secondaryEndpoint === 'start') {
return { start: primary.start, end: secondary.end }
}
if (primaryEndpoint === 'start' && secondaryEndpoint === 'end') {
return { start: secondary.start, end: primary.end }
}
if (primaryEndpoint === 'start' && secondaryEndpoint === 'start') {
return { start: primary.end, end: secondary.end }
}

return { start: primary.start, end: secondary.start }
}

function buildMergedWallAttachmentUpdates(
primary: WallNode,
secondary: WallNode,
mergedWallId: AnyNodeId,
mergedStart: [number, number],
mergedEnd: [number, number],
nodes: Record<AnyNodeId, AnyNode>,
): WallAttachmentUpdate[] {
const mergedLength = Math.max(
Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]),
1e-6,
)
const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength
const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength
const updates: WallAttachmentUpdate[] = []

const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[]
for (const childId of wallChildren) {
const child = nodes[childId]
if (!(child && 'position' in child && Array.isArray(child.position))) {
continue
}

const sourceWall = child.parentId === secondary.id ? secondary : primary
const sourceLength = Math.max(wallLength(sourceWall), 1e-6)
const localX = typeof child.position[0] === 'number' ? child.position[0] : 0
const worldX =
sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength
const worldZ =
sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength
const nextLocalX = Math.max(
0,
Math.min(
mergedLength,
(worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ,
),
)

updates.push({
id: childId,
data: {
parentId: mergedWallId,
wallId: mergedWallId,
position: [nextLocalX, child.position[1], child.position[2]] as typeof child.position,
...('wallT' in child ? { wallT: nextLocalX / mergedLength } : {}),
} as Partial<AnyNode>,
})
}

return updates
}

function buildWallMergePlans(
nodes: Record<AnyNodeId, AnyNode>,
idsToDelete: AnyNodeId[],
Expand All @@ -1162,7 +1032,7 @@ function buildWallMergePlans(
if (node?.type !== 'wall') return false
if (skippedWallIds.has(node.id) || usedWallIds.has(node.id)) return false
if ((node.parentId ?? null) !== (deletedWall.parentId ?? null)) return false
return pointsEqual(node.start, junction) || pointsEqual(node.end, junction)
return getWallEndpointAtPoint(node, junction) !== null
})

if (candidates.length !== 2) {
Expand Down
Loading
Loading