315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
import type {
|
|
ColumnNode,
|
|
FloorplanGeometry,
|
|
FloorplanPoint,
|
|
GeometryContext,
|
|
} from '@pascal-app/core'
|
|
import type { ColumnResizePayload } from './floorplan-affordances'
|
|
|
|
// Offsets for the floor-plan selection arrows. Resize chevrons hug the
|
|
// footprint a hair off the rim; the rotate-arrow corner sits a bit
|
|
// further out so it doesn't crowd the resize arrows.
|
|
const RESIZE_ARROW_OFFSET = 0.12
|
|
const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
|
|
|
const ROUND_CROSS_SECTIONS = new Set<ColumnNode['crossSection']>([
|
|
'round',
|
|
'octagonal',
|
|
'sixteen-sided',
|
|
])
|
|
|
|
/**
|
|
* Stage C floor-plan builder for column. Inlined from the legacy
|
|
* `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The
|
|
* footprint shape depends on `crossSection` (square / rectangular /
|
|
* round / octagonal / sixteen-sided) and `supportStyle` (vertical /
|
|
* a-frame / x-brace / etc.) — brace supports use a rotated rectangle
|
|
* spanning the base spread; standalone columns use the shaft profile.
|
|
*
|
|
* When selected, switches to a themed accent stroke and emits the
|
|
* orange move-handle dot, four perpendicular side move-arrows for
|
|
* dragging the body, and a rotate-arrow at the front-right corner.
|
|
*/
|
|
export function buildColumnFloorplan(
|
|
node: ColumnNode,
|
|
ctx: GeometryContext,
|
|
): FloorplanGeometry | null {
|
|
const polygon = getColumnPlanFootprint(node)
|
|
if (polygon.length < 3) return null
|
|
|
|
const view = ctx.viewState
|
|
const palette = view?.palette
|
|
const isSelected = view?.selected ?? false
|
|
const isHighlighted = view?.highlighted ?? false
|
|
const showSelectedChrome = isSelected || isHighlighted
|
|
|
|
const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151'
|
|
const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af'
|
|
|
|
const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint)
|
|
|
|
const children: FloorplanGeometry[] = [
|
|
{
|
|
kind: 'polygon',
|
|
points,
|
|
fill,
|
|
stroke,
|
|
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
|
|
opacity: 0.92,
|
|
},
|
|
]
|
|
|
|
// Hatch overlay on selected — same `<defs>` pattern as the wall.
|
|
if (isSelected && palette) {
|
|
children.push({
|
|
kind: 'hatch',
|
|
points,
|
|
color: palette.selectedHatch,
|
|
opacity: 0.7,
|
|
})
|
|
}
|
|
|
|
// Selection chrome — move-handle dot at the centre (body move), one
|
|
// resize chevron per dimension the 3D handle set exposes (radius /
|
|
// uniform / width+depth or brace-width+brace-depth + per-style spread
|
|
// arrows), and a rotate-arrow at the front-right corner. Mirrors
|
|
// `column/definition.ts`'s handle selection so what users can
|
|
// manipulate in 3D top-view is the same in the floor plan.
|
|
if (isSelected) {
|
|
children.push({
|
|
kind: 'move-handle',
|
|
point: [node.position[0], node.position[2]],
|
|
})
|
|
|
|
const cx = node.position[0]
|
|
const cz = node.position[2]
|
|
// Floor-plan plots at `-rotation` so SVG-CW maps to Three.js-CCW.
|
|
// Selection chrome (side arrows, rotate-arrow) follows the same
|
|
// convention so it stays glued to the rotated footprint.
|
|
const rot = -node.rotation
|
|
const emitArrowAlong = (
|
|
dim: ColumnResizePayload['dim'],
|
|
localDirection: 'x' | 'z',
|
|
localOffsetDistance: number,
|
|
) => {
|
|
// Local position of the arrow's chevron tip relative to the
|
|
// column centre. The chevron sits a hair past the dimension's
|
|
// current half-extent so it never overlaps the footprint stroke.
|
|
const localPos: [number, number] =
|
|
localDirection === 'x' ? [localOffsetDistance, 0] : [0, localOffsetDistance]
|
|
const [worldOffsetX, worldOffsetZ] = rotatePlanVector(localPos[0], localPos[1], rot)
|
|
// The cursor projection axis: same direction as the arrow's
|
|
// outward tip in plan coords. Captured at emit-time so the
|
|
// affordance doesn't need to recompute `column.rotation` (and
|
|
// a mid-drag rotation can't drift the projection basis).
|
|
const outwardLocal: [number, number] = localDirection === 'x' ? [1, 0] : [0, 1]
|
|
const [planAxisX, planAxisY] = rotatePlanVector(outwardLocal[0], outwardLocal[1], rot)
|
|
children.push({
|
|
kind: 'move-arrow',
|
|
point: [cx + worldOffsetX, cz + worldOffsetZ],
|
|
angle: Math.atan2(planAxisY, planAxisX),
|
|
affordance: 'column-resize',
|
|
payload: { dim, planAxis: [planAxisX, planAxisY] } satisfies ColumnResizePayload,
|
|
})
|
|
}
|
|
|
|
if (node.supportStyle !== 'vertical') {
|
|
// Brace columns — width + depth of the bracing structure. Spread
|
|
// arrows (top + bottom) project to the same XZ in top-view, so
|
|
// we only surface bracing dimensions here. The 3D set still has
|
|
// spread arrows at different heights.
|
|
const halfBraceX =
|
|
Math.max(
|
|
node.width,
|
|
node.braceWidth ?? 0,
|
|
node.braceBottomSpread ?? 0,
|
|
node.braceTopSpread ?? 0,
|
|
) / 2
|
|
const halfBraceZ = Math.max(node.depth, node.braceDepth ?? 0) / 2
|
|
emitArrowAlong('brace-width', 'x', halfBraceX + RESIZE_ARROW_OFFSET)
|
|
emitArrowAlong('brace-depth', 'z', halfBraceZ + RESIZE_ARROW_OFFSET)
|
|
} else if (ROUND_CROSS_SECTIONS.has(node.crossSection)) {
|
|
// Round shafts — single radius arrow. `radial-resize` factor 1
|
|
// (cursor delta = radius delta), so dragging the chevron
|
|
// outward 1 unit grows the column by 1 unit.
|
|
emitArrowAlong('radius', 'x', node.radius + RESIZE_ARROW_OFFSET)
|
|
} else if (node.crossSection === 'square') {
|
|
// Square shafts — single uniform arrow that grows width + depth
|
|
// together (matches 3D `columnUniformHandle`).
|
|
emitArrowAlong('uniform', 'x', node.width / 2 + RESIZE_ARROW_OFFSET)
|
|
} else {
|
|
// Rectangular — independent width + depth arrows.
|
|
emitArrowAlong('width', 'x', node.width / 2 + RESIZE_ARROW_OFFSET)
|
|
emitArrowAlong('depth', 'z', node.depth / 2 + RESIZE_ARROW_OFFSET)
|
|
}
|
|
|
|
// Rotate-arrow at the +X / +Z corner — matches the 3D
|
|
// `columnRotateHandle` corner placement so users see the rotation
|
|
// affordance in the same quadrant across views.
|
|
const { halfX, halfZ } = columnPlanHalfExtents(node)
|
|
const cornerLocalX = halfX + ROTATE_ARROW_CORNER_OFFSET
|
|
const cornerLocalZ = halfZ + ROTATE_ARROW_CORNER_OFFSET
|
|
const [cornerWorldX, cornerWorldZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, rot)
|
|
const [radialX, radialZ] = rotatePlanVector(1, 1, rot)
|
|
children.push({
|
|
kind: 'rotate-arrow',
|
|
point: [cx + cornerWorldX, cz + cornerWorldZ],
|
|
angle: Math.atan2(radialZ, radialX),
|
|
affordance: 'column-rotate',
|
|
pivot: [cx, cz],
|
|
})
|
|
}
|
|
|
|
return { kind: 'group', children }
|
|
}
|
|
|
|
// ── Inlined helpers from legacy floorplan-panel.tsx ───────────────────
|
|
|
|
type PlanPoint = { x: number; y: number }
|
|
|
|
/**
|
|
* XZ half-extents for the column's plan footprint. Mirrors the 3D
|
|
* `columnFootprintHalf` so side-arrows + rotate-arrow ring the same
|
|
* bounding box the in-world handles use. Vertical supports read the
|
|
* shaft geometry; non-vertical supports use the widest brace bound so
|
|
* arrows clear the splay.
|
|
*/
|
|
function columnPlanHalfExtents(column: ColumnNode): { halfX: number; halfZ: number } {
|
|
if (column.supportStyle !== 'vertical') {
|
|
return {
|
|
halfX:
|
|
Math.max(
|
|
column.width,
|
|
column.braceWidth ?? 0,
|
|
column.braceBottomSpread ?? 0,
|
|
column.braceTopSpread ?? 0,
|
|
) / 2,
|
|
halfZ: Math.max(column.depth, column.braceDepth ?? 0) / 2,
|
|
}
|
|
}
|
|
if (
|
|
column.crossSection === 'round' ||
|
|
column.crossSection === 'octagonal' ||
|
|
column.crossSection === 'sixteen-sided'
|
|
) {
|
|
return { halfX: column.radius, halfZ: column.radius }
|
|
}
|
|
if (column.crossSection === 'square') {
|
|
return { halfX: column.width / 2, halfZ: column.width / 2 }
|
|
}
|
|
return { halfX: column.width / 2, halfZ: column.depth / 2 }
|
|
}
|
|
|
|
function rotatePlanVector(x: number, y: number, rotation: number): [number, number] {
|
|
const c = Math.cos(rotation)
|
|
const s = Math.sin(rotation)
|
|
return [x * c - y * s, x * s + y * c]
|
|
}
|
|
|
|
function getRotatedRectanglePolygon(
|
|
center: PlanPoint,
|
|
width: number,
|
|
depth: number,
|
|
rotation: number,
|
|
): PlanPoint[] {
|
|
const halfW = width / 2
|
|
const halfD = depth / 2
|
|
const corners: Array<[number, number]> = [
|
|
[-halfW, -halfD],
|
|
[halfW, -halfD],
|
|
[halfW, halfD],
|
|
[-halfW, halfD],
|
|
]
|
|
return corners.map(([x, y]) => {
|
|
const [rx, ry] = rotatePlanVector(x, y, rotation)
|
|
return { x: center.x + rx, y: center.y + ry }
|
|
})
|
|
}
|
|
|
|
function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] {
|
|
const center: PlanPoint = { x: column.position[0], y: column.position[2] }
|
|
|
|
// Brace-support columns: rotated rectangle spanning the base spread.
|
|
if (
|
|
column.supportStyle === 'a-frame' ||
|
|
column.supportStyle === 'y-frame' ||
|
|
column.supportStyle === 'v-frame' ||
|
|
column.supportStyle === 'x-brace' ||
|
|
column.supportStyle === 'k-brace' ||
|
|
column.supportStyle === 'single-strut' ||
|
|
column.supportStyle === 'tripod' ||
|
|
column.supportStyle === 'trestle' ||
|
|
column.supportStyle === 'portal-frame' ||
|
|
column.supportStyle === 'box-frame'
|
|
) {
|
|
const width = Math.max(
|
|
column.supportStyle === 'a-frame' ||
|
|
column.supportStyle === 'x-brace' ||
|
|
column.supportStyle === 'k-brace' ||
|
|
column.supportStyle === 'single-strut' ||
|
|
column.supportStyle === 'tripod' ||
|
|
column.supportStyle === 'trestle' ||
|
|
column.supportStyle === 'portal-frame' ||
|
|
column.supportStyle === 'box-frame'
|
|
? (column.braceBottomSpread ?? 1.2)
|
|
: 0,
|
|
column.braceTopSpread ??
|
|
(column.supportStyle === 'y-frame' ||
|
|
column.supportStyle === 'v-frame' ||
|
|
column.supportStyle === 'x-brace' ||
|
|
column.supportStyle === 'k-brace' ||
|
|
column.supportStyle === 'single-strut' ||
|
|
column.supportStyle === 'tripod' ||
|
|
column.supportStyle === 'trestle' ||
|
|
column.supportStyle === 'portal-frame' ||
|
|
column.supportStyle === 'box-frame'
|
|
? 1
|
|
: 0),
|
|
(column.braceWidth ?? column.width) * 2,
|
|
)
|
|
const depth = Math.max(
|
|
column.supportStyle === 'tripod' ||
|
|
column.supportStyle === 'trestle' ||
|
|
column.supportStyle === 'box-frame'
|
|
? (column.braceTopSpread ?? 1)
|
|
: 0,
|
|
column.braceDepth ?? column.depth,
|
|
0.08,
|
|
)
|
|
return getRotatedRectanglePolygon(center, width, depth, -column.rotation)
|
|
}
|
|
|
|
// Standalone column: shaft profile expanded for base + capital.
|
|
const isRound =
|
|
column.crossSection === 'round' ||
|
|
column.crossSection === 'octagonal' ||
|
|
column.crossSection === 'sixteen-sided'
|
|
const shaftWidth = isRound ? column.radius * 2 : column.width
|
|
const shaftDepth = isRound ? column.radius * 2 : column.depth
|
|
const width = Math.max(
|
|
shaftWidth,
|
|
column.width * column.baseWidthScale,
|
|
column.width * column.capitalWidthScale,
|
|
)
|
|
const depth = Math.max(
|
|
shaftDepth,
|
|
column.depth * column.baseDepthScale,
|
|
column.depth * column.capitalDepthScale,
|
|
)
|
|
|
|
if (column.crossSection === 'square' || column.crossSection === 'rectangular') {
|
|
return getRotatedRectanglePolygon(center, width, depth, -column.rotation)
|
|
}
|
|
|
|
const segmentCount =
|
|
column.crossSection === 'octagonal' ? 8 : column.crossSection === 'sixteen-sided' ? 16 : 32
|
|
|
|
return Array.from({ length: segmentCount }, (_, index) => {
|
|
const angle = (index / segmentCount) * Math.PI * 2
|
|
const localX = Math.cos(angle) * (width / 2)
|
|
const localY = Math.sin(angle) * (depth / 2)
|
|
const [offsetX, offsetY] = rotatePlanVector(localX, localY, -column.rotation)
|
|
return { x: center.x + offsetX, y: center.y + offsetY }
|
|
})
|
|
}
|