arch: enforce layer boundaries — ceiling dispatch, store relocation, shared helper (#382)
* 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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7796837081
commit
ce6f999310
@@ -48,3 +48,8 @@ og-test
|
||||
|
||||
# Worktrees
|
||||
.worktrees
|
||||
|
||||
# Local IDE/agent configs
|
||||
.claude/launch.json
|
||||
.claude/settings.local.json
|
||||
.vscode/launch.json
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
EyeOff,
|
||||
Footprints,
|
||||
Grid2X2,
|
||||
Magnet,
|
||||
PenLine,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
@@ -270,6 +271,8 @@ function DisplayMenu() {
|
||||
const setEdges = useViewer((state) => state.setEdges)
|
||||
const shadows = useViewer((state) => state.shadows)
|
||||
const setShadows = useViewer((state) => state.setShadows)
|
||||
const magneticSnap = useEditor((state) => state.magneticSnap)
|
||||
const setMagneticSnap = useEditor((state) => state.setMagneticSnap)
|
||||
|
||||
const activeShading =
|
||||
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
|
||||
@@ -311,6 +314,13 @@ function DisplayMenu() {
|
||||
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
|
||||
<Magnet className="h-4 w-4" />
|
||||
<span>Magnetic snap</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{magneticSnap ? 'On' : 'Off'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShadows(!shadows))}>
|
||||
<Contrast className="h-4 w-4" />
|
||||
<span>Shadows</span>
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -29,6 +29,32 @@ export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): s
|
||||
return 'default' // fallback for orphaned items
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the parent chain of `nodeId` and returns the id of the first ancestor
|
||||
* whose `type` is `'level'`, or `null` when no level ancestor exists (orphaned
|
||||
* node, top-level building node, etc.). Unlike `resolveLevelId`, this variant:
|
||||
*
|
||||
* - accepts a node **id** rather than a resolved node, saving the caller a
|
||||
* `nodes[id]` lookup when only the id is at hand.
|
||||
* - returns `null` instead of the `'default'` fallback, which lets callers
|
||||
* distinguish "genuinely has no level" from "is a level".
|
||||
* - has a loop guard (16 iterations) so a corrupt parent-chain cycle cannot
|
||||
* hang the frame loop.
|
||||
*/
|
||||
export function findLevelAncestorId(
|
||||
nodeId: AnyNodeId,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): string | null {
|
||||
let current: AnyNode | undefined = nodes[nodeId]
|
||||
let guard = 0
|
||||
while (current && guard < 16) {
|
||||
if (current.type === 'level') return current.id
|
||||
current = current.parentId ? nodes[current.parentId] : undefined
|
||||
guard += 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the building id that contains the given level, or `null` if
|
||||
* the level is unparented or no enclosing building exists.
|
||||
|
||||
@@ -46,6 +46,7 @@ export {
|
||||
} from './hooks/spatial-grid/floor-placed-elevation'
|
||||
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
|
||||
export {
|
||||
findLevelAncestorId,
|
||||
initSpatialGridSync,
|
||||
resolveBuildingForLevel,
|
||||
resolveLevelId,
|
||||
@@ -111,7 +112,6 @@ export {
|
||||
resetSceneHistoryPauseDepth,
|
||||
resumeSceneHistory,
|
||||
} from './store/history-control'
|
||||
export { default as useAlignmentGuides } from './store/use-alignment-guides'
|
||||
export {
|
||||
type ControlValue,
|
||||
type DoorAnimationState,
|
||||
|
||||
@@ -982,6 +982,12 @@ export type Capabilities = {
|
||||
*/
|
||||
alignmentFootprint?: AlignmentFootprintConfig
|
||||
roofAccessory?: RoofAccessoryConfig
|
||||
/**
|
||||
* Kind cuts a hole in the ceiling surface it is attached to (e.g. recessed
|
||||
* downlights). The viewer's `CeilingSystem` calls this for each child of a
|
||||
* ceiling to collect extra holes before triangulating. See `CeilingCutCapability`.
|
||||
*/
|
||||
ceilingCut?: CeilingCutCapability
|
||||
paint?: PaintCapability
|
||||
/**
|
||||
* Kind is placed by clicking on a wall (door, window). When set, the
|
||||
@@ -1179,6 +1185,22 @@ export type RoofAccessoryConfig = {
|
||||
buildCut?: (node: AnyNode, hostSegment: AnyNode) => BufferGeometry | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability for kinds that cut a hole in their host ceiling when the node is
|
||||
* attached to a ceiling surface (e.g. recessed downlights). The viewer's
|
||||
* `CeilingSystem` queries children of a ceiling for this capability and merges
|
||||
* the returned polygons as extra holes before triangulating, keeping the viewer
|
||||
* free of per-kind branching.
|
||||
*
|
||||
* Returns a rotated-rectangle footprint in ceiling-local [x, z] plan space —
|
||||
* the same coordinate space as `CeilingNode.polygon` and `.holes`. Return
|
||||
* `null` when this particular instance should not cut a hole (e.g. a
|
||||
* non-recessed variant of the same kind).
|
||||
*/
|
||||
export type CeilingCutCapability = {
|
||||
buildCeilingHole: (node: AnyNode) => Array<[number, number]> | null
|
||||
}
|
||||
|
||||
export type CapabilityCtx = { node: AnyNode }
|
||||
|
||||
export type MovableConfig = {
|
||||
|
||||
@@ -98,6 +98,11 @@ const assetSchema = z.object({
|
||||
src: AssetUrl,
|
||||
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
|
||||
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
|
||||
// Ceiling fixtures (e.g. recessed downlights) that embed *into* the ceiling
|
||||
// rather than hang below it: the item seats flush with the ceiling plane
|
||||
// (its body rising into the void above) and the ceiling is cut out around
|
||||
// the item's footprint. Ignored unless `attachTo === 'ceiling'`.
|
||||
recessed: z.boolean().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
// Function-axis tag slugs from the taxonomy. Drives the hierarchical
|
||||
// Items-tab browse: a tree node matches when any of its descendant slugs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useAlignmentGuides } from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { memo } from 'react'
|
||||
import { formatMeasurement } from '../editor/measurement-pill'
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
pauseSceneHistory,
|
||||
resolveAlignment,
|
||||
resumeSceneHistory,
|
||||
useAlignmentGuides,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
|
||||
import { memo } from 'react'
|
||||
import { useFloorplanRender } from './floorplan-render-context'
|
||||
|
||||
/**
|
||||
* "Magnetic" wall-snap beacon for the 2D floor plan — the top-down twin of the
|
||||
* 3D `WallSnapBeaconLayer`. Subscribes to the shared `useWallSnapIndicator`
|
||||
* store (published by the floor-plan wall draft + endpoint-move handlers) and
|
||||
* draws a marker at the snap point whose shape tells you *what* it caught,
|
||||
* matching the 3D glyphs:
|
||||
*
|
||||
* endpoint (corner) → square midpoint → triangle
|
||||
* intersection → ✕ cross wall body (edge) → circle
|
||||
*
|
||||
* Indigo to match the 3D beacon and stay distinct from the red Figma alignment
|
||||
* guides. Sizes are pixel-budgeted via `unitsPerPixel` so the marker stays a
|
||||
* constant size on screen at any zoom. Mounted inside the `data-floorplan-scene`
|
||||
* group so coordinates are world meters (XZ) 1:1, like the alignment guides.
|
||||
*/
|
||||
const COLOR = '#6366f1' // indigo-500 — matches the 3D beacon
|
||||
|
||||
export const FloorplanSnapBeaconLayer = memo(function FloorplanSnapBeaconLayer() {
|
||||
const point = useWallSnapIndicator((s) => s.point)
|
||||
const ctx = useFloorplanRender()
|
||||
|
||||
if (!point) return null
|
||||
|
||||
const upp = ctx?.unitsPerPixel ?? 0.01
|
||||
const m = 6 * upp // base half-size of the glyph in world meters
|
||||
const stroke = 1.5 * upp
|
||||
|
||||
return (
|
||||
<g pointerEvents="none">
|
||||
<SnapMarker color={COLOR} kind={point.kind} m={m} stroke={stroke} x={point.x} z={point.z} />
|
||||
</g>
|
||||
)
|
||||
})
|
||||
|
||||
function SnapMarker({
|
||||
color,
|
||||
kind,
|
||||
m,
|
||||
stroke,
|
||||
x,
|
||||
z,
|
||||
}: {
|
||||
color: string
|
||||
kind: WallSnapKind
|
||||
m: number
|
||||
stroke: number
|
||||
x: number
|
||||
z: number
|
||||
}) {
|
||||
if (kind === 'endpoint') {
|
||||
return <rect fill={color} height={m * 2} width={m * 2} x={x - m} y={z - m} />
|
||||
}
|
||||
if (kind === 'midpoint') {
|
||||
const t = m * 1.3
|
||||
const points = `${x},${z - t} ${x - t},${z + t} ${x + t},${z + t}`
|
||||
return <polygon fill={color} points={points} />
|
||||
}
|
||||
if (kind === 'intersection') {
|
||||
const c = m * 1.4
|
||||
return (
|
||||
<g stroke={color} strokeLinecap="round" strokeWidth={stroke * 2}>
|
||||
<line x1={x - c} x2={x + c} y1={z - c} y2={z + c} />
|
||||
<line x1={x - c} x2={x + c} y1={z + c} y2={z - c} />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
// 'wall' (edge / along-wall) → circle
|
||||
return <circle cx={x} cy={z} fill={color} r={m} />
|
||||
}
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
pauseSceneHistory,
|
||||
resolveBuildingForLevel,
|
||||
resumeSceneHistory,
|
||||
useAlignmentGuides,
|
||||
useInteractive,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
memo,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { type AlignmentGuide, sceneRegistry, useAlignmentGuides } from '@pascal-app/core'
|
||||
import { type AlignmentGuide, sceneRegistry } from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
sampleWallCenterline,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useInteractive,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
ZoneNode as ZoneNodeSchema,
|
||||
type ZoneNode as ZoneNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides, useWallSnapIndicator } from '@pascal-app/editor'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Command, Ruler } from 'lucide-react'
|
||||
import {
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
type FloorplanRenderContextValue,
|
||||
FloorplanRenderProvider,
|
||||
} from '../editor-2d/floorplan-render-context'
|
||||
import { FloorplanSnapBeaconLayer } from '../editor-2d/floorplan-snap-beacon-layer'
|
||||
import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer'
|
||||
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
|
||||
import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
|
||||
@@ -132,6 +133,7 @@ import {
|
||||
createWallOnCurrentLevel,
|
||||
isSegmentLongEnough,
|
||||
snapWallDraftPoint,
|
||||
snapWallDraftPointDetailed,
|
||||
snapPointToGrid as snapWallPointToGrid,
|
||||
WALL_FINE_GRID_STEP,
|
||||
WALL_GRID_STEP,
|
||||
@@ -6843,6 +6845,7 @@ export function FloorplanPanel() {
|
||||
wallEndpointDragRef.current = null
|
||||
setWallEndpointDraft(null)
|
||||
setHoveredEndpointId(null)
|
||||
useWallSnapIndicator.getState().clear()
|
||||
}, [])
|
||||
const clearWallCurveDrag = useCallback(() => {
|
||||
wallCurveDragRef.current = null
|
||||
@@ -6869,6 +6872,7 @@ export function FloorplanPanel() {
|
||||
// Drop any Figma-style alignment guide a draft branch left behind so it
|
||||
// doesn't linger after the tool deactivates / Esc / draft reset.
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
}, [
|
||||
clearFencePlacementDraft,
|
||||
clearCeilingPlacementDraft,
|
||||
@@ -7265,12 +7269,22 @@ export function FloorplanPanel() {
|
||||
// Wall endpoint move: grid snap only (no 45° angle snap from the
|
||||
// fixed corner — that's draft-only behaviour). Shift switches
|
||||
// to the fine grid step for precision.
|
||||
const snappedPoint = snapWallDraftPoint({
|
||||
const snapResult = snapWallDraftPointDetailed({
|
||||
point: planPoint,
|
||||
walls,
|
||||
ignoreWallIds: [dragState.wallId],
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
})
|
||||
const snappedPoint = snapResult.point
|
||||
// Magnetic beacon at the endpoint when it locked onto existing geometry.
|
||||
useWallSnapIndicator
|
||||
.getState()
|
||||
.set(
|
||||
snapResult.snap
|
||||
? { x: snappedPoint[0], z: snappedPoint[1], kind: snapResult.snap }
|
||||
: null,
|
||||
)
|
||||
|
||||
if (pointsEqual(dragState.currentPoint, snappedPoint)) {
|
||||
return
|
||||
@@ -8238,20 +8252,25 @@ export function FloorplanPanel() {
|
||||
// wins outright — never pull the cursor off a corner the user is
|
||||
// closing onto — so alignment runs ONLY when the wall snap left the
|
||||
// point on the plain grid. Alt bypasses alignment.
|
||||
const gridStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
|
||||
const wallSnapped = snapWallDraftPoint({
|
||||
const wallSnap = snapWallDraftPointDetailed({
|
||||
point: planPoint,
|
||||
walls,
|
||||
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
})
|
||||
const gridBase = snapWallPointToGrid(planPoint, gridStep)
|
||||
const lockedToWall = wallSnapped[0] !== gridBase[0] || wallSnapped[1] !== gridBase[1]
|
||||
const wallSnapped = wallSnap.point
|
||||
// Locked onto existing geometry (corner / midpoint / crossing / edge) →
|
||||
// that snap wins, so skip Figma alignment and stand the beacon there.
|
||||
const lockedToWall = wallSnap.snap !== null
|
||||
let snappedPoint = wallSnapped
|
||||
if (lockedToWall) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
snappedPoint = alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey })
|
||||
}
|
||||
useWallSnapIndicator
|
||||
.getState()
|
||||
.set(wallSnap.snap ? { x: snappedPoint[0], z: snappedPoint[1], kind: wallSnap.snap } : null)
|
||||
|
||||
// Emit `grid:move` so the registry-driven wall tool's 3D preview
|
||||
// tracks the cursor. The local draftEnd update below is what
|
||||
@@ -8506,6 +8525,19 @@ export function FloorplanPanel() {
|
||||
phase,
|
||||
toPoint2D,
|
||||
})
|
||||
// Wall-commit snap for the placement hook. Mirrors the move-preview branch:
|
||||
// it honours the Magnetic snap toggle so a click never snaps to geometry the
|
||||
// preview didn't (keeps 2D commit consistent with the preview and with 3D).
|
||||
const snapWallDraftPointMagnetic = useCallback(
|
||||
(args: {
|
||||
point: WallPlanPoint
|
||||
walls: WallNode[]
|
||||
start?: WallPlanPoint
|
||||
angleSnap?: boolean
|
||||
step?: number
|
||||
}) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }),
|
||||
[],
|
||||
)
|
||||
const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({
|
||||
activePolygonDraftPoints,
|
||||
ceilingDraftPoints,
|
||||
@@ -8539,7 +8571,7 @@ export function FloorplanPanel() {
|
||||
setRoofDraftStart,
|
||||
shiftPressed,
|
||||
snapPolygonDraftPoint,
|
||||
snapWallDraftPoint,
|
||||
snapWallDraftPoint: snapWallDraftPointMagnetic,
|
||||
toPoint2D,
|
||||
walls,
|
||||
})
|
||||
@@ -10047,6 +10079,11 @@ export function FloorplanPanel() {
|
||||
paint on top of node geometry. */}
|
||||
<FloorplanAlignmentGuideLayer />
|
||||
|
||||
{/* "Magnetic" wall-snap beacon — per-kind glyph at the active
|
||||
draft / endpoint-move snap point. Same store + coord space as
|
||||
the alignment guides. */}
|
||||
<FloorplanSnapBeaconLayer />
|
||||
|
||||
<FloorplanMarqueeLayer
|
||||
bounds={visibleSvgMarqueeBounds}
|
||||
cursorColor={palette.cursor}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MathUtils, type Mesh, PlaneGeometry, Vector2, Vector3 } from 'three'
|
||||
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useCeilingEvents } from '../../hooks/use-ceiling-events'
|
||||
import { useGridEvents } from '../../hooks/use-grid-events'
|
||||
|
||||
export const Grid = ({
|
||||
@@ -117,6 +118,10 @@ export const Grid = ({
|
||||
|
||||
// Use custom raycasting for grid events (independent of mesh events)
|
||||
useGridEvents(gridY)
|
||||
// Same technique for ceiling-item placement: a math-plane raycast per ceiling,
|
||||
// so commits don't depend on hitting the thin, single-sided `ceiling-grid`
|
||||
// overlay mesh (which dropped clicks even with the green box showing).
|
||||
useCeilingEvents()
|
||||
|
||||
// Track the last world-space cursor hit. The reveal-fade shader reads
|
||||
// `positionLocal.xy` (vertex position on the un-transformed plane), and
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
collectParticipants,
|
||||
computeGroupBox,
|
||||
expandToComponent,
|
||||
levelFrame,
|
||||
type Vec2,
|
||||
} from './group-transform-shared'
|
||||
import {
|
||||
@@ -118,15 +119,18 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
|
||||
const planeY = rest.baseY
|
||||
|
||||
// Snapshot selected participants + connected wall/fence neighbours.
|
||||
const { starts, links } = collectParticipants(
|
||||
ids,
|
||||
useScene.getState().nodes,
|
||||
useViewer.getState().selection.levelId,
|
||||
)
|
||||
const levelId = useViewer.getState().selection.levelId
|
||||
const { starts, links } = collectParticipants(ids, useScene.getState().nodes, levelId)
|
||||
if (starts.length === 0) return
|
||||
|
||||
// Horizontal drag plane at the group's base; delta measured in world XZ
|
||||
// (= level-local XZ on an axis-aligned level).
|
||||
// Placements are stored in the level frame, so convert each world-space
|
||||
// ground-plane hit into that frame before measuring the delta. Frozen at
|
||||
// drag-start; `frameOrigin` lets us map the local delta back to world for
|
||||
// the gizmo's own travel (it's portalled to the scene root).
|
||||
const { matrix: frame, inverse: frameInv } = levelFrame(levelId)
|
||||
const frameOrigin = new Vector3().applyMatrix4(frame)
|
||||
|
||||
// Horizontal drag plane at the group's base.
|
||||
const plane = new Plane(new Vector3(0, 1, 0), -planeY)
|
||||
const ndc = new Vector2()
|
||||
const setNDC = (clientX: number, clientY: number) => {
|
||||
@@ -141,7 +145,7 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
const hit = new Vector3()
|
||||
if (!raycaster.ray.intersectPlane(plane, hit)) return
|
||||
const startHit = hit.clone()
|
||||
const startLocal = hit.clone().applyMatrix4(frameInv)
|
||||
|
||||
document.body.style.cursor = 'grabbing'
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
@@ -160,9 +164,14 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
const moveHit = new Vector3()
|
||||
if (!raycaster.ray.intersectPlane(plane, moveHit)) return
|
||||
const moveLocal = moveHit.applyMatrix4(frameInv)
|
||||
const snap = !e.shiftKey && step > 0
|
||||
const dx = snap ? Math.round((moveHit.x - startHit.x) / step) * step : moveHit.x - startHit.x
|
||||
const dz = snap ? Math.round((moveHit.z - startHit.z) / step) * step : moveHit.z - startHit.z
|
||||
const dx = snap
|
||||
? Math.round((moveLocal.x - startLocal.x) / step) * step
|
||||
: moveLocal.x - startLocal.x
|
||||
const dz = snap
|
||||
? Math.round((moveLocal.z - startLocal.z) / step) * step
|
||||
: moveLocal.z - startLocal.z
|
||||
|
||||
// Ticker on each grid-cell crossing, like single-item placement.
|
||||
if (snap && (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz)) {
|
||||
@@ -210,7 +219,9 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
|
||||
}
|
||||
useLiveNodeOverrides.getState().setMany(overrideEntries)
|
||||
|
||||
setLiveDelta([dx, dz])
|
||||
// Gizmo rides the group in world space; map the level-frame delta back out.
|
||||
const worldDelta = new Vector3(dx, 0, dz).applyMatrix4(frame).sub(frameOrigin)
|
||||
setLiveDelta([worldDelta.x, worldDelta.z])
|
||||
}
|
||||
|
||||
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
collectParticipants,
|
||||
computeGroupBox,
|
||||
expandToComponent,
|
||||
levelFrame,
|
||||
type Vec2,
|
||||
type Vec3,
|
||||
} from './group-transform-shared'
|
||||
@@ -140,13 +141,17 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
||||
|
||||
// Snapshot the selected participants + connected wall/fence neighbours whose
|
||||
// shared endpoints must follow the rotation (so junctions stay welded).
|
||||
const { starts, links } = collectParticipants(
|
||||
ids,
|
||||
useScene.getState().nodes,
|
||||
useViewer.getState().selection.levelId,
|
||||
)
|
||||
const levelId = useViewer.getState().selection.levelId
|
||||
const { starts, links } = collectParticipants(ids, useScene.getState().nodes, levelId)
|
||||
if (starts.length === 0) return
|
||||
|
||||
// Placements live in the level frame; the world pivot must be converted into
|
||||
// it before orbiting positions, or a rotated building displaces the centre.
|
||||
// The swept angle is frame-invariant (both frames differ by a constant yaw,
|
||||
// which cancels in `angleOf(move) - angleOf(start)`), so it's still measured
|
||||
// in world against `center` — keeping the world-space guide overlay correct.
|
||||
const localCenter = center.clone().applyMatrix4(levelFrame(levelId).inverse)
|
||||
|
||||
// Horizontal drag plane at the pivot; bearing measured around the pivot.
|
||||
const plane = new Plane(new Vector3(0, 1, 0), -center.y)
|
||||
const angleOf = (p: Vector3) => Math.atan2(p.z - center.z, p.x - center.x)
|
||||
@@ -155,7 +160,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
||||
// participant's anchor point(s).
|
||||
let spread = 0
|
||||
const reach = (x: number, z: number) => {
|
||||
spread = Math.max(spread, Math.hypot(x - center.x, z - center.z))
|
||||
spread = Math.max(spread, Math.hypot(x - localCenter.x, z - localCenter.z))
|
||||
}
|
||||
for (const s of starts) {
|
||||
if (s.kind === 'endpoint') {
|
||||
@@ -207,9 +212,9 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
||||
const cos = Math.cos(delta)
|
||||
const sin = Math.sin(delta)
|
||||
const rot = (x: number, z: number): Vec2 => {
|
||||
const dx = x - center.x
|
||||
const dz = z - center.z
|
||||
return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
|
||||
const dx = x - localCenter.x
|
||||
const dz = z - localCenter.z
|
||||
return [localCenter.x + dx * cos - dz * sin, localCenter.z + dx * sin + dz * cos]
|
||||
}
|
||||
const overrideEntries: Array<readonly [string, Record<string, unknown>]> = []
|
||||
const liveTransforms = useLiveTransforms.getState()
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveBuildingForLevel,
|
||||
sceneRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { Box3 } from 'three'
|
||||
import { Box3, Matrix4 } from 'three'
|
||||
|
||||
// Shared plumbing for the group transform gizmos (rotate + move). Both operate
|
||||
// on the same multi-selection: classify each participant by how its placement
|
||||
@@ -219,10 +219,26 @@ export function expandToComponent(
|
||||
return Array.from(included)
|
||||
}
|
||||
|
||||
// Frozen world matrix of the level group + its inverse. A node's placement
|
||||
// (`position` / `start` / `end`) is stored in its parent level's frame, but the
|
||||
// gizmos raycast the ground plane in WORLD space. When the building is rotated
|
||||
// those frames diverge, so a world-space drag delta / rotation pivot must be
|
||||
// converted into the level frame before it's written back to placements —
|
||||
// otherwise the move drifts off-axis from the cursor and the rotation orbits a
|
||||
// displaced centre. Returns identity matrices when the level isn't mounted, which
|
||||
// collapses to the old behaviour (world == local) for an unrotated building.
|
||||
export function levelFrame(levelId: string | null): { matrix: Matrix4; inverse: Matrix4 } {
|
||||
const obj = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : null
|
||||
if (!obj) return { matrix: new Matrix4(), inverse: new Matrix4() }
|
||||
obj.updateWorldMatrix(true, false)
|
||||
const matrix = obj.matrixWorld.clone()
|
||||
return { matrix, inverse: matrix.clone().invert() }
|
||||
}
|
||||
|
||||
// World-space union bounding box of the selected meshes, or null if none are
|
||||
// mounted yet. Levels are axis-aligned in XZ, so world XZ coincides with each
|
||||
// node's level-local placement — letting callers transform placement directly
|
||||
// against box-derived points without per-node frame conversion.
|
||||
// mounted yet. Used to place the gizmos (which are portalled to the scene root,
|
||||
// so they live in world space); placement writes convert back to the level frame
|
||||
// via `levelFrame`.
|
||||
export function computeGroupBox(ids: string[]): Box3 | null {
|
||||
const box = new Box3()
|
||||
const tmp = new Box3()
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { sceneRegistry } from '@pascal-app/core'
|
||||
import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { memo, useRef } from 'react'
|
||||
import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
|
||||
/**
|
||||
* "Magnetic" wall-snap beacon for the 3D editor — a vertical marker that
|
||||
* stands at the draft / move endpoint while it's locked onto existing wall
|
||||
* geometry. It's the spatial cue from the design reference: a standing pillar
|
||||
* so you can see *where* the snap caught even at an angle, plus a floor marker
|
||||
* whose shape tells you *what* it caught (CAD-style osnap glyphs):
|
||||
*
|
||||
* endpoint (corner) → square midpoint → triangle
|
||||
* intersection → ✕ cross wall body (edge) → circle
|
||||
*
|
||||
* Subscribes to the shared `useWallSnapIndicator` store (published by the wall
|
||||
* draft + endpoint-move tools). Shares the indigo accent of
|
||||
* `Alignment3DGuideLayer` for visual consistency.
|
||||
*
|
||||
* The point carries only XZ (building-local plan coords); like the alignment
|
||||
* guides it's lifted to the active level's building-local Y each frame so it
|
||||
* stands on the floor being edited when floors are stacked. Mounted inside
|
||||
* ToolManager's building-local group.
|
||||
*/
|
||||
|
||||
const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent
|
||||
const BEACON_HEIGHT = 2.5 // world-meter height of the pillar
|
||||
const BEACON_RADIUS = 0.018 // world-meter radius of the pillar
|
||||
const MARKER = 0.13 // world-meter base size of the floor glyph
|
||||
const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid
|
||||
|
||||
// Shared resources — one material + unit geometries, so snap churn during a
|
||||
// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer).
|
||||
const beaconMaterial = new MeshBasicNodeMaterial({
|
||||
color: BEACON_COLOR,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
})
|
||||
const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8)
|
||||
// Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as
|
||||
// a flat plate; circles/triangles lie flat via an X rotation at the mesh.
|
||||
const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1)
|
||||
const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3)
|
||||
const CIRCLE_GEOMETRY = new CircleGeometry(1, 28)
|
||||
|
||||
export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
|
||||
const point = useWallSnapIndicator((s) => s.point)
|
||||
const levelId = useViewer((s) => s.selection.levelId)
|
||||
const groupRef = useRef<Group>(null)
|
||||
|
||||
// Track the active level's building-local Y each frame so the beacon stands
|
||||
// on the floor being edited, not the building base — same source the
|
||||
// alignment guide layer and `grid.tsx` read.
|
||||
useFrame(() => {
|
||||
const group = groupRef.current
|
||||
if (!group) return
|
||||
const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
|
||||
group.position.y = levelMesh ? levelMesh.position.y : 0
|
||||
})
|
||||
|
||||
if (!point) return null
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<mesh
|
||||
geometry={PILLAR_GEOMETRY}
|
||||
layers={EDITOR_LAYER}
|
||||
material={beaconMaterial}
|
||||
position={[point.x, BEACON_HEIGHT / 2, point.z]}
|
||||
renderOrder={1002}
|
||||
/>
|
||||
<SnapMarker kind={point.kind} x={point.x} z={point.z} />
|
||||
</group>
|
||||
)
|
||||
})
|
||||
|
||||
/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */
|
||||
function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) {
|
||||
const y = FLOOR_LIFT
|
||||
if (kind === 'endpoint') {
|
||||
return (
|
||||
<mesh
|
||||
geometry={FLAT_BOX_GEOMETRY}
|
||||
layers={EDITOR_LAYER}
|
||||
material={beaconMaterial}
|
||||
position={[x, y, z]}
|
||||
renderOrder={1001}
|
||||
scale={[MARKER * 2, 1, MARKER * 2]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (kind === 'midpoint') {
|
||||
return (
|
||||
<mesh
|
||||
geometry={TRIANGLE_GEOMETRY}
|
||||
layers={EDITOR_LAYER}
|
||||
material={beaconMaterial}
|
||||
position={[x, y, z]}
|
||||
renderOrder={1001}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
scale={[MARKER * 1.4, MARKER * 1.4, MARKER * 1.4]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (kind === 'intersection') {
|
||||
// Two crossed bars → an ✕, the universal "crossing" glyph.
|
||||
return (
|
||||
<>
|
||||
<mesh
|
||||
geometry={FLAT_BOX_GEOMETRY}
|
||||
layers={EDITOR_LAYER}
|
||||
material={beaconMaterial}
|
||||
position={[x, y, z]}
|
||||
renderOrder={1001}
|
||||
rotation={[0, Math.PI / 4, 0]}
|
||||
scale={[MARKER * 2.6, 1, MARKER * 0.5]}
|
||||
/>
|
||||
<mesh
|
||||
geometry={FLAT_BOX_GEOMETRY}
|
||||
layers={EDITOR_LAYER}
|
||||
material={beaconMaterial}
|
||||
position={[x, y, z]}
|
||||
renderOrder={1001}
|
||||
rotation={[0, -Math.PI / 4, 0]}
|
||||
scale={[MARKER * 2.6, 1, MARKER * 0.5]}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
// 'wall' (edge / along-wall) → circle
|
||||
return (
|
||||
<mesh
|
||||
geometry={CIRCLE_GEOMETRY}
|
||||
layers={EDITOR_LAYER}
|
||||
material={beaconMaterial}
|
||||
position={[x, y, z]}
|
||||
renderOrder={1001}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
scale={[MARKER, MARKER, MARKER]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,11 @@ import * as THREE from 'three'
|
||||
// recomputation per segment when the user actually wants it back.
|
||||
function makeEmptySegmentGeometry(): THREE.BufferGeometry {
|
||||
const g = new THREE.BufferGeometry()
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
// Three zero-vertices (one degenerate, invisible triangle), not an empty
|
||||
// attribute: in accessory-reveal mode the segments-wrapper is shown, so these
|
||||
// meshes are drawn. An empty position (count 0) leaves WebGPU vertex buffer
|
||||
// slot 0 unbound and the draw is rejected, poisoning the command encoder.
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
// Match the four material slots the roof-segment renderer's material
|
||||
// array expects (0=top, 1=side, 2=interior, 3=shingle). Without these
|
||||
// groups, mesh.material is a single-material lookup that mismatches
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
|
||||
|
||||
@@ -370,16 +370,19 @@ export const ceilingStrategy = {
|
||||
// use the ceiling hit's local position rather than world position.
|
||||
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||||
const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
|
||||
// Recessed fixtures seat flush with the ceiling plane (body rising into the
|
||||
// void above); everything else hangs its full height below the ceiling.
|
||||
const seatY = ctx.asset.recessed ? 0 : -itemHeight
|
||||
const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
|
||||
|
||||
return {
|
||||
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
|
||||
nodeUpdate: {
|
||||
position: [x, -itemHeight, z],
|
||||
position: [x, seatY, z],
|
||||
parentId: event.node.id,
|
||||
},
|
||||
cursorRotationY: 0,
|
||||
gridPosition: [x, -itemHeight, z],
|
||||
gridPosition: [x, seatY, z],
|
||||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||
stopPropagation: true,
|
||||
}
|
||||
@@ -401,10 +404,13 @@ export const ceilingStrategy = {
|
||||
|
||||
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
||||
const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
|
||||
// Recessed fixtures seat flush with the ceiling plane (body rising into the
|
||||
// void above); everything else hangs its full height below the ceiling.
|
||||
const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
|
||||
const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
|
||||
|
||||
return {
|
||||
gridPosition: [x, -itemHeight, z],
|
||||
gridPosition: [x, seatY, z],
|
||||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||
cursorRotationY: 0,
|
||||
nodeUpdate: null,
|
||||
|
||||
@@ -14,13 +14,13 @@ import {
|
||||
resolveLevelId,
|
||||
type ShelfEvent,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
useSpatialQuery,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
resolveAlignment,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
resolveAlignment,
|
||||
sceneRegistry,
|
||||
snapScalar,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
syncAutoStairOpenings,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { type ComponentType, lazy, Suspense } from 'react'
|
||||
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
||||
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
|
||||
import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
|
||||
import { ElevatorTool } from './elevator/elevator-tool'
|
||||
import { MoveTool } from './item/move-tool'
|
||||
import { RoofTool } from './roof/roof-tool'
|
||||
@@ -281,6 +282,8 @@ export const ToolManager: React.FC = () => {
|
||||
tools above. Lives inside the building-local group so the
|
||||
building-local guide coords render at the right world position. */}
|
||||
<Alignment3DGuideLayer />
|
||||
{/* "Magnetic" beacon at the active wall-draft snap point. */}
|
||||
<WallSnapBeaconLayer />
|
||||
</group>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -3,10 +3,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
getScaledDimensions,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
type ItemNode,
|
||||
isCurvedWall,
|
||||
useScene,
|
||||
type WallNode,
|
||||
WallNode as WallSchema,
|
||||
@@ -15,19 +12,30 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import {
|
||||
distanceSquared,
|
||||
findWallSnapTarget,
|
||||
findWallSpecialPointSnap,
|
||||
projectPointOntoWall,
|
||||
WALL_JOIN_SNAP_RADIUS,
|
||||
type WallDraftSnapResult,
|
||||
type WallPlanPoint,
|
||||
} from './wall-snap-geometry'
|
||||
|
||||
export type WallPlanPoint = [number, number]
|
||||
// The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
|
||||
// existing importers (fence drafting, the editor barrel) keep their paths.
|
||||
export {
|
||||
findWallSnapTarget,
|
||||
WALL_JOIN_SNAP_RADIUS,
|
||||
type WallDraftSnapKind,
|
||||
type WallDraftSnapResult,
|
||||
type WallPlanPoint,
|
||||
} from './wall-snap-geometry'
|
||||
|
||||
export const WALL_GRID_STEP = 0.5
|
||||
// Smallest available grid snap. Used as a precision-mode step (Shift +
|
||||
// drag) so a drag can land on values the regular grid skips.
|
||||
export const WALL_FINE_GRID_STEP = 0.05
|
||||
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
||||
// Generous radius for snapping to an *existing* wall's endpoint while
|
||||
// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
|
||||
// is the strongest user intent (closing a polygon, attaching to a
|
||||
// corner) and the cursor never lands pixel-perfect on a corner.
|
||||
export const WALL_ENDPOINT_SNAP_RADIUS = 0.7
|
||||
export const WALL_MIN_LENGTH = 0.01
|
||||
const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
|
||||
|
||||
@@ -43,12 +51,6 @@ type WallSplitIntersection = {
|
||||
point: WallPlanPoint
|
||||
}
|
||||
|
||||
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
||||
const dx = a[0] - b[0]
|
||||
const dz = a[1] - b[1]
|
||||
return dx * dx + dz * dz
|
||||
}
|
||||
|
||||
export function getSegmentGridStep(): number {
|
||||
return useEditor.getState().gridSnapStep
|
||||
}
|
||||
@@ -83,24 +85,6 @@ export function getWallAngleSnapStep(step = getSegmentGridStep()): number {
|
||||
return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
|
||||
}
|
||||
|
||||
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
||||
const [x1, z1] = wall.start
|
||||
const [x2, z2] = wall.end
|
||||
const dx = x2 - x1
|
||||
const dz = z2 - z1
|
||||
const lengthSquared = dx * dx + dz * dz
|
||||
if (lengthSquared < 1e-9) {
|
||||
return null
|
||||
}
|
||||
|
||||
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
||||
if (t <= 0 || t >= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [x1 + dx * t, z1 + dz * t]
|
||||
}
|
||||
|
||||
function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
|
||||
const { id: _id, parentId: _parentId, children, ...rest } = wall
|
||||
|
||||
@@ -332,84 +316,7 @@ function splitWallIfNeeded(
|
||||
}
|
||||
}
|
||||
|
||||
export function findWallSnapTarget(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
options?: { ignoreWallIds?: string[]; radius?: number },
|
||||
): WallPlanPoint | null {
|
||||
const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])
|
||||
const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
|
||||
let bestTarget: WallPlanPoint | null = null
|
||||
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const wall of walls) {
|
||||
if (ignoreWallIds.has(wall.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
|
||||
|
||||
if (isCurvedWall(wall)) {
|
||||
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
|
||||
for (let index = 0; index <= sampleCount; index += 1) {
|
||||
const frame = getWallCurveFrameAt(wall, index / sampleCount)
|
||||
candidates.push([frame.point.x, frame.point.y])
|
||||
}
|
||||
} else {
|
||||
candidates.push(projectPointOntoWall(point, wall))
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidateDistanceSquared = distanceSquared(point, candidate)
|
||||
if (
|
||||
candidateDistanceSquared > radiusSquared ||
|
||||
candidateDistanceSquared >= bestDistanceSquared
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
bestTarget = candidate
|
||||
bestDistanceSquared = candidateDistanceSquared
|
||||
}
|
||||
}
|
||||
|
||||
return bestTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a
|
||||
* generous radius. Use this before `findWallSnapTarget` so the strong
|
||||
* "attach to an existing wall corner" intent isn't accidentally pushed
|
||||
* out of range by an interim grid snap that moved the cursor away from
|
||||
* the endpoint.
|
||||
*/
|
||||
function findWallEndpointFromRaw(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
ignoreWallIds?: string[],
|
||||
): WallPlanPoint | null {
|
||||
const ignored = new Set(ignoreWallIds ?? [])
|
||||
const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
|
||||
let best: WallPlanPoint | null = null
|
||||
let bestDistSq = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const wall of walls) {
|
||||
if (ignored.has(wall.id)) continue
|
||||
for (const corner of [wall.start, wall.end] as WallPlanPoint[]) {
|
||||
const d = distanceSquared(point, corner)
|
||||
if (d <= radiusSquared && d < bestDistSq) {
|
||||
best = corner
|
||||
bestDistSq = d
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function snapWallDraftPoint(args: {
|
||||
type SnapWallDraftArgs = {
|
||||
point: WallPlanPoint
|
||||
walls: WallNode[]
|
||||
start?: WallPlanPoint
|
||||
@@ -417,16 +324,33 @@ export function snapWallDraftPoint(args: {
|
||||
ignoreWallIds?: string[]
|
||||
/** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
|
||||
step?: number
|
||||
}): WallPlanPoint {
|
||||
const { point, walls, start, angleSnap = false, ignoreWallIds, step: overrideStep } = args
|
||||
/**
|
||||
* Magnetic snapping to existing wall geometry (corners, midpoints,
|
||||
* crossings, wall bodies). When `false`, only grid/angle snap applies and
|
||||
* `snap` is always `null`. Defaults to `true` so callers that don't care
|
||||
* keep the prior behaviour.
|
||||
*/
|
||||
magnetic?: boolean
|
||||
}
|
||||
|
||||
// Endpoint of an existing wall wins outright when the cursor is
|
||||
// anywhere within `WALL_ENDPOINT_SNAP_RADIUS` — closing a polygon or
|
||||
// attaching to a corner should "just work" without needing
|
||||
// pixel-perfect aim. Done from the raw cursor so it isn't masked by
|
||||
// an interim grid snap that nudged us out of range.
|
||||
const endpointSnap = findWallEndpointFromRaw(point, walls, ignoreWallIds)
|
||||
if (endpointSnap) return endpointSnap
|
||||
export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
|
||||
const {
|
||||
point,
|
||||
walls,
|
||||
start,
|
||||
angleSnap = false,
|
||||
ignoreWallIds,
|
||||
step: overrideStep,
|
||||
magnetic = true,
|
||||
} = args
|
||||
|
||||
// Discrete special points (corner / midpoint / crossing) are taken from the
|
||||
// raw cursor so an interim grid snap can't mask them. A corner always wins,
|
||||
// then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
|
||||
if (magnetic) {
|
||||
const special = findWallSpecialPointSnap(point, walls, ignoreWallIds)
|
||||
if (special) return special
|
||||
}
|
||||
|
||||
const step = overrideStep ?? getSegmentGridStep()
|
||||
const angleStep = getWallAngleSnapStep(step)
|
||||
@@ -435,11 +359,16 @@ export function snapWallDraftPoint(args: {
|
||||
? snapPointTo45Degrees(start, point, step, angleStep)
|
||||
: snapPointToGrid(point, step)
|
||||
|
||||
return (
|
||||
findWallSnapTarget(basePoint, walls, {
|
||||
ignoreWallIds,
|
||||
}) ?? basePoint
|
||||
)
|
||||
if (magnetic) {
|
||||
const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds })
|
||||
if (wallSnap) return { point: wallSnap, snap: 'wall' }
|
||||
}
|
||||
|
||||
return { point: basePoint, snap: null }
|
||||
}
|
||||
|
||||
export function snapWallDraftPoint(args: SnapWallDraftArgs): WallPlanPoint {
|
||||
return snapWallDraftPointDetailed(args).point
|
||||
}
|
||||
|
||||
export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { WallNode } from '@pascal-app/core'
|
||||
import {
|
||||
findWallSnapTarget,
|
||||
findWallSpecialPointSnap,
|
||||
type WallPlanPoint,
|
||||
} from './wall-snap-geometry'
|
||||
|
||||
function makeWall(start: WallPlanPoint, end: WallPlanPoint, id?: string): WallNode {
|
||||
return {
|
||||
object: 'node',
|
||||
id: (id ?? `wall_${start.join('_')}_${end.join('_')}`) as WallNode['id'],
|
||||
type: 'wall',
|
||||
name: 'Wall',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
start,
|
||||
end,
|
||||
thickness: 0.1,
|
||||
frontSide: 'unknown',
|
||||
backSide: 'unknown',
|
||||
} as unknown as WallNode
|
||||
}
|
||||
|
||||
describe('findWallSpecialPointSnap', () => {
|
||||
test('snaps to a wall corner (endpoint) when near it', () => {
|
||||
const walls = [makeWall([0, 0], [4, 0])]
|
||||
const result = findWallSpecialPointSnap([0.1, 0.1], walls)
|
||||
expect(result?.snap).toBe('endpoint')
|
||||
expect(result?.point).toEqual([0, 0])
|
||||
})
|
||||
|
||||
test('snaps to a wall midpoint when near it (not a corner)', () => {
|
||||
const walls = [makeWall([0, 0], [4, 0])]
|
||||
const result = findWallSpecialPointSnap([2.1, 0.2], walls)
|
||||
expect(result?.snap).toBe('midpoint')
|
||||
expect(result?.point).toEqual([2, 0])
|
||||
})
|
||||
|
||||
test('snaps to the crossing of two walls (intersection)', () => {
|
||||
// A runs along z=0; B crosses it at x=1. B's own midpoint is [1,1], far
|
||||
// from the crossing, so the snap is the intersection, not a midpoint.
|
||||
const walls = [makeWall([0, 0], [4, 0], 'a'), makeWall([1, -1], [1, 3], 'b')]
|
||||
const result = findWallSpecialPointSnap([1.1, 0.1], walls)
|
||||
expect(result?.snap).toBe('intersection')
|
||||
expect(result?.point[0]).toBeCloseTo(1, 6)
|
||||
expect(result?.point[1]).toBeCloseTo(0, 6)
|
||||
})
|
||||
|
||||
test('corner wins over a midpoint when both are in range', () => {
|
||||
const walls = [makeWall([0, 0], [1, 0])]
|
||||
const result = findWallSpecialPointSnap([0.9, 0.1], walls)
|
||||
expect(result?.snap).toBe('endpoint')
|
||||
expect(result?.point).toEqual([1, 0])
|
||||
})
|
||||
|
||||
test('returns null when no special point is in range', () => {
|
||||
const walls = [makeWall([0, 0], [4, 0])]
|
||||
// Near the wall body but far from corner/midpoint — that's an edge snap,
|
||||
// handled separately by findWallSnapTarget, not a special point.
|
||||
expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findWallSnapTarget (edge / along-wall)', () => {
|
||||
test('projects onto a wall body within range', () => {
|
||||
const walls = [makeWall([0, 0], [4, 0])]
|
||||
const result = findWallSnapTarget([1.2, 0.1], walls)
|
||||
expect(result?.[0]).toBeCloseTo(1.2, 6)
|
||||
expect(result?.[1]).toBeCloseTo(0, 6)
|
||||
})
|
||||
|
||||
test('returns null when too far from any wall', () => {
|
||||
const walls = [makeWall([0, 0], [4, 0])]
|
||||
expect(findWallSnapTarget([1.2, 2], walls)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,270 @@
|
||||
// Pure geometry for "magnetic" wall-draft snapping — no store / viewer / React
|
||||
// deps, so it's unit-testable in isolation. Coordinates are XZ plan points
|
||||
// (building-local meters). `wall-drafting.ts` layers grid/angle snapping and
|
||||
// scene access on top of these primitives.
|
||||
|
||||
import {
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
isCurvedWall,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export type WallPlanPoint = [number, number]
|
||||
|
||||
/** Which kind of existing-geometry snap produced a drafted point. */
|
||||
export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
|
||||
|
||||
export type WallDraftSnapResult = {
|
||||
point: WallPlanPoint
|
||||
/**
|
||||
* Set when `point` locked onto existing wall geometry (a corner, midpoint,
|
||||
* crossing, or wall body) rather than a plain grid/angle position. This is
|
||||
* the "magnetic" snap the beacon visualises; `null` for grid/angle-only.
|
||||
*/
|
||||
snap: WallDraftSnapKind | null
|
||||
}
|
||||
|
||||
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
||||
// Generous radius for snapping to an *existing* wall's endpoint while
|
||||
// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
|
||||
// is the strongest user intent (closing a polygon, attaching to a
|
||||
// corner) and the cursor never lands pixel-perfect on a corner.
|
||||
export const WALL_ENDPOINT_SNAP_RADIUS = 0.7
|
||||
// Discrete "special point" snaps taken from the raw cursor (like the
|
||||
// endpoint snap) but slightly tighter — a corner is the strongest intent,
|
||||
// a midpoint / crossing is the next tier down.
|
||||
export const WALL_MIDPOINT_SNAP_RADIUS = 0.5
|
||||
export const WALL_INTERSECTION_SNAP_RADIUS = 0.5
|
||||
|
||||
export function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
||||
const dx = a[0] - b[0]
|
||||
const dz = a[1] - b[1]
|
||||
return dx * dx + dz * dz
|
||||
}
|
||||
|
||||
export function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
||||
const [x1, z1] = wall.start
|
||||
const [x2, z2] = wall.end
|
||||
const dx = x2 - x1
|
||||
const dz = z2 - z1
|
||||
const lengthSquared = dx * dx + dz * dz
|
||||
if (lengthSquared < 1e-9) {
|
||||
return null
|
||||
}
|
||||
|
||||
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
||||
if (t <= 0 || t >= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [x1 + dx * t, z1 + dz * t]
|
||||
}
|
||||
|
||||
export function findWallSnapTarget(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
options?: { ignoreWallIds?: string[]; radius?: number },
|
||||
): WallPlanPoint | null {
|
||||
const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])
|
||||
const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
|
||||
let bestTarget: WallPlanPoint | null = null
|
||||
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const wall of walls) {
|
||||
if (ignoreWallIds.has(wall.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
|
||||
|
||||
if (isCurvedWall(wall)) {
|
||||
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
|
||||
for (let index = 0; index <= sampleCount; index += 1) {
|
||||
const frame = getWallCurveFrameAt(wall, index / sampleCount)
|
||||
candidates.push([frame.point.x, frame.point.y])
|
||||
}
|
||||
} else {
|
||||
candidates.push(projectPointOntoWall(point, wall))
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidateDistanceSquared = distanceSquared(point, candidate)
|
||||
if (
|
||||
candidateDistanceSquared > radiusSquared ||
|
||||
candidateDistanceSquared >= bestDistanceSquared
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
bestTarget = candidate
|
||||
bestDistanceSquared = candidateDistanceSquared
|
||||
}
|
||||
}
|
||||
|
||||
return bestTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a
|
||||
* generous radius. Use this before `findWallSnapTarget` so the strong
|
||||
* "attach to an existing wall corner" intent isn't accidentally pushed
|
||||
* out of range by an interim grid snap that moved the cursor away from
|
||||
* the endpoint.
|
||||
*/
|
||||
export function findWallEndpointFromRaw(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
ignoreWallIds?: string[],
|
||||
): WallPlanPoint | null {
|
||||
const ignored = new Set(ignoreWallIds ?? [])
|
||||
const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
|
||||
let best: WallPlanPoint | null = null
|
||||
let bestDistSq = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const wall of walls) {
|
||||
if (ignored.has(wall.id)) continue
|
||||
for (const corner of [wall.start, wall.end] as WallPlanPoint[]) {
|
||||
const d = distanceSquared(point, corner)
|
||||
if (d <= radiusSquared && d < bestDistSq) {
|
||||
best = corner
|
||||
bestDistSq = d
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Midpoint of a wall — curve midpoint for curved walls, segment midpoint otherwise. */
|
||||
function wallMidpoint(wall: WallNode): WallPlanPoint {
|
||||
if (isCurvedWall(wall)) {
|
||||
const frame = getWallCurveFrameAt(wall, 0.5)
|
||||
return [frame.point.x, frame.point.y]
|
||||
}
|
||||
return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
|
||||
}
|
||||
|
||||
/** Nearest wall midpoint to the raw cursor, within `WALL_MIDPOINT_SNAP_RADIUS`. */
|
||||
export function findWallMidpointFromRaw(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
ignoreWallIds?: string[],
|
||||
): WallPlanPoint | null {
|
||||
const ignored = new Set(ignoreWallIds ?? [])
|
||||
const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2
|
||||
let best: WallPlanPoint | null = null
|
||||
let bestDistSq = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const wall of walls) {
|
||||
if (ignored.has(wall.id)) continue
|
||||
const mid = wallMidpoint(wall)
|
||||
const d = distanceSquared(point, mid)
|
||||
if (d <= radiusSquared && d < bestDistSq) {
|
||||
best = mid
|
||||
bestDistSq = d
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Crossing point of two straight segments, or null if they don't intersect within both. */
|
||||
function segmentIntersection(
|
||||
a1: WallPlanPoint,
|
||||
a2: WallPlanPoint,
|
||||
b1: WallPlanPoint,
|
||||
b2: WallPlanPoint,
|
||||
): WallPlanPoint | null {
|
||||
const rx = a2[0] - a1[0]
|
||||
const rz = a2[1] - a1[1]
|
||||
const sx = b2[0] - b1[0]
|
||||
const sz = b2[1] - b1[1]
|
||||
const denom = rx * sz - rz * sx
|
||||
if (Math.abs(denom) < 1e-9) return null // parallel / collinear
|
||||
|
||||
const qpx = b1[0] - a1[0]
|
||||
const qpz = b1[1] - a1[1]
|
||||
const t = (qpx * sz - qpz * sx) / denom
|
||||
const u = (qpx * rz - qpz * rx) / denom
|
||||
if (t < 0 || t > 1 || u < 0 || u > 1) return null
|
||||
|
||||
return [a1[0] + t * rx, a1[1] + t * rz]
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest point where two existing straight walls cross, within
|
||||
* `WALL_INTERSECTION_SNAP_RADIUS`. Curved walls are skipped. O(n²) over the
|
||||
* level's walls — fine at editor scale.
|
||||
*/
|
||||
export function findWallIntersectionFromRaw(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
ignoreWallIds?: string[],
|
||||
): WallPlanPoint | null {
|
||||
const ignored = new Set(ignoreWallIds ?? [])
|
||||
const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall))
|
||||
const radiusSquared = WALL_INTERSECTION_SNAP_RADIUS ** 2
|
||||
let best: WallPlanPoint | null = null
|
||||
let bestDistSq = Number.POSITIVE_INFINITY
|
||||
|
||||
for (let i = 0; i < straight.length; i += 1) {
|
||||
for (let j = i + 1; j < straight.length; j += 1) {
|
||||
const crossing = segmentIntersection(
|
||||
straight[i]!.start,
|
||||
straight[i]!.end,
|
||||
straight[j]!.start,
|
||||
straight[j]!.end,
|
||||
)
|
||||
if (!crossing) continue
|
||||
const d = distanceSquared(point, crossing)
|
||||
if (d <= radiusSquared && d < bestDistSq) {
|
||||
best = crossing
|
||||
bestDistSq = d
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Pick the candidate nearest to `point`, ignoring nulls. */
|
||||
function nearestCandidate(
|
||||
point: WallPlanPoint,
|
||||
candidates: Array<WallDraftSnapResult | null | false>,
|
||||
): WallDraftSnapResult | null {
|
||||
let best: WallDraftSnapResult | null = null
|
||||
let bestDistSq = Number.POSITIVE_INFINITY
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
const d = distanceSquared(point, candidate.point)
|
||||
if (d < bestDistSq) {
|
||||
best = candidate
|
||||
bestDistSq = d
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Discrete "special point" snap from the raw cursor, in priority order:
|
||||
* 1. corners (endpoints) — strongest intent, largest radius
|
||||
* 2. midpoints / crossings — next tier; the nearer of the two wins
|
||||
* A corner within range always wins over a midpoint/crossing. Returns null
|
||||
* when no special point is in range (caller falls back to grid/edge snap).
|
||||
*/
|
||||
export function findWallSpecialPointSnap(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
ignoreWallIds?: string[],
|
||||
): WallDraftSnapResult | null {
|
||||
const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds)
|
||||
if (endpoint) return { point: endpoint, snap: 'endpoint' }
|
||||
|
||||
const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds)
|
||||
const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds)
|
||||
return nearestCandidate(point, [
|
||||
midpoint && { point: midpoint, snap: 'midpoint' },
|
||||
intersection && { point: intersection, snap: 'intersection' },
|
||||
])
|
||||
}
|
||||
@@ -628,6 +628,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
attachTo: 'ceiling',
|
||||
recessed: true,
|
||||
interactive: {
|
||||
controls: [
|
||||
{ kind: 'toggle' },
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CeilingEvent,
|
||||
type CeilingNode,
|
||||
emitter,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
||||
import useEditor from '../store/use-editor'
|
||||
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
/** Ray-casting point-in-polygon on ceiling-local [x, z] tuples. */
|
||||
function pointInPolygon(x: number, z: number, polygon: Array<[number, number]>): boolean {
|
||||
let inside = false
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const a = polygon[i]!
|
||||
const b = polygon[j]!
|
||||
const intersects =
|
||||
a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]
|
||||
if (intersects) inside = !inside
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
/**
|
||||
* Reliable pointer events for placing ceiling-attached items.
|
||||
*
|
||||
* Mirrors {@link useGridEvents} (the floor path): rather than relying on the
|
||||
* thin, single-sided `ceiling-grid` overlay mesh to be hit by R3F's raycaster —
|
||||
* which misses from the "wrong" camera side, near polygon edges/holes, or while
|
||||
* the overlay is mid-reveal, dropping the commit click even though the green box
|
||||
* still shows — it intersects a math plane at each ceiling's height (double-sided,
|
||||
* so it hits from above or below) and point-in-polygon tests the hit. Emits
|
||||
* `ceiling:enter/move/leave/click` so both the placement coordinator and the 2D
|
||||
* floor-plan item preview keep working.
|
||||
*
|
||||
* Active only while a ceiling-attached item is being placed or moved. The
|
||||
* `ceiling-grid` mesh no longer emits these events (see `CeilingRenderer`), so
|
||||
* this is the single, reliable source.
|
||||
*/
|
||||
export function useCeilingEvents() {
|
||||
const { camera, gl } = useThree()
|
||||
const raycaster = useRef(new Raycaster())
|
||||
const pointer = useRef(new Vector2())
|
||||
const plane = useRef(new Plane(UP.clone(), 0))
|
||||
const worldHit = useRef(new Vector3())
|
||||
const meshWorld = useRef(new Vector3())
|
||||
const hoveredRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement
|
||||
|
||||
const isActive = (): boolean => {
|
||||
const ed = useEditor.getState()
|
||||
if (ed.selectedItem?.attachTo === 'ceiling') return true
|
||||
const moving = ed.movingNode
|
||||
return moving?.type === 'item' && moving.asset?.attachTo === 'ceiling'
|
||||
}
|
||||
|
||||
type Hit = { node: CeilingNode; mesh: Object3D; world: Vector3; local: Vector3 }
|
||||
|
||||
// The ceiling on the active level whose plane the cursor ray crosses inside
|
||||
// its polygon, nearest the camera.
|
||||
const pick = (nativeEvent: PointerEvent | MouseEvent): Hit | null => {
|
||||
const activeLevelId = useViewer.getState().selection.levelId
|
||||
if (!activeLevelId) return null
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
|
||||
pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1
|
||||
raycaster.current.setFromCamera(pointer.current, camera)
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const ceilingIds = sceneRegistry.byType.ceiling
|
||||
if (!ceilingIds) return null
|
||||
|
||||
let best: Hit | null = null
|
||||
let bestDist = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const id of ceilingIds) {
|
||||
const node = nodes[id as AnyNodeId] as CeilingNode | undefined
|
||||
if (!node || node.type !== 'ceiling' || node.polygon.length < 3) continue
|
||||
if (resolveLevelId(node, nodes) !== activeLevelId) continue
|
||||
const mesh = sceneRegistry.nodes.get(id)
|
||||
if (!mesh) continue
|
||||
|
||||
mesh.getWorldPosition(meshWorld.current)
|
||||
plane.current.set(UP, -meshWorld.current.y)
|
||||
if (!raycaster.current.ray.intersectPlane(plane.current, worldHit.current)) continue
|
||||
|
||||
const local = mesh.worldToLocal(worldHit.current.clone())
|
||||
if (!pointInPolygon(local.x, local.z, node.polygon)) continue
|
||||
|
||||
const dist = raycaster.current.ray.origin.distanceToSquared(worldHit.current)
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist
|
||||
best = { node, mesh, world: worldHit.current.clone(), local }
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
const buildEvent = (hit: Hit, nativeEvent: PointerEvent | MouseEvent): CeilingEvent => ({
|
||||
node: hit.node,
|
||||
position: [hit.world.x, hit.world.y, hit.world.z],
|
||||
localPosition: [hit.local.x, hit.local.y, hit.local.z],
|
||||
normal: [0, 1, 0],
|
||||
object: hit.mesh,
|
||||
stopPropagation: () => {},
|
||||
nativeEvent: nativeEvent as never,
|
||||
})
|
||||
|
||||
const emitLeave = (nativeEvent: PointerEvent | MouseEvent) => {
|
||||
const id = hoveredRef.current
|
||||
if (!id) return
|
||||
hoveredRef.current = null
|
||||
const node = useScene.getState().nodes[id as AnyNodeId] as CeilingNode | undefined
|
||||
const mesh = sceneRegistry.nodes.get(id)
|
||||
if (!node || !mesh) return
|
||||
emitter.emit(
|
||||
'ceiling:leave',
|
||||
buildEvent(
|
||||
{ node, mesh, world: meshWorld.current.clone(), local: new Vector3() },
|
||||
nativeEvent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!isActive()) {
|
||||
emitLeave(e)
|
||||
return
|
||||
}
|
||||
const hit = pick(e)
|
||||
if (!hit) {
|
||||
emitLeave(e)
|
||||
return
|
||||
}
|
||||
const ev = buildEvent(hit, e)
|
||||
if (hoveredRef.current !== hit.node.id) {
|
||||
if (hoveredRef.current) emitLeave(e)
|
||||
hoveredRef.current = hit.node.id
|
||||
emitter.emit('ceiling:enter', ev)
|
||||
}
|
||||
emitter.emit('ceiling:move', ev)
|
||||
}
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
if (e.button !== 0) return
|
||||
if (!isActive()) return
|
||||
const hit = pick(e)
|
||||
if (!hit) return
|
||||
emitter.emit('ceiling:click', buildEvent(hit, e))
|
||||
}
|
||||
|
||||
const onPointerLeave = (e: PointerEvent) => emitLeave(e)
|
||||
|
||||
canvas.addEventListener('pointermove', onMove)
|
||||
canvas.addEventListener('click', onClick)
|
||||
canvas.addEventListener('pointerleave', onPointerLeave)
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('pointermove', onMove)
|
||||
canvas.removeEventListener('click', onClick)
|
||||
canvas.removeEventListener('pointerleave', onPointerLeave)
|
||||
}
|
||||
}, [camera, gl])
|
||||
}
|
||||
@@ -102,7 +102,10 @@ export {
|
||||
snapPointToGrid,
|
||||
snapScalarToGrid,
|
||||
snapWallDraftPoint,
|
||||
snapWallDraftPointDetailed,
|
||||
WALL_FINE_GRID_STEP,
|
||||
type WallDraftSnapKind,
|
||||
type WallDraftSnapResult,
|
||||
type WallPlanPoint,
|
||||
} from './components/tools/wall/wall-drafting'
|
||||
// `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the
|
||||
@@ -234,6 +237,7 @@ export {
|
||||
// nodes` so they don't need their own copy / their own tailwind-merge
|
||||
// dependency.
|
||||
export { cn } from './lib/utils'
|
||||
export { default as useAlignmentGuides } from './store/use-alignment-guides'
|
||||
export { default as useAudio } from './store/use-audio'
|
||||
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
||||
export type {
|
||||
@@ -255,3 +259,8 @@ export {
|
||||
export { default as usePlacementPreview } from './store/use-placement-preview'
|
||||
export { useUploadStore } from './store/use-upload'
|
||||
export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts'
|
||||
export {
|
||||
default as useWallSnapIndicator,
|
||||
type WallSnapKind,
|
||||
type WallSnapPoint,
|
||||
} from './store/use-wall-snap-indicator'
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
type AlignmentGuide,
|
||||
collectAlignmentAnchors,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Fixed Figma-style alignment threshold (meters) for floor-plan placement /
|
||||
|
||||
+1
-1
@@ -3,8 +3,8 @@
|
||||
// guides on pointermove; the renderer (a 2D / 3D guide layer) subscribes
|
||||
// and draws them. Both sides clear on commit, cancel, and unmount.
|
||||
|
||||
import type { AlignmentGuide } from '@pascal-app/core'
|
||||
import { create } from 'zustand'
|
||||
import type { AlignmentGuide } from '../services/alignment'
|
||||
|
||||
type AlignmentGuidesState = {
|
||||
guides: AlignmentGuide[]
|
||||
@@ -344,6 +344,11 @@ type EditorState = {
|
||||
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
|
||||
gridSnapStep: GridSnapStep
|
||||
setGridSnapStep: (step: GridSnapStep) => void
|
||||
// Magnetic snapping while drafting — snaps wall endpoints onto existing
|
||||
// wall corners / wall bodies (the "magnetic" beacon). Independent of grid
|
||||
// snap. On by default; toggled from the Display menu.
|
||||
magneticSnap: boolean
|
||||
setMagneticSnap: (enabled: boolean) => void
|
||||
showReferenceFloor: boolean
|
||||
toggleReferenceFloor: () => void
|
||||
setShowReferenceFloor: (show: boolean) => void
|
||||
@@ -386,6 +391,7 @@ type PersistedEditorLayoutState = Pick<
|
||||
| 'splitOrientation'
|
||||
| 'floorplanSelectionTool'
|
||||
| 'gridSnapStep'
|
||||
| 'magneticSnap'
|
||||
| 'showReferenceFloor'
|
||||
| 'referenceFloorOffset'
|
||||
| 'referenceFloorOpacity'
|
||||
@@ -408,6 +414,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
|
||||
splitOrientation: 'horizontal',
|
||||
floorplanSelectionTool: 'click',
|
||||
gridSnapStep: 0.5,
|
||||
magneticSnap: true,
|
||||
showReferenceFloor: false,
|
||||
referenceFloorOffset: 1,
|
||||
referenceFloorOpacity: 0.35,
|
||||
@@ -520,6 +527,8 @@ function normalizePersistedEditorLayoutState(
|
||||
gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
|
||||
? (state?.gridSnapStep as GridSnapStep)
|
||||
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
||||
// Default on: only an explicit persisted `false` disables it.
|
||||
magneticSnap: state?.magneticSnap !== false,
|
||||
showReferenceFloor: state?.showReferenceFloor === true,
|
||||
referenceFloorOffset:
|
||||
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
|
||||
@@ -876,6 +885,8 @@ const useEditor = create<EditorState>()(
|
||||
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
|
||||
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
||||
setGridSnapStep: (step) => set({ gridSnapStep: step }),
|
||||
magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
|
||||
setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
|
||||
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
|
||||
toggleReferenceFloor: () =>
|
||||
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
|
||||
@@ -965,6 +976,7 @@ const useEditor = create<EditorState>()(
|
||||
splitOrientation: state.splitOrientation,
|
||||
floorplanSelectionTool: state.floorplanSelectionTool,
|
||||
gridSnapStep: state.gridSnapStep,
|
||||
magneticSnap: state.magneticSnap,
|
||||
showReferenceFloor: state.showReferenceFloor,
|
||||
referenceFloorOffset: state.referenceFloorOffset,
|
||||
referenceFloorOpacity: state.referenceFloorOpacity,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Ephemeral store for the "magnetic" wall-snap beacon shown during wall
|
||||
// drafting. The wall tool writes the active snap point on pointermove when
|
||||
// the draft endpoint locks onto existing wall geometry (a corner or a point
|
||||
// along a wall); the 3D beacon layer subscribes and draws a vertical marker
|
||||
// there. Cleared on commit, cancel, and unmount — same lifecycle as
|
||||
// `use-alignment-guides`.
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
/** Which kind of wall geometry the draft point snapped to. */
|
||||
export type WallSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
|
||||
|
||||
export type WallSnapPoint = {
|
||||
/** Building-local plan coordinates (XZ meters). */
|
||||
x: number
|
||||
z: number
|
||||
kind: WallSnapKind
|
||||
}
|
||||
|
||||
type WallSnapIndicatorState = {
|
||||
point: WallSnapPoint | null
|
||||
set(point: WallSnapPoint | null): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
const useWallSnapIndicator = create<WallSnapIndicatorState>((set) => ({
|
||||
point: null,
|
||||
set: (point) => set({ point }),
|
||||
clear: () => set({ point: null }),
|
||||
}))
|
||||
|
||||
export default useWallSnapIndicator
|
||||
@@ -9,11 +9,16 @@ import {
|
||||
polygonAnchors,
|
||||
resolveAlignment,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
CursorSphere,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
@@ -5,23 +5,21 @@ import {
|
||||
getMaterialPresetByRef,
|
||||
resolveMaterial,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
createSurfaceRoleMaterial,
|
||||
NodeRenderer,
|
||||
resolveSurfaceColor,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute } from 'three'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
|
||||
|
||||
function createEmptyGeometry() {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
|
||||
return geometry
|
||||
return createPlaceholderGeometry()
|
||||
}
|
||||
|
||||
const gridScale = 5
|
||||
@@ -69,7 +67,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const gridPlaceholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
|
||||
useRegistry(node.id, 'ceiling', ref)
|
||||
const handlers = useNodeEvents(node, 'ceiling')
|
||||
// Build the real geometry on mount instead of relying on a child item to
|
||||
// mark us dirty (CeilingSystem only rebuilds dirty ceilings). Ceiling-hosted
|
||||
// items are async GLB loads, so without this the ceiling holds its
|
||||
// placeholder geometry until the first child finishes downloading — and a
|
||||
// childless ceiling would never build at all. Mirrors WallRenderer /
|
||||
// RoofRenderer.
|
||||
useLayoutEffect(() => {
|
||||
useScene.getState().markDirty(node.id)
|
||||
}, [node.id])
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
@@ -123,7 +129,6 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
geometry={gridPlaceholderGeometry}
|
||||
material={materials.topMaterial}
|
||||
name="ceiling-grid"
|
||||
{...handlers}
|
||||
scale={0}
|
||||
visible={false}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
EDITOR_LAYER,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
movingFootprintAnchors,
|
||||
resolveAlignment,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
resolvePlanarCursorPosition,
|
||||
stripPlacementMetadataFlags,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useFreshPlacementVisibility,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
getFloorStackPreviewPosition,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
usePlacementPreview,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
@@ -13,12 +13,22 @@ import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dim
|
||||
*
|
||||
* 1. The door footprint rectangle in the wall cutout (themed
|
||||
* accent stroke when selected).
|
||||
* 2. The door swing arc — a quarter-circle from the hinge to the
|
||||
* door's open position, modulated by `swingAngle`, `hingesSide`,
|
||||
* and `swingDirection`. Renders as a wedge of low-opacity fill so
|
||||
* the swept area reads at a glance.
|
||||
* 2. The door swing arc — a fixed quarter-circle from the hinge to
|
||||
* the door's fully-open (90°) position, oriented by `hingesSide`
|
||||
* and `swingDirection`. The angle is intentionally constant so the
|
||||
* plan symbol stays static regardless of the door's live open-close
|
||||
* state. Renders as a wedge of low-opacity fill so the swept area
|
||||
* reads at a glance.
|
||||
* 3. The door leaf — a thick line from the hinge to the open
|
||||
* position, terminating at the arc end.
|
||||
*
|
||||
* Double / french doors render two mirrored half-width leaves hinged at
|
||||
* the opposite outer ends, each with its own dashed arc, meeting
|
||||
* perpendicular at the centre — the standard double-door plan sign.
|
||||
*
|
||||
* Folding / bifold doors render a static zigzag accordion of panels
|
||||
* across the opening (porting the 3D folding geometry's panel layout),
|
||||
* with no swing arc.
|
||||
* 4. Center line through the cutout (matches the legacy's
|
||||
* `getOpeningCenterLine` segment for visual continuity).
|
||||
*
|
||||
@@ -65,7 +75,12 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
? 'outward'
|
||||
: 'inward'
|
||||
: baseSwingDirection
|
||||
const swingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0))
|
||||
// The floor-plan door symbol is the standard architectural sign: the
|
||||
// leaf drawn at a fixed 90° open with its quarter-circle swing arc.
|
||||
// It deliberately ignores `node.swingAngle` / the live open-close
|
||||
// animation — the plan view documents how the door is hung, not how
|
||||
// far it currently happens to be open, so it must stay static.
|
||||
const swingAngle = Math.PI / 2
|
||||
|
||||
// Footprint rectangle in the cutout.
|
||||
const points: readonly FloorplanPoint[] = [
|
||||
@@ -103,53 +118,49 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
},
|
||||
]
|
||||
|
||||
// Swing geometry. The hinge sits at one end of the door along the
|
||||
// wall direction; the strike sits at the opposite end. The leaf
|
||||
// rotates around the hinge by `swingAngle` toward the inward /
|
||||
// outward side of the wall.
|
||||
const hingeTangentSign = hingesSide === 'left' ? 1 : -1
|
||||
// Swing geometry. A leaf is drawn as a wedge fill + dashed swing arc +
|
||||
// solid leaf line. `drawSwingLeaf` emits one leaf given its hinge, the
|
||||
// closed-leaf vector (hinge → strike, whose length is the swing
|
||||
// radius) and a signed swing angle. Single doors draw one leaf; double
|
||||
// / french doors draw two mirrored half-width leaves meeting in the
|
||||
// middle.
|
||||
const swingSign = swingDirection === 'inward' ? 1 : -1
|
||||
const hingeX = cx - dirX * halfWidth * hingeTangentSign
|
||||
const hingeZ = cz - dirZ * halfWidth * hingeTangentSign
|
||||
// Closed leaf vector points from hinge to strike (along the wall).
|
||||
const closedLeafX = dirX * width * hingeTangentSign
|
||||
const closedLeafZ = dirZ * width * hingeTangentSign
|
||||
|
||||
if (swingAngle > 1e-3 && width > 1e-3) {
|
||||
// Rotate the closed leaf vector by `swingAngle * swingSign *
|
||||
// hingeTangentSign` around the hinge to get the open leaf tip.
|
||||
const angle = swingAngle * swingSign * hingeTangentSign
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
const openLeafX = closedLeafX * cos - closedLeafZ * sin
|
||||
const openLeafZ = closedLeafX * sin + closedLeafZ * cos
|
||||
const tipX = hingeX + openLeafX
|
||||
const tipZ = hingeZ + openLeafZ
|
||||
const drawSwingLeaf = (
|
||||
hX: number,
|
||||
hZ: number,
|
||||
closedX: number,
|
||||
closedZ: number,
|
||||
signedAngle: number,
|
||||
) => {
|
||||
const radius = Math.sqrt(closedX * closedX + closedZ * closedZ)
|
||||
if (radius < 1e-3) return
|
||||
|
||||
// Closed leaf tip — where the leaf would land if fully closed.
|
||||
const closedTipX = hingeX + closedLeafX
|
||||
const closedTipZ = hingeZ + closedLeafZ
|
||||
// Rotate the closed leaf vector around the hinge to the open tip.
|
||||
const cos = Math.cos(signedAngle)
|
||||
const sin = Math.sin(signedAngle)
|
||||
const tipX = hX + (closedX * cos - closedZ * sin)
|
||||
const tipZ = hZ + (closedX * sin + closedZ * cos)
|
||||
const closedTipX = hX + closedX
|
||||
const closedTipZ = hZ + closedZ
|
||||
|
||||
// Swing arc — a path from closed tip to open tip via an arc
|
||||
// centered at the hinge. SVG's A command takes rx ry rotation
|
||||
// large-arc-flag sweep-flag x y. Sweep flag flips based on the
|
||||
// signed angle direction.
|
||||
const sweepFlag = angle >= 0 ? 1 : 0
|
||||
const arcPath = `M ${closedTipX} ${closedTipZ} A ${width} ${width} 0 0 ${sweepFlag} ${tipX} ${tipZ}`
|
||||
// Swing arc — from closed tip to open tip via an arc centered at the
|
||||
// hinge. SVG `A`: rx ry rotation large-arc-flag sweep-flag x y. The
|
||||
// sweep flag flips with the signed angle direction.
|
||||
const sweepFlag = signedAngle >= 0 ? 1 : 0
|
||||
const arcPath = `M ${closedTipX} ${closedTipZ} A ${radius} ${radius} 0 0 ${sweepFlag} ${tipX} ${tipZ}`
|
||||
|
||||
// Swept wedge fill (light, low opacity) — gives the door a
|
||||
// visible "this is the open zone" treatment.
|
||||
// Swept wedge fill (light, low opacity) — reads as the open zone.
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d: `M ${hingeX} ${hingeZ} L ${closedTipX} ${closedTipZ} ${arcPath
|
||||
.replace(/^M [^A]+/, '')
|
||||
.trim()} Z`,
|
||||
d: `M ${hX} ${hZ} L ${closedTipX} ${closedTipZ} ${arcPath.replace(/^M [^A]+/, '').trim()} Z`,
|
||||
fill: accentColor,
|
||||
fillOpacity: showSelectedChrome ? 0.08 : 0.05,
|
||||
stroke: 'none',
|
||||
})
|
||||
|
||||
// The arc itself, stroked.
|
||||
// The arc itself, dashed to match the standard architectural plan
|
||||
// symbol, where the door's swing path is drawn as a broken arc.
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d: arcPath,
|
||||
@@ -157,6 +168,9 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.6 : 1.1,
|
||||
strokeOpacity: 0.85,
|
||||
// Dash is in screen pixels because of `non-scaling-stroke` (same
|
||||
// reason strokeWidth is a small pixel value, not metres).
|
||||
strokeDasharray: '5 4',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinecap: 'round',
|
||||
})
|
||||
@@ -164,8 +178,8 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
// The door leaf — line from hinge to the open tip.
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: hingeX,
|
||||
y1: hingeZ,
|
||||
x1: hX,
|
||||
y1: hZ,
|
||||
x2: tipX,
|
||||
y2: tipZ,
|
||||
stroke: accentColor,
|
||||
@@ -175,6 +189,497 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
})
|
||||
}
|
||||
|
||||
const isDoubleLeaf = node.doorType === 'double' || node.doorType === 'french'
|
||||
const isFolding = node.doorType === 'folding'
|
||||
const isSliding = node.doorType === 'sliding'
|
||||
const isPocket = node.doorType === 'pocket'
|
||||
const isBarn = node.doorType === 'barn'
|
||||
const isGarageSectional = node.doorType === 'garage-sectional'
|
||||
const isGarageRollup = node.doorType === 'garage-rollup'
|
||||
const isGarageTiltup = node.doorType === 'garage-tiltup'
|
||||
// Swing doors get the dashed swing arc; garage and any other types
|
||||
// fall through to just the opening footprint (the earlier behaviour).
|
||||
const isSwingDoor = node.doorType === 'hinged' || isDoubleLeaf
|
||||
// A frameless wall opening — drawn as a bare gap, no leaf / arc / panel
|
||||
// (mirrors the 3D system, which renders only the cutout for openings).
|
||||
const isOpening = node.openingKind === 'opening'
|
||||
|
||||
if (isOpening) {
|
||||
// Open doorway — a frameless gap in the wall. No leaf, arc, or panel;
|
||||
// the cleared footprint above is the whole symbol.
|
||||
} else if (isFolding && width > 1e-3) {
|
||||
// Folding / bifold door: a static accordion of panels drawn as a
|
||||
// zigzag that folds toward the hinge side, occupying ~70% of the
|
||||
// opening (a real folding door stacks to one side, so it never
|
||||
// spans the full width). Mirrors the 3D folding geometry's
|
||||
// alternating-fold panels (`panelCount = leafCount === 2 ? 2 : 4`).
|
||||
// The fold span / depth are fixed so the plan symbol stays static
|
||||
// regardless of the live open state.
|
||||
const FOLD_SPAN_RATIO = 0.8
|
||||
const panelCount = node.leafCount === 2 ? 2 : 4
|
||||
const panelLen = (width * FOLD_SPAN_RATIO) / panelCount
|
||||
const peakDepth = panelLen * 0.7
|
||||
// Anchor the accordion's base line on the room-facing wall face (the
|
||||
// edge / corner of the opening box) rather than the wall centreline,
|
||||
// so the panels start from the box corner instead of its middle.
|
||||
const baseOffX = perpX * halfDepth * swingSign
|
||||
const baseOffZ = perpZ * halfDepth * swingSign
|
||||
// Start at the hinge edge and step toward the opposite jamb.
|
||||
const hingeTangentSign = hingesSide === 'left' ? 1 : -1
|
||||
const startX = cx - dirX * halfWidth * hingeTangentSign + baseOffX
|
||||
const startZ = cz - dirZ * halfWidth * hingeTangentSign + baseOffZ
|
||||
const stepX = dirX * panelLen * hingeTangentSign
|
||||
const stepZ = dirZ * panelLen * hingeTangentSign
|
||||
const peakX = perpX * peakDepth * swingSign
|
||||
const peakZ = perpZ * peakDepth * swingSign
|
||||
let d = ''
|
||||
for (let i = 0; i <= panelCount; i++) {
|
||||
const alongX = startX + stepX * i
|
||||
const alongZ = startZ + stepZ * i
|
||||
const isPeak = i % 2 === 1
|
||||
const px = alongX + (isPeak ? peakX : 0)
|
||||
const pz = alongZ + (isPeak ? peakZ : 0)
|
||||
d += `${i === 0 ? 'M' : 'L'} ${px} ${pz} `
|
||||
}
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d: d.trim(),
|
||||
fill: 'none',
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 2.4 : 1.7,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
} else if (isSliding && width > 1e-3) {
|
||||
// Sliding (bypass) door: two panels on parallel tracks that slide
|
||||
// past each other. Each spans a bit over half the opening and they
|
||||
// overlap in the centre, drawn at slightly different depths (one
|
||||
// toward each wall face). A slide arrow shows the direction. Static
|
||||
// regardless of the live open state.
|
||||
const slideSign = node.slideDirection === 'right' ? 1 : -1
|
||||
const panelHalfThick = Math.min(depth * 0.22, 0.03)
|
||||
const panelHalfLen = width * 0.275
|
||||
const panelRect = (centerAlong: number, perpSign: number): [number, number][] => {
|
||||
const rcx = cx + dirX * centerAlong + perpX * panelHalfThick * perpSign
|
||||
const rcz = cz + dirZ * centerAlong + perpZ * panelHalfThick * perpSign
|
||||
const aX = dirX * panelHalfLen
|
||||
const aZ = dirZ * panelHalfLen
|
||||
const tX = perpX * panelHalfThick
|
||||
const tZ = perpZ * panelHalfThick
|
||||
return [
|
||||
[rcx - aX + tX, rcz - aZ + tZ],
|
||||
[rcx + aX + tX, rcz + aZ + tZ],
|
||||
[rcx + aX - tX, rcz + aZ - tZ],
|
||||
[rcx - aX - tX, rcz - aZ - tZ],
|
||||
]
|
||||
}
|
||||
const pushPanel = (points: [number, number][]) =>
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points,
|
||||
fill: fillColor,
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 2 : 1.4,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
// Left panel toward one face, right panel toward the other; they
|
||||
// overlap across the centre.
|
||||
pushPanel(panelRect(-halfWidth + panelHalfLen, 1))
|
||||
pushPanel(panelRect(halfWidth - panelHalfLen, -1))
|
||||
// Slide-direction arrow, centred and pushed clear of the wall face
|
||||
// so it doesn't overlap the wall or panels.
|
||||
const arrowPerp = -(halfDepth + Math.max(panelHalfThick * 4, halfWidth * 0.22))
|
||||
const arX = cx + perpX * arrowPerp
|
||||
const arZ = cz + perpZ * arrowPerp
|
||||
const tipX = arX + dirX * halfWidth * 0.5 * slideSign
|
||||
const tipZ = arZ + dirZ * halfWidth * 0.5 * slideSign
|
||||
const tailX = arX - dirX * halfWidth * 0.5 * slideSign
|
||||
const tailZ = arZ - dirZ * halfWidth * 0.5 * slideSign
|
||||
const headLen = halfWidth * 0.18
|
||||
const headSpread = headLen * 0.6
|
||||
const backX = tipX - dirX * headLen * slideSign
|
||||
const backZ = tipZ - dirZ * headLen * slideSign
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d:
|
||||
`M ${tailX} ${tailZ} L ${tipX} ${tipZ} ` +
|
||||
`M ${backX + perpX * headSpread} ${backZ + perpZ * headSpread} L ${tipX} ${tipZ} ` +
|
||||
`L ${backX - perpX * headSpread} ${backZ - perpZ * headSpread}`,
|
||||
fill: 'none',
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.4 : 1,
|
||||
strokeOpacity: 0.85,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
} else if (isPocket && width > 1e-3) {
|
||||
// Pocket door: the leaf slides into a cavity inside the wall. Shown
|
||||
// open — the leaf is tucked into the wall on the `slideDirection`
|
||||
// side, and the carved pocket slot is sized to match the leaf (no
|
||||
// empty cavity sticking out past it). The opening itself reads as a
|
||||
// clear doorway. Static regardless of the live open state.
|
||||
const slideSign = node.slideDirection === 'right' ? 1 : -1
|
||||
const leafHalfThick = Math.min(depth * 0.2, 0.025)
|
||||
const rectPoints = (
|
||||
centerAlong: number,
|
||||
halfLen: number,
|
||||
halfThick: number,
|
||||
): [number, number][] => {
|
||||
const rcx = cx + dirX * centerAlong
|
||||
const rcz = cz + dirZ * centerAlong
|
||||
const aX = dirX * halfLen
|
||||
const aZ = dirZ * halfLen
|
||||
const tX = perpX * halfThick
|
||||
const tZ = perpZ * halfThick
|
||||
return [
|
||||
[rcx - aX + tX, rcz - aZ + tZ],
|
||||
[rcx + aX + tX, rcz + aZ + tZ],
|
||||
[rcx + aX - tX, rcz + aZ - tZ],
|
||||
[rcx - aX - tX, rcz - aZ - tZ],
|
||||
]
|
||||
}
|
||||
// Show the door ~60% closed: the leaf covers 60% of the opening and
|
||||
// has slid 40% of its width into the pocket. The pocket side of the
|
||||
// wall stays solid (no carve) so the in-wall part of the leaf reads
|
||||
// as an outline over it, matching the standard plan symbol.
|
||||
const CLOSE_FRACTION = 0.6
|
||||
const openOffset = (1 - CLOSE_FRACTION) * width
|
||||
const leafCenter = slideSign * openOffset
|
||||
// The leaf is a thin white rectangle with an outline — white-filled
|
||||
// along its whole length, so the part sliding into the wall carves
|
||||
// its shape out in white rather than showing the solid wall behind.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: rectPoints(leafCenter, halfWidth, leafHalfThick),
|
||||
fill: '#ffffff',
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 2 : 1.4,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
} else if (isBarn && width > 1e-3) {
|
||||
// Barn / surface-sliding door: the leaf rides a surface-mounted
|
||||
// track in front of the wall. Shown as a solid panel parked over the
|
||||
// wall on the slide side, a dashed ghost of its closed position over
|
||||
// the opening, and a slide-direction arrow. `slideDirection` picks
|
||||
// the side. Static regardless of the live open state.
|
||||
const slideSign = node.slideDirection === 'right' ? 1 : -1
|
||||
const panelHalfThick = Math.min(depth * 0.3, 0.04)
|
||||
// Sit the panels clearly in front of the wall face with a gap, so
|
||||
// they read as surface-mounted rather than embedded in the wall.
|
||||
const gap = Math.max(panelHalfThick * 1.6, halfDepth * 0.6)
|
||||
const faceOffset = halfDepth + gap + panelHalfThick
|
||||
const frontRect = (centerAlong: number, halfLen: number): [number, number][] => {
|
||||
const fcx = cx + perpX * faceOffset + dirX * centerAlong
|
||||
const fcz = cz + perpZ * faceOffset + dirZ * centerAlong
|
||||
const aX = dirX * halfLen
|
||||
const aZ = dirZ * halfLen
|
||||
const tX = perpX * panelHalfThick
|
||||
const tZ = perpZ * panelHalfThick
|
||||
return [
|
||||
[fcx - aX + tX, fcz - aZ + tZ],
|
||||
[fcx + aX + tX, fcz + aZ + tZ],
|
||||
[fcx + aX - tX, fcz + aZ - tZ],
|
||||
[fcx - aX - tX, fcz - aZ - tZ],
|
||||
]
|
||||
}
|
||||
// Dashed ghost of the closed position across the opening.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: frontRect(0, halfWidth),
|
||||
fill: 'none',
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.4 : 1,
|
||||
strokeDasharray: '5 4',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
// Solid panel parked over the wall on the slide side.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: frontRect(slideSign * width, halfWidth),
|
||||
fill: accentColor,
|
||||
fillOpacity: showSelectedChrome ? 0.25 : 0.18,
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 2 : 1.4,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
// Slide-direction arrow, pushed out beyond the panels so it clears
|
||||
// them instead of sitting on top.
|
||||
const arrowOffset = faceOffset + panelHalfThick + gap
|
||||
const arX = cx + perpX * arrowOffset
|
||||
const arZ = cz + perpZ * arrowOffset
|
||||
const tipX = arX + dirX * halfWidth * 0.8 * slideSign
|
||||
const tipZ = arZ + dirZ * halfWidth * 0.8 * slideSign
|
||||
const tailX = arX - dirX * halfWidth * 0.8 * slideSign
|
||||
const tailZ = arZ - dirZ * halfWidth * 0.8 * slideSign
|
||||
const headLen = halfWidth * 0.18
|
||||
const headSpread = headLen * 0.6
|
||||
const backX = tipX - dirX * headLen * slideSign
|
||||
const backZ = tipZ - dirZ * headLen * slideSign
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d:
|
||||
`M ${tailX} ${tailZ} L ${tipX} ${tipZ} ` +
|
||||
`M ${backX + perpX * headSpread} ${backZ + perpZ * headSpread} L ${tipX} ${tipZ} ` +
|
||||
`L ${backX - perpX * headSpread} ${backZ - perpZ * headSpread}`,
|
||||
fill: 'none',
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.4 : 1,
|
||||
strokeOpacity: 0.85,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
} else if (isGarageSectional && width > 1e-3) {
|
||||
// Garage sectional door: an overhead door that rolls up on side
|
||||
// tracks. Drawn as the closed leaf across the opening (just inside
|
||||
// the interior face), two tracks running into the garage, and a
|
||||
// dashed ghost of the door parked at the inner end of the tracks.
|
||||
// `swingDirection` picks the interior side. Static.
|
||||
// Mechanism (tracks / coil) sits on the interior side — matching the
|
||||
// 3D garage builders, which place it on the door-local -z side.
|
||||
const interiorSign = -swingSign
|
||||
const panelHalfThick = Math.min(depth * 0.22, 0.03)
|
||||
const trackLen = Math.max(width * 0.55, 0.6)
|
||||
const aX = dirX * halfWidth
|
||||
const aZ = dirZ * halfWidth
|
||||
const tX = perpX * panelHalfThick
|
||||
const tZ = perpZ * panelHalfThick
|
||||
const leafRect = (perpDist: number): [number, number][] => {
|
||||
const rcx = cx + perpX * perpDist
|
||||
const rcz = cz + perpZ * perpDist
|
||||
return [
|
||||
[rcx - aX + tX, rcz - aZ + tZ],
|
||||
[rcx + aX + tX, rcz + aZ + tZ],
|
||||
[rcx + aX - tX, rcz + aZ - tZ],
|
||||
[rcx - aX - tX, rcz - aZ - tZ],
|
||||
]
|
||||
}
|
||||
const faceDist = interiorSign * halfDepth
|
||||
const innerDist = interiorSign * (halfDepth + trackLen)
|
||||
// Two side tracks running into the garage interior.
|
||||
for (const edgeSign of [-1, 1]) {
|
||||
const ex = cx + dirX * halfWidth * edgeSign
|
||||
const ez = cz + dirZ * halfWidth * edgeSign
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: ex + perpX * faceDist,
|
||||
y1: ez + perpZ * faceDist,
|
||||
x2: ex + perpX * innerDist,
|
||||
y2: ez + perpZ * innerDist,
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.4 : 1,
|
||||
strokeOpacity: 0.85,
|
||||
strokeDasharray: '5 4',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinecap: 'round',
|
||||
})
|
||||
}
|
||||
// Dashed ghost of the door parked at the inner end of the tracks.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: leafRect(innerDist - interiorSign * panelHalfThick),
|
||||
fill: 'none',
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.4 : 1,
|
||||
strokeDasharray: '5 4',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
// Closed leaf, just inside the interior wall face.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: leafRect(interiorSign * (halfDepth + panelHalfThick)),
|
||||
fill: fillColor,
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 2 : 1.4,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
} else if (isGarageRollup && width > 1e-3) {
|
||||
// Roll-up garage door: the curtain coils into a barrel just inside
|
||||
// the opening (rather than running back on tracks like a sectional).
|
||||
// Drawn as the closed leaf across the opening, the coil barrel — a
|
||||
// capsule parallel to the wall — and a small coil hint at its centre.
|
||||
// `swingDirection` picks the interior side. Static.
|
||||
// Mechanism (tracks / coil) sits on the interior side — matching the
|
||||
// 3D garage builders, which place it on the door-local -z side.
|
||||
const interiorSign = -swingSign
|
||||
const panelHalfThick = Math.min(depth * 0.22, 0.03)
|
||||
const aX = dirX * halfWidth
|
||||
const aZ = dirZ * halfWidth
|
||||
const tX = perpX * panelHalfThick
|
||||
const tZ = perpZ * panelHalfThick
|
||||
// Closed leaf, just inside the interior wall face.
|
||||
const leafPerp = interiorSign * (halfDepth + panelHalfThick)
|
||||
const lcx = cx + perpX * leafPerp
|
||||
const lcz = cz + perpZ * leafPerp
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: [
|
||||
[lcx - aX + tX, lcz - aZ + tZ],
|
||||
[lcx + aX + tX, lcz + aZ + tZ],
|
||||
[lcx + aX - tX, lcz + aZ - tZ],
|
||||
[lcx - aX - tX, lcz - aZ - tZ],
|
||||
],
|
||||
fill: fillColor,
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 2 : 1.4,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
// Coil barrel — a capsule (stadium) parallel to the wall, just inside
|
||||
// the leaf. Built as a polygon so it's robust to wall orientation.
|
||||
const drumRadius = Math.min(Math.max(width * 0.12, 0.08), halfWidth * 0.5, 0.16)
|
||||
const drumPerp = interiorSign * (halfDepth + 2 * panelHalfThick + drumRadius)
|
||||
const dcx = cx + perpX * drumPerp
|
||||
const dcz = cz + perpZ * drumPerp
|
||||
const capL = Math.max(halfWidth - drumRadius, 0)
|
||||
const SAMPLES = 8
|
||||
const capsule: [number, number][] = []
|
||||
const c2x = dcx + dirX * capL
|
||||
const c2z = dcz + dirZ * capL
|
||||
for (let i = 0; i <= SAMPLES; i++) {
|
||||
const th = Math.PI / 2 - (Math.PI * i) / SAMPLES
|
||||
capsule.push([
|
||||
c2x + drumRadius * (Math.cos(th) * dirX + Math.sin(th) * perpX),
|
||||
c2z + drumRadius * (Math.cos(th) * dirZ + Math.sin(th) * perpZ),
|
||||
])
|
||||
}
|
||||
const c1x = dcx - dirX * capL
|
||||
const c1z = dcz - dirZ * capL
|
||||
for (let i = 0; i <= SAMPLES; i++) {
|
||||
const ph = -Math.PI / 2 - (Math.PI * i) / SAMPLES
|
||||
capsule.push([
|
||||
c1x + drumRadius * (Math.cos(ph) * dirX + Math.sin(ph) * perpX),
|
||||
c1z + drumRadius * (Math.cos(ph) * dirZ + Math.sin(ph) * perpZ),
|
||||
])
|
||||
}
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: capsule,
|
||||
fill: fillColor,
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.6 : 1.1,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
// Coil hint — a small circle at the barrel centre.
|
||||
const innerR = drumRadius * 0.45
|
||||
const coil: [number, number][] = []
|
||||
for (let i = 0; i <= 12; i++) {
|
||||
const a = (Math.PI * 2 * i) / 12
|
||||
coil.push([
|
||||
dcx + innerR * (Math.cos(a) * dirX + Math.sin(a) * perpX),
|
||||
dcz + innerR * (Math.cos(a) * dirZ + Math.sin(a) * perpZ),
|
||||
])
|
||||
}
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: coil,
|
||||
fill: 'none',
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.4 : 1,
|
||||
strokeOpacity: 0.85,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
} else if (isGarageTiltup && width > 1e-3) {
|
||||
// Tilt-up (up-and-over) garage door: one rigid panel that pivots at
|
||||
// the top and swings up to park overhead inside the garage. Drawn as
|
||||
// the closed leaf across the opening, a dashed panel parked into the
|
||||
// interior, and a dashed curved swing path between them.
|
||||
// `swingDirection` is ignored; like the other garage builders the
|
||||
// mechanism is on the door-local -z (interior) side. Static.
|
||||
const interiorSign = -swingSign
|
||||
const panelHalfThick = Math.min(depth * 0.22, 0.03)
|
||||
const projDepth = Math.max(width * 0.5, 0.7)
|
||||
const aX = dirX * halfWidth
|
||||
const aZ = dirZ * halfWidth
|
||||
const tX = perpX * panelHalfThick
|
||||
const tZ = perpZ * panelHalfThick
|
||||
const leafRect = (perpDist: number): [number, number][] => {
|
||||
const rcx = cx + perpX * perpDist
|
||||
const rcz = cz + perpZ * perpDist
|
||||
return [
|
||||
[rcx - aX + tX, rcz - aZ + tZ],
|
||||
[rcx + aX + tX, rcz + aZ + tZ],
|
||||
[rcx + aX - tX, rcz + aZ - tZ],
|
||||
[rcx - aX - tX, rcz - aZ - tZ],
|
||||
]
|
||||
}
|
||||
const closedPerp = interiorSign * (halfDepth + panelHalfThick)
|
||||
const parkedPerp = interiorSign * (halfDepth + projDepth)
|
||||
// Dashed parked panel, projected overhead into the interior.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: leafRect(parkedPerp),
|
||||
fill: 'none',
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.4 : 1,
|
||||
strokeDasharray: '5 4',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
// Dashed curved swing path from the closed leaf to the parked panel.
|
||||
const startX = cx + perpX * closedPerp
|
||||
const startZ = cz + perpZ * closedPerp
|
||||
const endX = cx + perpX * parkedPerp
|
||||
const endZ = cz + perpZ * parkedPerp
|
||||
const midPerp = interiorSign * (halfDepth + projDepth * 0.5)
|
||||
const ctrlX = cx + perpX * midPerp + dirX * projDepth * 0.5
|
||||
const ctrlZ = cz + perpZ * midPerp + dirZ * projDepth * 0.5
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d: `M ${startX} ${startZ} Q ${ctrlX} ${ctrlZ} ${endX} ${endZ}`,
|
||||
fill: 'none',
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.4 : 1,
|
||||
strokeOpacity: 0.85,
|
||||
strokeDasharray: '5 4',
|
||||
strokeLinecap: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
// Closed leaf, just inside the interior wall face.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: leafRect(closedPerp),
|
||||
fill: fillColor,
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 2 : 1.4,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
} else if (isSwingDoor && swingAngle > 1e-3 && width > 1e-3) {
|
||||
if (isDoubleLeaf) {
|
||||
// Two half-width leaves hinged at the opposite outer ends, each
|
||||
// swinging toward the centre. `hingesSide` is irrelevant for a
|
||||
// symmetric double door; only `swingDirection` chooses the side.
|
||||
const halfLeafX = dirX * halfWidth
|
||||
const halfLeafZ = dirZ * halfWidth
|
||||
// Leaf at the start edge: closed leaf points toward the centre.
|
||||
drawSwingLeaf(cx - halfLeafX, cz - halfLeafZ, halfLeafX, halfLeafZ, swingAngle * swingSign)
|
||||
// Leaf at the end edge: closed leaf points the other way, swung
|
||||
// with the opposite sign so both meet perpendicular at the centre.
|
||||
drawSwingLeaf(cx + halfLeafX, cz + halfLeafZ, -halfLeafX, -halfLeafZ, -swingAngle * swingSign)
|
||||
} else {
|
||||
// Single leaf hinged at one end, strike at the opposite end.
|
||||
const hingeTangentSign = hingesSide === 'left' ? 1 : -1
|
||||
drawSwingLeaf(
|
||||
cx - dirX * halfWidth * hingeTangentSign,
|
||||
cz - dirZ * halfWidth * hingeTangentSign,
|
||||
dirX * width * hingeTangentSign,
|
||||
dirZ * width * hingeTangentSign,
|
||||
swingAngle * swingSign * hingeTangentSign,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Move handle — orange dot at the door center. Only visible when
|
||||
// selected. Pointer-down on this triggers `setMovingNode(door)`
|
||||
// → `FloorplanRegistryMoveOverlay` → `def.floorplanMoveTarget`.
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
isValidWallSideFace,
|
||||
stripPlacementMetadataFlags,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
} from '@pascal-app/core'
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type DragAction,
|
||||
type FenceNode,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
type FencePlanPoint,
|
||||
isSegmentLongEnough,
|
||||
snapFenceDraftPoint,
|
||||
useAlignmentGuides,
|
||||
WALL_FINE_GRID_STEP,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
normalizeWallCurveOffset,
|
||||
useAlignmentGuides,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
isSegmentLongEnough,
|
||||
snapFenceDraftPoint,
|
||||
snapScalarToGrid,
|
||||
useAlignmentGuides,
|
||||
WALL_FINE_GRID_STEP,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type FenceNode,
|
||||
getWallCurveLength,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
type FencePlanPoint,
|
||||
@@ -16,6 +10,7 @@ import {
|
||||
MeasurementPill,
|
||||
type MovingFenceEndpoint,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useDragAction,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type LevelNode,
|
||||
type Point2D,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
type SegmentAngleReference,
|
||||
snapFenceDraftPoint,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
WALL_FINE_GRID_STEP,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
getScaledDimensions,
|
||||
type HandleDescriptor,
|
||||
type ItemNode as ItemNodeType,
|
||||
@@ -221,6 +222,39 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
||||
},
|
||||
applies: (node) => !(node as ItemNodeType).asset.attachTo,
|
||||
},
|
||||
// Recessed ceiling fixtures cut a hole in their host ceiling. The viewer's
|
||||
// CeilingSystem queries this capability on each child of a ceiling so it
|
||||
// never needs to branch on `node.type`.
|
||||
ceilingCut: {
|
||||
buildCeilingHole(rawNode: AnyNode): Array<[number, number]> | null {
|
||||
const node = rawNode as ItemNodeType
|
||||
if (!node.asset.recessed || node.asset.attachTo !== 'ceiling') return null
|
||||
|
||||
// Inset slightly so the fixture's trim (widest part, sitting in the
|
||||
// ceiling plane) overlaps the solid ceiling around the opening and hides
|
||||
// the cut edge. Same constant the old ceiling-system used.
|
||||
const INSET = 0.82
|
||||
const [width, , depth] = getScaledDimensions(node)
|
||||
const halfW = (width / 2) * INSET
|
||||
const halfD = (depth / 2) * INSET
|
||||
const cx = node.position[0]
|
||||
const cz = node.position[2]
|
||||
const yaw = node.rotation?.[1] ?? 0
|
||||
const cos = Math.cos(yaw)
|
||||
const sin = Math.sin(yaw)
|
||||
|
||||
// Rotate the (inset) footprint corners about Y and translate to the
|
||||
// item's plan position. Y-rotation of (dx, dz): (dx·cos + dz·sin, −dx·sin + dz·cos).
|
||||
return (
|
||||
[
|
||||
[-halfW, -halfD],
|
||||
[halfW, -halfD],
|
||||
[halfW, halfD],
|
||||
[-halfW, halfD],
|
||||
] as Array<[number, number]>
|
||||
).map(([dx, dz]) => [cx + dx * cos + dz * sin, cz - dx * sin + dz * cos])
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
parametrics: itemParametrics,
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function ItemPanel() {
|
||||
// frame regenerates its cutout geometry around the moved item.
|
||||
if (n.asset.attachTo === 'wall' && n.parentId) {
|
||||
requestAnimationFrame(() => {
|
||||
useScene.getState().dirtyNodes.add(n.parentId as AnyNode['id'])
|
||||
useScene.getState().markDirty(n.parentId as AnyNode['id'])
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -156,7 +156,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!node.parentId) return
|
||||
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
useScene.getState().markDirty(node.parentId as AnyNodeId)
|
||||
}, [node.parentId])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { getRoofDebugMaterials, getRoofMaterials } from '../roof/roof-materials'
|
||||
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
|
||||
|
||||
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
const ref = useRef<THREE.Mesh>(null!)
|
||||
@@ -36,15 +37,8 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
const parentNode = node.parentId
|
||||
? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
|
||||
: undefined
|
||||
const placeholderGeometry = useMemo(() => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
geometry.addGroup(0, 0, 0)
|
||||
geometry.addGroup(0, 0, 1)
|
||||
geometry.addGroup(0, 0, 2)
|
||||
geometry.addGroup(0, 0, 3)
|
||||
return geometry
|
||||
}, [])
|
||||
// 4 groups map 1:1 to the roof's 4-material array (see getRoofMaterialArray).
|
||||
const placeholderGeometry = useMemo(() => createPlaceholderGeometry(4), [])
|
||||
|
||||
// Segment material precedence, per-role:
|
||||
// 1. Segment's role-specific override (topMaterial, edgeMaterial, wallMaterial).
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { getRoofMaterialArray, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import type * as THREE from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
|
||||
import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials'
|
||||
|
||||
export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => {
|
||||
@@ -80,15 +81,8 @@ export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => {
|
||||
}),
|
||||
)
|
||||
|
||||
const placeholderGeometry = useMemo(() => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
geometry.addGroup(0, 0, 0)
|
||||
geometry.addGroup(0, 0, 1)
|
||||
geometry.addGroup(0, 0, 2)
|
||||
geometry.addGroup(0, 0, 3)
|
||||
return geometry
|
||||
}, [])
|
||||
// 4 groups map 1:1 to the roof's 4-material array (see getRoofMaterialArray).
|
||||
const placeholderGeometry = useMemo(() => createPlaceholderGeometry(4), [])
|
||||
|
||||
const customMaterial = useMemo(
|
||||
() => getRoofMaterialArray(node, shading, textures, colorPreset, sceneTheme),
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
snapFenceDraftPoint,
|
||||
stripPlacementMetadataFlags,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useFreshPlacementVisibility,
|
||||
type WallPlanPoint,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BufferGeometry, Float32BufferAttribute } from 'three'
|
||||
|
||||
/**
|
||||
* Placeholder geometry for a mesh whose real geometry is filled in later by a
|
||||
* system (wall / roof / roof-segment / ceiling / stair-segment). These meshes
|
||||
* are mounted *visible*, so the WebGPU renderer draws them on the first
|
||||
* frame(s) before the owning system runs — and the system passes are
|
||||
* rate-limited, so several meshes can still hold the placeholder across
|
||||
* multiple frames.
|
||||
*
|
||||
* It carries three zero-vertices — a single degenerate, zero-area (invisible)
|
||||
* triangle — rather than an empty `position` attribute. An empty attribute
|
||||
* (count 0) makes three.js create no GPU buffer for it, so vertex buffer slot 0
|
||||
* is never bound and WebGPU rejects the draw with "Vertex buffer slot 0 … was
|
||||
* not set", which poisons the whole command encoder (cascading into "Invalid
|
||||
* CommandBuffer" on every queue submit). Three real vertices give it a bound
|
||||
* buffer; the `groupCount` count-0 groups keep nothing drawn while matching the
|
||||
* mesh's material-array length so raycasts / BVH never index past the materials.
|
||||
*/
|
||||
export function createPlaceholderGeometry(groupCount = 0): BufferGeometry {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3))
|
||||
for (let group = 0; group < groupCount; group++) {
|
||||
geometry.addGroup(0, 0, group)
|
||||
}
|
||||
return geometry
|
||||
}
|
||||
@@ -6,11 +6,10 @@ import {
|
||||
polygonAnchors,
|
||||
resolveAlignment,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getSegmentGridStep, type WallPlanPoint } from '@pascal-app/editor'
|
||||
import { getSegmentGridStep, useAlignmentGuides, type WallPlanPoint } from '@pascal-app/editor'
|
||||
import type * as THREE from 'three'
|
||||
import { createFloorplanCursorResolver } from './floorplan-cursor'
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { snapToHalf } from '@pascal-app/editor'
|
||||
import { type AlignmentAnchor, resolveAlignment, type WallNode } from '@pascal-app/core'
|
||||
import { snapToHalf, useAlignmentGuides } from '@pascal-app/editor'
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||
export const WALL_OPENING_ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
@@ -5,10 +5,14 @@ import {
|
||||
emitter,
|
||||
type GridEvent,
|
||||
ShelfNode,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getFloorStackPreviewPosition, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
getFloorStackPreviewPosition,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Group } from 'three'
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
resolveAlignment,
|
||||
type SlabNode,
|
||||
sceneRegistry,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
markToolCancelConsumed,
|
||||
snapFenceDraftPoint,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
EDITOR_LAYER,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { getStraightStairSegmentBodyMaterials, useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import type * as THREE from 'three'
|
||||
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
|
||||
|
||||
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
||||
const ref = useRef<THREE.Mesh>(null!)
|
||||
@@ -55,13 +56,8 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
||||
parentNode,
|
||||
])
|
||||
|
||||
const placeholderGeometry = useMemo(() => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
geometry.addGroup(0, 0, 0)
|
||||
geometry.addGroup(0, 0, 1)
|
||||
return geometry
|
||||
}, [])
|
||||
// 2 groups map 1:1 to the stair segment's 2-material array (body + tread).
|
||||
const placeholderGeometry = useMemo(() => createPlaceholderGeometry(2), [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
|
||||
|
||||
type SegmentTransform = {
|
||||
position: [number, number, number]
|
||||
@@ -106,13 +107,8 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
|
||||
[node, shading, textures, colorPreset],
|
||||
)
|
||||
|
||||
const straightPlaceholderGeometry = useMemo(() => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
geometry.addGroup(0, 0, 0)
|
||||
geometry.addGroup(0, 0, 1)
|
||||
return geometry
|
||||
}, [])
|
||||
// 2 groups map 1:1 to the stair body's 2-material array (body + tread).
|
||||
const straightPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(2), [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
normalizeWallCurveOffset,
|
||||
useAlignmentGuides,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
isSegmentLongEnough,
|
||||
snapScalarToGrid,
|
||||
snapWallDraftPoint,
|
||||
useAlignmentGuides,
|
||||
WALL_FINE_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
pauseSceneHistory,
|
||||
resolveAlignment,
|
||||
resumeSceneHistory,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -24,9 +23,11 @@ import {
|
||||
MeasurementPill,
|
||||
type MovingWallEndpoint,
|
||||
markToolCancelConsumed,
|
||||
snapWallDraftPoint,
|
||||
snapWallDraftPointDetailed,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useWallSnapIndicator,
|
||||
WALL_FINE_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
@@ -291,12 +292,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
// for precision placement, so it can land on positions the
|
||||
// active grid would skip (e.g. 0.05m increments when the active
|
||||
// grid is 0.5m). It does NOT bypass snap.
|
||||
const snappedPoint = snapWallDraftPoint({
|
||||
const snapResult = snapWallDraftPointDetailed({
|
||||
point: planPoint,
|
||||
walls: levelWalls,
|
||||
ignoreWallIds: [nodeId],
|
||||
step: shiftPressedRef.current ? WALL_FINE_GRID_STEP : undefined,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
})
|
||||
const snappedPoint = snapResult.point
|
||||
|
||||
// Figma-style alignment: nudge the dragged endpoint onto another wall /
|
||||
// fence endpoint or midpoint axis when within threshold, and publish a
|
||||
@@ -327,11 +330,22 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
previousGridPosRef.current = alignedPoint
|
||||
hasDraggedRef.current = true
|
||||
|
||||
// Stand the magnetic beacon at the endpoint when it locked onto existing
|
||||
// wall geometry (corner / midpoint / crossing / wall body).
|
||||
useWallSnapIndicator
|
||||
.getState()
|
||||
.set(
|
||||
snapResult.snap
|
||||
? { x: alignedPoint[0], z: alignedPoint[1], kind: snapResult.snap }
|
||||
: null,
|
||||
)
|
||||
|
||||
applyPreview(alignedPoint, event.nativeEvent.altKey)
|
||||
}
|
||||
|
||||
const onPointerUp = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
// Press-release without drag: dismiss the tool without committing.
|
||||
if (!hasDraggedRef.current) {
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -379,6 +393,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
|
||||
const onCancel = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
@@ -425,6 +440,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
|
||||
return () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal(false)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { getVisibleWallMaterials, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
|
||||
import type { Mesh } from 'three'
|
||||
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
|
||||
|
||||
/**
|
||||
* Thin wall renderer.
|
||||
@@ -24,23 +25,11 @@ import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
|
||||
* That decision lands in a later milestone; for now the system retains
|
||||
* ownership of the rebuild loop.
|
||||
*/
|
||||
function createEmptyWallGeometry(): BufferGeometry {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
|
||||
geometry.addGroup(0, 0, 0)
|
||||
geometry.addGroup(0, 0, 1)
|
||||
geometry.addGroup(0, 0, 2)
|
||||
return geometry
|
||||
}
|
||||
|
||||
const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const placeholderGeometry = useMemo(createEmptyWallGeometry, [])
|
||||
const collisionPlaceholderGeometry = useMemo(() => {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
|
||||
return geometry
|
||||
}, [])
|
||||
// 3 groups map 1:1 to the wall's 3-material array (see getVisibleWallMaterials).
|
||||
const placeholderGeometry = useMemo(() => createPlaceholderGeometry(3), [])
|
||||
const collisionPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(), [])
|
||||
|
||||
useRegistry(node.id, 'wall', ref)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type LevelNode,
|
||||
type Point2D,
|
||||
resolveAlignment,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
@@ -22,9 +21,11 @@ import {
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
markToolCancelConsumed,
|
||||
type SegmentAngleReference,
|
||||
snapWallDraftPoint,
|
||||
snapWallDraftPointDetailed,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useWallSnapIndicator,
|
||||
WALL_FINE_GRID_STEP,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
@@ -540,6 +541,7 @@ export const WallTool: React.FC = () => {
|
||||
setDraftMeasurement(null)
|
||||
setAxisGuide(null)
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
@@ -554,7 +556,22 @@ export const WallTool: React.FC = () => {
|
||||
// a grid intersection.
|
||||
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
gridPosition = alignPoint(snapWallDraftPoint({ point: localPoint, walls, step }), bypassAlign)
|
||||
const snapResult = snapWallDraftPointDetailed({
|
||||
point: localPoint,
|
||||
walls,
|
||||
step,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
})
|
||||
gridPosition = alignPoint(snapResult.point, bypassAlign)
|
||||
// Stand the magnetic beacon at the endpoint when it locked onto an
|
||||
// existing wall corner / wall point; clear it for plain grid/angle moves.
|
||||
useWallSnapIndicator
|
||||
.getState()
|
||||
.set(
|
||||
snapResult.snap
|
||||
? { x: gridPosition[0], z: gridPosition[1], kind: snapResult.snap }
|
||||
: null,
|
||||
)
|
||||
|
||||
if (buildingState.current === 1) {
|
||||
const snappedLocal = gridPosition
|
||||
@@ -617,7 +634,12 @@ export const WallTool: React.FC = () => {
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
const snappedStart = alignPoint(
|
||||
snapWallDraftPoint({ point: localClick, walls, step: clickStep }),
|
||||
snapWallDraftPointDetailed({
|
||||
point: localClick,
|
||||
walls,
|
||||
step: clickStep,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
}).point,
|
||||
bypassAlign,
|
||||
)
|
||||
gridPosition = snappedStart
|
||||
@@ -640,7 +662,12 @@ export const WallTool: React.FC = () => {
|
||||
setDraftMeasurement(null)
|
||||
} else if (buildingState.current === 1) {
|
||||
const snappedEnd = alignPoint(
|
||||
snapWallDraftPoint({ point: localClick, walls, step: clickStep }),
|
||||
snapWallDraftPointDetailed({
|
||||
point: localClick,
|
||||
walls,
|
||||
step: clickStep,
|
||||
magnetic: useEditor.getState().magneticSnap,
|
||||
}).point,
|
||||
bypassAlign,
|
||||
)
|
||||
const dx = snappedEnd[0] - startingPoint.current.x
|
||||
@@ -657,6 +684,7 @@ export const WallTool: React.FC = () => {
|
||||
// for the next segment, and drop the just-shown guide.
|
||||
refreshAlignmentCandidates()
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
|
||||
const nextStart = createdWall.end
|
||||
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
|
||||
@@ -706,6 +734,7 @@ export const WallTool: React.FC = () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
}
|
||||
}, [unit])
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
WindowNode,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type AnyNodeId, StairOpeningSystem } from '@pascal-app/core'
|
||||
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
import * as THREE from 'three/webgpu'
|
||||
import { hasDrawableGeometry } from '../../lib/drawable-geometry'
|
||||
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
|
||||
import { applyIsolation, clearIsolation } from '../../lib/isolation'
|
||||
import type { ColorPreset, RenderShading } from '../../lib/materials'
|
||||
@@ -44,6 +45,62 @@ extend(THREE as any)
|
||||
// renderers in parallel and only caching the second.
|
||||
const WEBGPU_RENDERER_CACHE = new WeakMap<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>()
|
||||
|
||||
const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet<object>()
|
||||
|
||||
/**
|
||||
* Renderer-level safety net against the empty-vertex-buffer crash.
|
||||
*
|
||||
* Wraps the per-object render function so any draw whose geometry has a count-0
|
||||
* `position` attribute is skipped instead of submitted. One such draw leaves
|
||||
* WebGPU vertex buffer slot 0 unbound, which the validator rejects and which
|
||||
* poisons the *whole* command encoder — so a single stray empty mesh (e.g. a
|
||||
* transient placeholder, or a derived edge/outline geometry) flickers the entire
|
||||
* canvas, not just itself. See `hasDrawableGeometry`.
|
||||
*
|
||||
* The custom render-object function is the documented three.js hook for this
|
||||
* (`Renderer.setRenderObjectFunction`); it must call `renderObject()` for
|
||||
* everything it keeps. `MergedOutlineNode` captures and restores this function
|
||||
* around its passes, so the guard survives outline rendering (its own passes
|
||||
* carry the same check inline).
|
||||
*/
|
||||
function installEmptyDrawGuard(renderer: THREE.WebGPURenderer) {
|
||||
renderer.setRenderObjectFunction(
|
||||
(
|
||||
object: any,
|
||||
scene: any,
|
||||
camera: any,
|
||||
geometry: any,
|
||||
material: any,
|
||||
group: any,
|
||||
lightsNode: any,
|
||||
clippingContext: any,
|
||||
passId: any,
|
||||
) => {
|
||||
if (!hasDrawableGeometry(geometry)) {
|
||||
if (warnedEmptyDraw && !warnedEmptyDraw.has(geometry ?? object)) {
|
||||
warnedEmptyDraw.add(geometry ?? object)
|
||||
console.warn(
|
||||
'[viewer] skipped a draw with an empty position buffer (would poison the WebGPU command encoder)',
|
||||
{ name: object?.name, type: object?.type, material: material?.name },
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
;(renderer as any).renderObject(
|
||||
object,
|
||||
scene,
|
||||
camera,
|
||||
geometry,
|
||||
material,
|
||||
group,
|
||||
lightsNode,
|
||||
clippingContext,
|
||||
passId,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors the WebGPU device for loss / uncaptured errors and logs them.
|
||||
* WebGPU device loss can happen when:
|
||||
@@ -245,6 +302,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
useViewer.getState().sceneTheme,
|
||||
).toneMappingExposure
|
||||
await renderer.init()
|
||||
installEmptyDrawGuard(renderer)
|
||||
return renderer
|
||||
} catch (err) {
|
||||
// Drop the failed promise from the cache so a future Canvas
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { BufferGeometry } from 'three'
|
||||
|
||||
/**
|
||||
* True when `geometry` has a bound, non-empty `position` attribute — i.e. it is
|
||||
* safe to submit to the WebGPU renderer.
|
||||
*
|
||||
* A geometry whose `position` attribute has `count === 0` (or no `position` at
|
||||
* all) leaves WebGPU **vertex buffer slot 0 unbound**. The validator rejects the
|
||||
* draw with "Vertex buffer slot 0 … was not set", and — critically — that single
|
||||
* rejected draw **poisons the entire command encoder**: every other draw in the
|
||||
* frame (the whole scene + every editor overlay) is discarded on the next queue
|
||||
* submit ("Invalid CommandBuffer"). The visible result is the whole canvas
|
||||
* flickering/garbling, not just the offending mesh.
|
||||
*
|
||||
* Individual call-sites guard against *creating* empty geometry (see
|
||||
* `createPlaceholderGeometry`, the ceiling/door degenerate fallbacks, etc.), but
|
||||
* transient/derived geometries can still slip through. This predicate is the
|
||||
* renderer-level safety net: skipping a count-0 draw is a no-op visually (it
|
||||
* would draw nothing anyway) while keeping the command encoder healthy.
|
||||
*/
|
||||
export function hasDrawableGeometry(geometry: BufferGeometry | undefined | null): boolean {
|
||||
const position = geometry?.attributes?.position
|
||||
return Boolean(position && position.count > 0)
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
SpriteNodeMaterial,
|
||||
TempNode,
|
||||
} from 'three/webgpu'
|
||||
import { hasDrawableGeometry } from './drawable-geometry'
|
||||
|
||||
const _quadMesh = new QuadMesh()
|
||||
const _size = new Vector2()
|
||||
@@ -353,6 +354,7 @@ export class MergedOutlineNode extends TempNode {
|
||||
renderer.setRenderTarget(this._depthRT)
|
||||
renderer.setRenderObjectFunction(
|
||||
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
|
||||
if (!hasDrawableGeometry(geo)) return
|
||||
const inCache = this._cacheA.has(obj) || this._cacheB.has(obj)
|
||||
if (!inCache) {
|
||||
const m = obj.isSprite ? this._depthSpriteMaterial : this._depthMaterial
|
||||
@@ -368,6 +370,7 @@ export class MergedOutlineNode extends TempNode {
|
||||
renderer.setRenderTarget(this._groupA.maskBuffer)
|
||||
renderer.setRenderObjectFunction(
|
||||
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
|
||||
if (!hasDrawableGeometry(geo)) return
|
||||
if (this._cacheA.has(obj)) {
|
||||
const m = obj.isSprite ? this._prepareMaskSpriteMatA : this._prepareMaskMatA
|
||||
renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip)
|
||||
@@ -383,6 +386,7 @@ export class MergedOutlineNode extends TempNode {
|
||||
renderer.setRenderTarget(this._groupB.maskBuffer)
|
||||
renderer.setRenderObjectFunction(
|
||||
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
|
||||
if (!hasDrawableGeometry(geo)) return
|
||||
if (this._cacheB.has(obj)) {
|
||||
const m = obj.isSprite ? this._prepareMaskSpriteMatB : this._prepareMaskMatB
|
||||
renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip)
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
getEffectiveNode,
|
||||
nodeRegistry,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -9,6 +10,8 @@ import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
|
||||
|
||||
type SceneNodes = ReturnType<typeof useScene.getState>['nodes']
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv) return
|
||||
@@ -38,7 +41,9 @@ export const CeilingSystem = () => {
|
||||
// Merge any live drag override so the polygon / height resize
|
||||
// arrow rebuilds the mesh at pointer rate — zustand only learns
|
||||
// the final value on commit. Mirrors WallSystem / GeometrySystem.
|
||||
updateCeilingGeometry(getEffectiveNode(node as CeilingNode), mesh)
|
||||
const effective = getEffectiveNode(node as CeilingNode)
|
||||
const itemHoles = collectCeilingHoles(effective, nodes)
|
||||
updateCeilingGeometry(effective, mesh, itemHoles)
|
||||
clearDirty(id as AnyNodeId)
|
||||
}
|
||||
// If mesh not found, keep it dirty for next frame
|
||||
@@ -48,11 +53,42 @@ export const CeilingSystem = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects ceiling-hole polygons from child nodes that declare the `ceilingCut`
|
||||
* capability. Each child's `buildCeilingHole` returns a rotated-rectangle
|
||||
* footprint in ceiling-local [x, z] space (or `null` to opt out), which is
|
||||
* merged as an extra hole before triangulation.
|
||||
*
|
||||
* The viewer never branches on `child.type` — the dispatch goes through
|
||||
* `nodeRegistry`, so any future kind (a heat lamp, a skylight panel, …) can
|
||||
* participate just by declaring `capabilities.ceilingCut` on its definition.
|
||||
*/
|
||||
function collectCeilingHoles(
|
||||
ceiling: CeilingNode,
|
||||
nodes: SceneNodes,
|
||||
): Array<Array<[number, number]>> {
|
||||
const holes: Array<Array<[number, number]>> = []
|
||||
|
||||
for (const childId of ceiling.children ?? []) {
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) continue
|
||||
const def = nodeRegistry.get(child.type)
|
||||
const hole = def?.capabilities?.ceilingCut?.buildCeilingHole(child)
|
||||
if (hole) holes.push(hole)
|
||||
}
|
||||
|
||||
return holes
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the geometry for a single ceiling
|
||||
*/
|
||||
function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {
|
||||
const newGeo = generateCeilingGeometry(node)
|
||||
function updateCeilingGeometry(
|
||||
node: CeilingNode,
|
||||
mesh: THREE.Mesh,
|
||||
extraHoles: Array<Array<[number, number]>> = [],
|
||||
) {
|
||||
const newGeo = generateCeilingGeometry(node, extraHoles)
|
||||
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeo
|
||||
@@ -74,13 +110,28 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates flat ceiling geometry from polygon (no extrusion)
|
||||
* Generates flat ceiling geometry from polygon (no extrusion).
|
||||
*
|
||||
* `extraHoles` are transient, derived cutouts (e.g. recessed-fixture
|
||||
* footprints) that are cut alongside the node's persisted `holes` but never
|
||||
* stored on the node — they are recomputed on every rebuild.
|
||||
*/
|
||||
export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferGeometry {
|
||||
export function generateCeilingGeometry(
|
||||
ceilingNode: CeilingNode,
|
||||
extraHoles: Array<Array<[number, number]>> = [],
|
||||
): THREE.BufferGeometry {
|
||||
const polygon = ceilingNode.polygon
|
||||
|
||||
if (polygon.length < 3) {
|
||||
return new THREE.BufferGeometry()
|
||||
// A degenerate ceiling (fewer than 3 points, e.g. mid-edit) still gets a
|
||||
// non-empty position buffer — three zero-vertices forming one invisible
|
||||
// triangle. An empty attribute (count 0) would leave WebGPU vertex buffer
|
||||
// slot 0 unbound when this mesh (and its cloned grid overlay) is drawn,
|
||||
// which the validator rejects ("slot 0 … was not set") and which poisons
|
||||
// the whole command encoder.
|
||||
const degenerate = new THREE.BufferGeometry()
|
||||
degenerate.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
return degenerate
|
||||
}
|
||||
|
||||
// Create shape from polygon
|
||||
@@ -97,8 +148,10 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG
|
||||
}
|
||||
shape.closePath()
|
||||
|
||||
// Add holes to the shape
|
||||
const holes = mergeSurfaceHolePolygons(ceilingNode.holes || [])
|
||||
// Add holes to the shape: persisted structural openings (stair/elevator/
|
||||
// manual, merged to dissolve overlaps) plus transient recessed-fixture
|
||||
// cutouts. Both are in the same ceiling-local [x, z] space.
|
||||
const holes = [...mergeSurfaceHolePolygons(ceilingNode.holes || []), ...extraHoles]
|
||||
for (const holePolygon of holes) {
|
||||
if (holePolygon.length < 3) continue
|
||||
|
||||
|
||||
@@ -2276,6 +2276,22 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
||||
}
|
||||
|
||||
syncDoorCutout(node, mesh)
|
||||
|
||||
// Guard: some degenerate door configs can leave a child mesh with an
|
||||
// empty (0-vertex) geometry — e.g. a zero-area extruded leaf frame.
|
||||
// Submitting such a mesh trips a WebGPU error ("Vertex buffer slot 0
|
||||
// … was not set" on a Draw(0, …)). Hide any empty mesh so it is never
|
||||
// drawn (it would render nothing anyway).
|
||||
hideEmptyGeometryMeshes(mesh)
|
||||
}
|
||||
|
||||
function hideEmptyGeometryMeshes(root: THREE.Object3D) {
|
||||
root.traverse((obj) => {
|
||||
const child = obj as THREE.Mesh
|
||||
if (!child.isMesh || !child.geometry) return
|
||||
const position = child.geometry.getAttribute('position')
|
||||
if (!position || position.count === 0) child.visible = false
|
||||
})
|
||||
}
|
||||
|
||||
function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AnyNodeId, LevelNode } from '@pascal-app/core'
|
||||
import { sceneRegistry, useInteractive, useScene } from '@pascal-app/core'
|
||||
import { findLevelAncestorId, sceneRegistry, useInteractive, useScene } from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useRef } from 'react'
|
||||
import { MathUtils, type PointLight, Vector3 } from 'three'
|
||||
@@ -67,8 +67,7 @@ function scoreRegistration(
|
||||
const dist = _camPos.distanceTo(_itemPos) / 200
|
||||
|
||||
// ── Level factor ──────────────────────────────────────────────────────────
|
||||
const node = nodes[nodeId]
|
||||
const itemLevelId = node?.parentId ?? null
|
||||
const itemLevelId = findLevelAncestorId(nodeId, nodes)
|
||||
|
||||
let levelPenalty = 0
|
||||
if (selectedLevelId) {
|
||||
@@ -135,7 +134,10 @@ export function ItemLightSystem() {
|
||||
scored.sort((a, b) => a.score - b.score)
|
||||
|
||||
// Build the desired assignment (top POOL_SIZE keys)
|
||||
const desired = scored.slice(0, POOL_SIZE).map((s) => s.key)
|
||||
const desired = scored
|
||||
.filter((s) => Number.isFinite(s.score))
|
||||
.slice(0, POOL_SIZE)
|
||||
.map((s) => s.key)
|
||||
|
||||
// Build a map of currently-assigned keys → slot index for hysteresis
|
||||
const currentlyAssigned = new Map<string, number>()
|
||||
@@ -224,9 +226,11 @@ export function ItemLightSystem() {
|
||||
|
||||
// Fade-out phase: lerp intensity → 0, then complete the transition
|
||||
if (slot.isFadingOut) {
|
||||
light.visible = true
|
||||
light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12)
|
||||
if (light.intensity < 0.01) {
|
||||
light.intensity = 0
|
||||
light.visible = false
|
||||
slot.isFadingOut = false
|
||||
slot.key = slot.pendingKey
|
||||
slot.pendingKey = null
|
||||
@@ -245,6 +249,7 @@ export function ItemLightSystem() {
|
||||
if (!slot.key) {
|
||||
// Idle slot — keep dark
|
||||
light.intensity = 0
|
||||
light.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -252,6 +257,7 @@ export function ItemLightSystem() {
|
||||
if (!reg) {
|
||||
slot.key = null
|
||||
light.intensity = 0
|
||||
light.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -275,7 +281,14 @@ export function ItemLightSystem() {
|
||||
? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t)
|
||||
: reg.effect.intensityRange[0]
|
||||
|
||||
if (targetIntensity > 0) {
|
||||
light.visible = true
|
||||
}
|
||||
light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12)
|
||||
if (targetIntensity <= 0 && light.intensity < 0.01) {
|
||||
light.intensity = 0
|
||||
light.visible = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -286,6 +299,7 @@ export function ItemLightSystem() {
|
||||
castShadow={false}
|
||||
intensity={0}
|
||||
key={i}
|
||||
visible={false}
|
||||
ref={(el: any) => {
|
||||
lightRefs.current[i] = el
|
||||
}}
|
||||
|
||||
@@ -144,7 +144,14 @@ export const RoofSystem = () => {
|
||||
if (mesh.geometry.type === 'BoxGeometry') {
|
||||
mesh.geometry.dispose()
|
||||
const placeholder = new THREE.BufferGeometry()
|
||||
placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
// Three zero-vertices (one degenerate, invisible triangle), not an
|
||||
// empty attribute: an empty position (count 0) leaves WebGPU vertex
|
||||
// buffer slot 0 unbound if the mesh is ever drawn, and computeBoundsTree
|
||||
// needs a real position buffer to index.
|
||||
placeholder.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute(new Float32Array(9), 3),
|
||||
)
|
||||
computeGeometryBoundsTree(placeholder)
|
||||
mesh.geometry = placeholder
|
||||
}
|
||||
|
||||
@@ -65,11 +65,9 @@ export const StairSystem = () => {
|
||||
} else if (isVisible) {
|
||||
return // Over budget — keep dirty, process next frame
|
||||
} else if (mesh.geometry.type === 'BoxGeometry') {
|
||||
// Replace BoxGeometry placeholder with empty geometry
|
||||
// Replace BoxGeometry placeholder with a non-drawing degenerate one.
|
||||
mesh.geometry.dispose()
|
||||
const placeholder = new THREE.BufferGeometry()
|
||||
placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
mesh.geometry = placeholder
|
||||
mesh.geometry = createEmptyGeometry()
|
||||
}
|
||||
clearDirty(id as AnyNodeId)
|
||||
} else {
|
||||
@@ -528,7 +526,11 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] {
|
||||
|
||||
function createEmptyGeometry(): THREE.BufferGeometry {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
// Three zero-vertices (one degenerate, invisible triangle), not an empty
|
||||
// attribute: an empty position (count 0) leaves WebGPU vertex buffer slot 0
|
||||
// unbound and the draw is rejected ("Vertex buffer slot 0 … was not set"),
|
||||
// poisoning the command encoder. The count-0 groups keep nothing drawn.
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX)
|
||||
geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX)
|
||||
return geometry
|
||||
|
||||
Reference in New Issue
Block a user