* 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 * editor: per-door-type floor-plan symbols Render a distinct, static plan symbol for each door type in the registry floor-plan builder (`packages/nodes/src/door/floorplan.ts`), independent of the door's live open/close animation: - single / hinged: fixed 90° swing with a dashed quarter-circle arc - double / french: two mirrored half-width leaves + dashed arcs - folding / bifold: static zigzag accordion (~80% span) on the wall face - sliding: bypass — two overlapping panels on parallel tracks + arrow - pocket: thin white leaf, ~60% closed, sliding into the solid wall - barn: surface-mounted panel parked over the wall, dashed closed-ghost + slide arrow The swing arc is dashed in screen-pixel units (the renderer uses non-scaling-stroke). Symbols are oriented by hingesSide / swingDirection / slideDirection as appropriate. Also includes pre-existing working-tree changes unrelated to the door symbols: group move/rotate transform and box-select tweaks, and a regenerated ifc-converter next-env.d.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix recessed ceiling fixtures and draw safety * feat(editor): magnetic wall-snap with per-kind beacon (2D + 3D) Snap the wall draft / endpoint-move point onto existing wall geometry — corners, midpoints, wall–wall intersections, and along-wall edges — and show a beacon at the snap point whose glyph encodes what it caught (square = corner, triangle = midpoint, ✕ = intersection, circle = edge). - Pure snap geometry extracted to wall-snap-geometry.ts (unit-tested). - Ephemeral useWallSnapIndicator store drives a 3D pillar+glyph beacon and a 2D SVG glyph beacon, both indigo to match the alignment guides. - Gated by a new persisted "Magnetic snap" toggle in the Display menu (useEditor); honored by draw + commit + endpoint-move in both views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * editor: garage and open-doorway floor-plan symbols Extend the per-door-type plan symbols in the registry floor-plan builder (packages/nodes/src/door/floorplan.ts): - open doorway (openingKind === 'opening'): bare gap, no leaf/arc/panel (mirrors the 3D system, which renders only the cutout for openings) - garage sectional: closed leaf + side tracks into the garage + dashed parked ghost at the inner end - garage roll-up: closed leaf + coil barrel (capsule) with a coil hint - garage tilt-up: closed leaf + dashed parked panel + dashed curved up-and-over swing path - gate the swing arc to actual swing doors (hinged/double/french) so other types fall back to the plain footprint Garage mechanisms sit on the interior (door-local -z) side to match the 3D garage builders, independent of swingDirection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * arch: enforce layer boundaries — registry dispatch, store relocation, shared helper Three architectural fixes to bring the branch into full compliance: 1. **ceiling-system kind check → CeilingCutCapability** Replace `child.type === 'item'` branch in `ceiling-system` with registry dispatch. Add `CeilingCutCapability` type to `packages/core` registry types, implement `buildCeilingHole` on `itemDefinition`, and rewrite `collectRecessedItemHoles` → `collectCeilingHoles` to dispatch through `nodeRegistry` — viewer never again inspects a node's kind directly. 2. **useAlignmentGuides + useWallSnapIndicator → packages/editor** These stores are editor-only UI (snap beacons, alignment guides). Move them from `packages/core/src/store/` to `packages/editor/src/store/`, re-export from `packages/editor`, and update all 34 consumer files across `packages/editor` and `packages/nodes` to import from `@pascal-app/editor`. 3. **findLevelAncestorId extracted to core** `item-light-system` had a private `resolveNodeLevelId` that duplicated level-ancestor traversal logic. Extract it as `findLevelAncestorId` in `packages/core` (spatial-grid-sync), export it, and replace the local copy. All four packages typecheck cleanly (zero errors). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: fix lint, untrack .claude/launch.json Run bun check --write to clear 8 Biome errors (formatting + import order + one unused import). Untrack .claude/launch.json and add it plus .claude/settings.local.json to .gitignore so local IDE/agent configs stop landing in commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
463 lines
16 KiB
TypeScript
463 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
collectAlignmentAnchors,
|
|
emitter,
|
|
type GridEvent,
|
|
type LevelNode,
|
|
resolveAlignment,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
CursorSphere,
|
|
EDITOR_LAYER,
|
|
markToolCancelConsumed,
|
|
triggerSFX,
|
|
useAlignmentGuides,
|
|
useEditor,
|
|
} from '@pascal-app/editor'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
|
import { mix, positionLocal } from 'three/tsl'
|
|
import { CeilingNode } from './schema'
|
|
|
|
/**
|
|
* Phase 5 Stage D — ceiling placement tool (kind-owned via `def.tool`).
|
|
*
|
|
* Multi-click polygon drawing at the ceiling height (2.52m default)
|
|
* with a vertical TSL-gradient connector + ground-shadow lines so the
|
|
* draft is visible against both the ceiling plane and the floor.
|
|
* Shift defeats the axis/45° snap during drag.
|
|
*/
|
|
|
|
const CEILING_HEIGHT = 2.52
|
|
const GRID_OFFSET = 0.02
|
|
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
|
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
|
|
function calculateSnapPoint(
|
|
lastPoint: [number, number],
|
|
currentPoint: [number, number],
|
|
): [number, number] {
|
|
const [x1, y1] = lastPoint
|
|
const [x, y] = currentPoint
|
|
const dx = x - x1
|
|
const dy = y - y1
|
|
const absDx = Math.abs(dx)
|
|
const absDy = Math.abs(dy)
|
|
const horizontalDist = absDy
|
|
const verticalDist = absDx
|
|
const diagonalDist = Math.abs(absDx - absDy)
|
|
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
|
if (minDist === diagonalDist) {
|
|
const diagonalLength = Math.min(absDx, absDy)
|
|
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
|
}
|
|
if (minDist === horizontalDist) return [x, y1]
|
|
return [x1, y]
|
|
}
|
|
|
|
function commitCeilingDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string {
|
|
const { createNode, nodes } = useScene.getState()
|
|
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
|
|
const name = `Ceiling ${ceilingCount + 1}`
|
|
// A placed ceiling preset seeds `toolDefaults.ceiling` (thickness, height,
|
|
// material, …) before the tool activates; the drawn polygon always wins.
|
|
const defaults = useEditor.getState().toolDefaults.ceiling ?? {}
|
|
const ceiling = CeilingNode.parse({ ...defaults, name, polygon: points })
|
|
createNode(ceiling, levelId)
|
|
triggerSFX('sfx:structure-build')
|
|
return ceiling.id
|
|
}
|
|
|
|
export const CeilingTool: React.FC = () => {
|
|
const cursorRef = useRef<Group>(null)
|
|
const gridCursorRef = useRef<Group>(null)
|
|
const mainLineRef = useRef<Line>(null!)
|
|
const closingLineRef = useRef<Line>(null!)
|
|
const groundMainLineRef = useRef<Line>(null!)
|
|
const groundClosingLineRef = useRef<Line>(null!)
|
|
const verticalLineRef = useRef<Line>(null!)
|
|
const currentLevelId = useViewer((s) => s.selection.levelId)
|
|
const setSelection = useViewer((s) => s.setSelection)
|
|
|
|
const [points, setPoints] = useState<Array<[number, number]>>([])
|
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
|
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
|
|
const [levelY, setLevelY] = useState(0)
|
|
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
|
const shiftPressed = useRef(false)
|
|
|
|
// Clear preset-seeded defaults on deactivation so a later manual ceiling
|
|
// draw isn't built with a stale preset's parameters. Unmount-only.
|
|
useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), [])
|
|
|
|
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs
|
|
// on every cursor move (cursorPosition is in its deps), so clearing guides
|
|
// in its cleanup would wipe the guide the instant after each move sets it.
|
|
useEffect(() => () => useAlignmentGuides.getState().clear(), [])
|
|
|
|
const verticalGeo = useMemo(
|
|
() =>
|
|
new BufferGeometry().setFromPoints([
|
|
new Vector3(0, 0, 0),
|
|
new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0),
|
|
]),
|
|
[],
|
|
)
|
|
|
|
const gradientOpacityNode = useMemo(
|
|
() => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),
|
|
[],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!currentLevelId) return
|
|
|
|
// Alignment candidates — anchors of every OTHER alignable object. The
|
|
// ceiling's own in-progress vertices are intentionally excluded (no
|
|
// self-alignment while drawing).
|
|
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
|
// Snap the drafted vertex onto another object's nearest real anchor and
|
|
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
|
|
// point: resolving against the grid point would only ever catch anchors
|
|
// that happen to sit on a grid line, so off-grid items (furniture, angled
|
|
// walls) would never surface a guide. The matched axis locks exactly to the
|
|
// candidate's coordinate; the other axis keeps its grid/ortho snap. Alt
|
|
// bypasses.
|
|
const alignPoint = (
|
|
fallback: [number, number],
|
|
raw: [number, number],
|
|
bypass: boolean,
|
|
): [number, number] => {
|
|
if (bypass || alignmentCandidates.length === 0) {
|
|
useAlignmentGuides.getState().clear()
|
|
return fallback
|
|
}
|
|
const ar = resolveAlignment({
|
|
moving: [{ nodeId: '__ceiling-draft__', kind: 'corner', x: raw[0], z: raw[1] }],
|
|
candidates: alignmentCandidates,
|
|
threshold: ALIGNMENT_THRESHOLD_M,
|
|
})
|
|
if (ar.guides.length === 0) {
|
|
useAlignmentGuides.getState().clear()
|
|
return fallback
|
|
}
|
|
useAlignmentGuides.getState().set(ar.guides)
|
|
let [x, z] = fallback
|
|
for (const guide of ar.guides) {
|
|
if (guide.axis === 'x') x = guide.coord
|
|
else z = guide.coord
|
|
}
|
|
return [x, z]
|
|
}
|
|
|
|
const onGridMove = (event: GridEvent) => {
|
|
if (!(cursorRef.current && gridCursorRef.current)) return
|
|
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
|
|
const gridX = Math.round(rawPoint[0] * 2) / 2
|
|
const gridZ = Math.round(rawPoint[1] * 2) / 2
|
|
const gridPosition: [number, number] = [gridX, gridZ]
|
|
setCursorPosition(gridPosition)
|
|
setLevelY(event.localPosition[1])
|
|
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
|
|
const gridY = event.localPosition[1] + GRID_OFFSET
|
|
const lastPoint = points[points.length - 1]
|
|
const orthoPoint =
|
|
shiftPressed.current || !lastPoint
|
|
? gridPosition
|
|
: calculateSnapPoint(lastPoint, gridPosition)
|
|
const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true)
|
|
setSnappedCursorPosition(displayPoint)
|
|
if (
|
|
points.length > 0 &&
|
|
previousSnappedPointRef.current &&
|
|
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
|
displayPoint[1] !== previousSnappedPointRef.current[1])
|
|
) {
|
|
triggerSFX('sfx:grid-snap')
|
|
}
|
|
previousSnappedPointRef.current = displayPoint
|
|
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
|
|
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
|
if (verticalLineRef.current) {
|
|
verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
|
}
|
|
}
|
|
|
|
const onGridClick = (_event: GridEvent) => {
|
|
if (!currentLevelId) return
|
|
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
|
|
const firstPoint = points[0]
|
|
if (
|
|
points.length >= 3 &&
|
|
firstPoint &&
|
|
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
|
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
|
) {
|
|
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
|
setSelection({ selectedIds: [ceilingId] })
|
|
setPoints([])
|
|
useAlignmentGuides.getState().clear()
|
|
} else {
|
|
// Every non-closing vertex is a "start" tick; the closing click above
|
|
// fires the structure-build (end) cue.
|
|
triggerSFX('sfx:structure-build-start')
|
|
setPoints([...points, clickPoint])
|
|
}
|
|
}
|
|
|
|
const onGridDoubleClick = (_event: GridEvent) => {
|
|
if (!currentLevelId) return
|
|
if (points.length >= 3) {
|
|
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
|
setSelection({ selectedIds: [ceilingId] })
|
|
setPoints([])
|
|
useAlignmentGuides.getState().clear()
|
|
}
|
|
}
|
|
|
|
const onCancel = () => {
|
|
if (points.length > 0) markToolCancelConsumed()
|
|
setPoints([])
|
|
useAlignmentGuides.getState().clear()
|
|
}
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Shift') shiftPressed.current = true
|
|
}
|
|
const onKeyUp = (e: KeyboardEvent) => {
|
|
if (e.key === 'Shift') shiftPressed.current = false
|
|
}
|
|
document.addEventListener('keydown', onKeyDown)
|
|
document.addEventListener('keyup', onKeyUp)
|
|
|
|
emitter.on('grid:move', onGridMove)
|
|
emitter.on('grid:click', onGridClick)
|
|
emitter.on('grid:double-click', onGridDoubleClick)
|
|
emitter.on('tool:cancel', onCancel)
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', onKeyDown)
|
|
document.removeEventListener('keyup', onKeyUp)
|
|
emitter.off('grid:move', onGridMove)
|
|
emitter.off('grid:click', onGridClick)
|
|
emitter.off('grid:double-click', onGridDoubleClick)
|
|
emitter.off('tool:cancel', onCancel)
|
|
}
|
|
}, [currentLevelId, points, cursorPosition, setSelection])
|
|
|
|
useEffect(() => {
|
|
if (!(mainLineRef.current && closingLineRef.current)) return
|
|
if (points.length === 0) {
|
|
mainLineRef.current.visible = false
|
|
closingLineRef.current.visible = false
|
|
if (groundMainLineRef.current) groundMainLineRef.current.visible = false
|
|
if (groundClosingLineRef.current) groundClosingLineRef.current.visible = false
|
|
return
|
|
}
|
|
const ceilingY = levelY + CEILING_HEIGHT
|
|
const snappedCursor = snappedCursorPosition
|
|
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
|
|
linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))
|
|
const gridY = levelY + GRID_OFFSET
|
|
const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z))
|
|
groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1]))
|
|
if (linePoints.length >= 2) {
|
|
mainLineRef.current.geometry.dispose()
|
|
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
|
|
mainLineRef.current.visible = true
|
|
groundMainLineRef.current.geometry.dispose()
|
|
groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)
|
|
groundMainLineRef.current.visible = true
|
|
} else {
|
|
mainLineRef.current.visible = false
|
|
groundMainLineRef.current.visible = false
|
|
}
|
|
const firstPoint = points[0]
|
|
if (points.length >= 2 && firstPoint) {
|
|
const closingPoints = [
|
|
new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),
|
|
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
|
|
]
|
|
closingLineRef.current.geometry.dispose()
|
|
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
|
|
closingLineRef.current.visible = true
|
|
const groundClosingPoints = [
|
|
new Vector3(snappedCursor[0], gridY, snappedCursor[1]),
|
|
new Vector3(firstPoint[0], gridY, firstPoint[1]),
|
|
]
|
|
groundClosingLineRef.current.geometry.dispose()
|
|
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(
|
|
groundClosingPoints,
|
|
)
|
|
groundClosingLineRef.current.visible = true
|
|
} else {
|
|
closingLineRef.current.visible = false
|
|
groundClosingLineRef.current.visible = false
|
|
}
|
|
}, [points, snappedCursorPosition, levelY])
|
|
|
|
const previewShape = useMemo(() => {
|
|
if (points.length < 3) return null
|
|
const snappedCursor = snappedCursorPosition
|
|
const allPoints = [...points, snappedCursor]
|
|
const firstPt = allPoints[0]
|
|
if (!firstPt) return null
|
|
const shape = new Shape()
|
|
shape.moveTo(firstPt[0], -firstPt[1])
|
|
for (let i = 1; i < allPoints.length; i++) {
|
|
const pt = allPoints[i]
|
|
if (pt) shape.lineTo(pt[0], -pt[1])
|
|
}
|
|
shape.closePath()
|
|
return shape
|
|
}, [points, snappedCursorPosition])
|
|
|
|
return (
|
|
<group>
|
|
<CursorSphere ref={cursorRef} />
|
|
<mesh
|
|
layers={EDITOR_LAYER}
|
|
ref={gridCursorRef}
|
|
renderOrder={2}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
>
|
|
<ringGeometry args={[0.15, 0.2, 32]} />
|
|
<meshBasicMaterial
|
|
color="#818cf8"
|
|
depthTest={false}
|
|
depthWrite={true}
|
|
opacity={0.5}
|
|
side={DoubleSide}
|
|
transparent
|
|
/>
|
|
</mesh>
|
|
{/* @ts-ignore */}
|
|
<line geometry={verticalGeo} layers={EDITOR_LAYER} ref={verticalLineRef} renderOrder={1}>
|
|
<lineBasicNodeMaterial
|
|
color="#818cf8"
|
|
depthTest={false}
|
|
depthWrite={false}
|
|
opacityNode={gradientOpacityNode}
|
|
transparent
|
|
/>
|
|
</line>
|
|
{previewShape && (
|
|
<mesh
|
|
frustumCulled={false}
|
|
layers={EDITOR_LAYER}
|
|
position={[0, levelY + CEILING_HEIGHT, 0]}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
>
|
|
<shapeGeometry args={[previewShape]} />
|
|
<meshBasicMaterial
|
|
color="#818cf8"
|
|
depthTest={false}
|
|
opacity={0.15}
|
|
side={DoubleSide}
|
|
transparent
|
|
/>
|
|
</mesh>
|
|
)}
|
|
{previewShape && (
|
|
<mesh
|
|
frustumCulled={false}
|
|
layers={EDITOR_LAYER}
|
|
position={[0, levelY + GRID_OFFSET, 0]}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
>
|
|
<shapeGeometry args={[previewShape]} />
|
|
<meshBasicMaterial
|
|
color="#818cf8"
|
|
depthTest={false}
|
|
opacity={0.1}
|
|
side={DoubleSide}
|
|
transparent
|
|
/>
|
|
</mesh>
|
|
)}
|
|
{/* @ts-ignore */}
|
|
<line
|
|
frustumCulled={false}
|
|
layers={EDITOR_LAYER}
|
|
// @ts-expect-error
|
|
ref={mainLineRef}
|
|
renderOrder={1}
|
|
visible={false}
|
|
>
|
|
<bufferGeometry />
|
|
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
|
</line>
|
|
{/* @ts-ignore */}
|
|
<line
|
|
frustumCulled={false}
|
|
layers={EDITOR_LAYER}
|
|
// @ts-expect-error
|
|
ref={closingLineRef}
|
|
renderOrder={1}
|
|
visible={false}
|
|
>
|
|
<bufferGeometry />
|
|
<lineBasicNodeMaterial
|
|
color="#818cf8"
|
|
depthTest={false}
|
|
depthWrite={false}
|
|
linewidth={2}
|
|
opacity={0.5}
|
|
transparent
|
|
/>
|
|
</line>
|
|
{/* @ts-ignore */}
|
|
<line
|
|
frustumCulled={false}
|
|
layers={EDITOR_LAYER}
|
|
// @ts-expect-error
|
|
ref={groundMainLineRef}
|
|
renderOrder={1}
|
|
visible={false}
|
|
>
|
|
<bufferGeometry />
|
|
<lineBasicNodeMaterial
|
|
color="#818cf8"
|
|
depthTest={false}
|
|
depthWrite={false}
|
|
linewidth={3}
|
|
opacity={0.3}
|
|
transparent
|
|
/>
|
|
</line>
|
|
{/* @ts-ignore */}
|
|
<line
|
|
frustumCulled={false}
|
|
layers={EDITOR_LAYER}
|
|
// @ts-expect-error
|
|
ref={groundClosingLineRef}
|
|
renderOrder={1}
|
|
visible={false}
|
|
>
|
|
<bufferGeometry />
|
|
<lineBasicNodeMaterial
|
|
color="#818cf8"
|
|
depthTest={false}
|
|
depthWrite={false}
|
|
linewidth={2}
|
|
opacity={0.15}
|
|
transparent
|
|
/>
|
|
</line>
|
|
{points.map(([x, z], index) => (
|
|
<CursorSphere
|
|
color="#818cf8"
|
|
key={index}
|
|
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
|
|
showTooltip={false}
|
|
/>
|
|
))}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
export default CeilingTool
|