feat: vertical building model — stored level heights, wall inversion, decks (#526)
* feat(core): stored storey heights groundwork — pure slab-support module + level height schema Extract pointInPolygon/computeWallSlabSupport and friends into a cycle-free packages/core/src/systems/slab/slab-support.ts (severs level-height -> spatial-grid-manager -> use-scene), add deriveLegacyLevelHeight as the pure mesh-free equivalent of the viewer's stacked level height, and add the optional LevelNode.height field plus the storey service (getStoredLevelHeight, getLevelElevations per-building prefix sums). No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): storey height badge + edit popover on level rows Each floating-level-selector row shows its storey height; clicking opens a popover with 2.5/3.0/3.5 presets and a free slider writing level.height. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(core): vertical-model load migration — stored heights, ordinal compaction, wall-top classification Pass 3 in migrateNodes: derive and store each legacy level's exact stacked height (never snapped), compact ordinals per building anchored at zero so basements stay basements, classify wall tops against the derived plane (|plane - top| < 0.20 strictly -> plane-bound, else explicit height materialized), and drop the blind totalRise 2.5 stair default on legacy scenes only. Epsilon and strictness validated by a prod census. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: pin wall tops to the storey plane; stored heights become the only vertical truth Wall-top inversion: a wall without an explicit height now tops out at its storey plane (resolveWallTop); slabs lift only the base. Window/door caps resolve the real top through the same slab election instead of Infinity. All level stacking (viewer, elevator, first-person, stair openings, MCP scene queries) reads stored LevelNode.height; the four divergent live derivations and level.metadata.height are deleted. Stair totalRise becomes optional and derives from the storey height when absent. MCP create-level stops writing its elevation param into the ordinal. Level creation sites write explicit heights; templates carry their true derived heights. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: clamp slabs/ceilings under the storey plane; sweep wall-height fallbacks; wall Top control Slab elevation writes clamp to plane − MIN_WALL_HEIGHT when plane-bound walls elect the slab (pure clampSlabElevationForWalls + registry handle bounds + shared panels for 2D/3D parity); ceiling heights clamp under the plane and auto-ceilings derive from resolved wall tops. Every remaining wall.height ?? 2.5 fallback resolves through resolveWallTop / resolveWallEffectiveHeight (panels, overlays, measurements, quantities, spatial grid, MCP reports); template walls matching their storey become plane-bound. Wall panel gains a Top control (Follows storey / Custom height) derived purely from height presence; the store update path now deletes keys passed as explicit undefined so plane-binding round-trips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(core): persisted support hosts — schema, host-preferring election, rendered-polygon unification Floor-placed nodes and walls gain a nullable supportSlabId. Elections surface the winning slab (getSlabSupportForItem, candidates query) and prefer a still-valid persisted host, falling back silently when the host is gone or reshaped away; deleting the host strips references in the same undo commit. Item-side support now tests the rendered slab polygon (like walls) through a per-level cache invalidated by the spatial-grid sync. Also adds the resolveStairTotalRise unit tests from the stage-1 gates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: persist support hosts at commit; thread wall host preference everywhere Floor-placed commits (draft pipeline, per-kind creation tools, registry move tool) and wall create/move/endpoint commits persist supportSlabId via shared resolveSupportSlabPatch helpers — only when overlapping supports disagree on elevation, clearing it otherwise or when the node leaves the floor. WallSlabSupport surfaces electedSlabId; every wall support read site passes wall.supportSlabId as the preferred host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: split slab into placement + thickness; pools become explicit recess intent slab.elevation stays the walking surface; new thickness grows downward so the solid occupies [elevation − thickness, elevation]. Migration writes thickness := elevation for solids (byte-identical intervals, including degenerate zero) and recessed: true for legacy negative pools. Geometry branches on recessed instead of the elevation sign; presets keep today's intervals; free elevation edits move the body without coupling thickness (the deck semantic). Dead viewer SlabSystem component deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: clamp ceilings to covering-slab undersides across levels getLevelAbove + getCoveringSlabUndersideAt give the first cross-level query; ceiling writes clamp to min(storey plane, lowest covering underside) − 0.01, and the space-detection reconcile now clamps manual ceilings down (never up) when a deck above intrudes — a flush deck reactively lowers the ceiling below it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: mezzanine and balcony build-tab tools One-gesture composites over the kernel: draw a deck footprint and commit deck slab + railings + stair (mezzanine) or deck + railings (balcony) in a single undo step. Fences gain supportSlabId and lift onto their host deck; railing runs split around the stair mouth; edges near wall centerlines are treated as closed. Stairs target the deck via explicit totalRise with no level-to-level opening sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: adaptive slab vertical editing; level vocabulary in UI copy Dragging a grounded slab's top stretches it (elevation and thickness move together — gaps impossible); floating decks move with thickness preserved and land grounded at zero; pools keep the drag-through-zero gesture. User-facing copy says level, not storey (Follows level, Level height). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: clamp plane-bound wall tops to covering-slab undersides; fix vertical reactivity getWallPlaneTop samples the wall span against the level above's slabs, so a thick or flush upper floor shortens the walls below it instead of colliding (automatic attach, no dialog). Level-height edits now dirty the level's walls, stairs, ceilings, and fences; covering-slab changes dirty the level below. The effective-height helper triplicated across editor overlays moved to core. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: ceilings follow the level top by default ceiling.height becomes optional — absent means the ceiling tracks min(level top, covering slab underside) − 0.01 live, so level-height edits no longer require ceiling fixes. Ceiling panel gains the same Follows level / Custom height control as walls; auto-from-walls ceilings are created height-less and their height-derivation machinery is deleted; migration drops stored heights within 0.20 of the bound on legacy scenes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: wall plane clamp missed max-side boundary walls Auto slabs derive their polygon from wall centerlines, so covering-clamp samples sat exactly on the boundary where ray-cast point-in-polygon is side-dependent (min edges in, max edges out) — walls clamped or not by orientation. getWallPlaneTop now clips the wall's thickness band against the covering polygon (boundary-inclusive, arc-aware) and the ceiling bound's point sampling gained an explicit on-boundary test. Verified against the reported repro scene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: stairs attach to decks; pointer-decided placement surface Stairs gain deckSlabId — rise follows the deck's elevation live (straight flights re-converge via a write-sync mirroring auto-openings), the panel shows a unified To destination with Follows deck / Custom rise, and the mezzanine tool attaches instead of baking a stale rise. Item placement under an elevated deck no longer flickers: grid events fed a feedback loop (the grid plane rode the ghost's elected height), so the support election is now capped at the surface the pointer ray actually hits, with a ground sentinel keeping under-deck commits deterministic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: remove the mezzanine/balcony composite tools Decks ship as catalog presets instead; the kernel the presets rely on (fence deck-hosting, stair deck attachment, clone remaps, pointer-decided placement) all stays. The tool code lives at e30042db for reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: gate wall adoption to grounded slabs; panel moves, drag stretches then unsticks Floating decks keep their drawn polygon (and stop being seam candidates for grounded neighbors) instead of growing into nearby walls. Panel elevation edits are pure placement; the viewport drag stretches a grounded slab up to 0.4 m then unsticks it into a thin deck. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: deck-attached stairs land flush with the deck surface The rise now subtracts the stair's own elected base (same election the visual lift uses), so base + rise always equals the deck walking surface; the auto-sync defers a microtask so it reads a settled spatial grid and re-converges on both deck and base-slab moves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: draw walls and fences on elevated decks Wall and fence draw tools now publish the pointed surface, so the draw plane rides the deck top (no more perspective-skewed floor hits) and previews sit on the deck. Fences gain real support election: a pure resolveFenceSupportSlabPatch persists the deck host at draw and reshape commits; wall commits thread the pointer cap so aiming under a deck elects the floor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: unit-aware height presets Level, ceiling, and slab preset buttons show clean values per display system (8/9/10 ft storeys, 8'-9' ceilings, whole-inch slab steps) instead of converted metric labels; metric presets unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: stairs converge to their resolved rise; deck attachment disables the cutout syncStairRises now converges every follows-mode straight stair (level or deck) plus deck-attached custom rises — detaching a stair from a deck re-derives its height, and ordinary stairs finally track level-height changes. Attaching via the panel writes slabOpeningMode none and hides the cutout controls; detaching restores the destination cutout and clears the stale explicit rise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: stacked-slab move hopping — one ray, one surface, one XZ The hop was hysteresis: the election consumed the riding grid plane's perspective-skewed hit, giving two self-consistent fixed points for one pointer ray. getPointedSupportSurface now returns the ray's crossing of the pointed surface and both the support cap and the cursor XZ derive from that single computation, so items stay on the surface the pointer aims at and sit exactly under the cursor across storeys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: single stair per click; tools restore select mode on exit The stair tool subscribed to both node clicks (synthesized on pointerup) and the native-click grid event with none of the guards sibling tools carry — one physical click over any node surface committed twice. A commit gate + follow-up click swallow fix the double dispatch, and the stair and column tools now restore select mode on exit instead of leaving the dead build-mode-without-tool state that ignored every click. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: compact multi-selection panel with host footer slot Selecting multiple nodes now docks the collapsed-by-default panel on the right: N selected header, kind breakdown, and Duplicate/Delete mirroring the floating pill. A new multiSelectionFooter slot lets the host app dock actions below it, exactly like inspectorFooter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
34b601bb79
commit
bf89b5bcf2
@@ -15,6 +15,7 @@ import {
|
||||
movingFootprintAnchors,
|
||||
nodeRegistry,
|
||||
resolveAlignment,
|
||||
resolveSupportSlabPatch,
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
@@ -797,6 +798,7 @@ const CabinetTool = () => {
|
||||
name: island ? 'Kitchen Island' : 'Modular Cabinet',
|
||||
position,
|
||||
rotation: yaw,
|
||||
parentId: activeLevelId,
|
||||
depth: patch.depth ?? cabinetDefinition.defaults().depth,
|
||||
carcassHeight: patch.carcassHeight ?? cabinetDefinition.defaults().carcassHeight,
|
||||
...(island && {
|
||||
@@ -841,11 +843,16 @@ const CabinetTool = () => {
|
||||
buildModule(m.x, m.width, index),
|
||||
)
|
||||
for (const module of modules) sceneApi.upsert(module, cabinet.id as AnyNodeId)
|
||||
const liveRun = sceneApi.get<CabinetNode>(cabinet.id as AnyNodeId) ?? cabinet
|
||||
sceneApi.update(
|
||||
liveRun.id as AnyNodeId,
|
||||
resolveSupportSlabPatch(liveRun, sceneApi.nodes()),
|
||||
)
|
||||
bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId)
|
||||
sceneApi.resumeHistory()
|
||||
return {
|
||||
endModule: modules[modules.length - 1]!,
|
||||
run: sceneApi.get<CabinetNode>(cabinet.id as AnyNodeId) ?? cabinet,
|
||||
run: sceneApi.get<CabinetNode>(cabinet.id as AnyNodeId) ?? liveRun,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,6 +896,19 @@ const CabinetTool = () => {
|
||||
}
|
||||
|
||||
bumpCabinetRunsNearNewRun(nextRun.id as AnyNodeId)
|
||||
const liveNextRun = sceneApi.get<CabinetNode>(nextRun.id as AnyNodeId) ?? nextRun
|
||||
sceneApi.update(
|
||||
liveNextRun.id as AnyNodeId,
|
||||
resolveSupportSlabPatch(liveNextRun, sceneApi.nodes()),
|
||||
)
|
||||
const rootRun = chainRootRunRef.current
|
||||
if (rootRun) {
|
||||
const liveRoot = sceneApi.get<CabinetNode>(rootRun.id as AnyNodeId) ?? rootRun
|
||||
sceneApi.update(
|
||||
liveRoot.id as AnyNodeId,
|
||||
resolveSupportSlabPatch(liveRoot, sceneApi.nodes()),
|
||||
)
|
||||
}
|
||||
sceneApi.resumeHistory()
|
||||
return {
|
||||
endModule: anchorModule,
|
||||
@@ -996,11 +1016,16 @@ const CabinetTool = () => {
|
||||
}
|
||||
const { cabinet, buildModule } = buildRunNodes(next.position, next.yaw)
|
||||
const module = buildModule(0, previewNode.width, 0)
|
||||
const nodes = { ...useScene.getState().nodes, [cabinet.id]: cabinet, [module.id]: module }
|
||||
const committedCabinet = CabinetNode.parse({
|
||||
...cabinet,
|
||||
...resolveSupportSlabPatch(cabinet, nodes),
|
||||
})
|
||||
useScene.getState().createNodes([
|
||||
{ node: cabinet, parentId: activeLevelId },
|
||||
{ node: module, parentId: cabinet.id },
|
||||
{ node: committedCabinet, parentId: activeLevelId },
|
||||
{ node: module, parentId: committedCabinet.id },
|
||||
])
|
||||
bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId)
|
||||
bumpCabinetRunsNearNewRun(committedCabinet.id as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [module.id] })
|
||||
useEditor.getState().setMode('select')
|
||||
triggerSFX('sfx:item-place')
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type CeilingNode,
|
||||
resolveCeilingHeight,
|
||||
resolveLevelId,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
boundaryReshapeScope,
|
||||
clearCeilingSnapFeedback,
|
||||
@@ -173,7 +179,7 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
||||
onVertexHoverChange={handleHandleHoverChange}
|
||||
polygon={effectiveCeiling.polygon}
|
||||
resolvePlanPoint={resolvePolygonEditorPlanPoint}
|
||||
surfaceHeight={effectiveCeiling.height ?? 2.5}
|
||||
surfaceHeight={resolveCeilingHeight(effectiveCeiling, useScene.getState().nodes)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type {
|
||||
CeilingNode as CeilingNodeType,
|
||||
HandleDescriptor,
|
||||
NodeDefinition,
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CeilingNode as CeilingNodeType,
|
||||
getCeilingClampBound,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
resolveCeilingHeight,
|
||||
type SceneApi,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
|
||||
import { buildCeilingFloorplan } from './floorplan'
|
||||
@@ -20,6 +25,18 @@ import { ceilingSlots } from './slots'
|
||||
const HEIGHT_HANDLE_OFFSET = 0.22
|
||||
const MIN_CEILING_HEIGHT = 0.5
|
||||
|
||||
// Ceilings no longer drive the storey height; the stored level height
|
||||
// does. Height writes clamp under min(storey plane, lowest underside of
|
||||
// any covering slab from the level above) − CEILING_CLAMP_MARGIN, so a
|
||||
// ceiling can poke into neither the level above nor a deck hanging from
|
||||
// it (clamp, never ask).
|
||||
function ceilingHeightBound(n: CeilingNodeType, sceneApi: SceneApi): number {
|
||||
const parent = n.parentId ? sceneApi.get(n.parentId as AnyNodeId) : undefined
|
||||
return parent?.type === 'level'
|
||||
? getCeilingClampBound(parent.id, sceneApi.nodes(), n.polygon ?? [])
|
||||
: Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
function ceilingPolygonCenter(n: CeilingNodeType): [number, number] {
|
||||
const polygon = n.polygon ?? []
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
@@ -38,8 +55,12 @@ function ceilingPolygonCenter(n: CeilingNodeType): [number, number] {
|
||||
// the cursor upward grows the value directly. Live override + commit
|
||||
// flow comes from the shared registry arrow pipeline.
|
||||
//
|
||||
// `currentValue` resolves through `resolveCeilingHeight`, and `apply`
|
||||
// always writes `height` — so dragging a follows-mode ceiling converts
|
||||
// it to an explicit custom height, mirroring the wall top-drag.
|
||||
//
|
||||
// The placement Y is in *mesh-local* coords. CeilingSystem already
|
||||
// parks `mesh.position.y = ceiling.height - 0.01`, so the local Y is
|
||||
// parks `mesh.position.y = resolved height - 0.01`, so the local Y is
|
||||
// just the offset above that plane (NOT `height + offset` — that
|
||||
// would double-add the height and push the arrow off-screen).
|
||||
function ceilingHeightHandle(): HandleDescriptor<CeilingNodeType> {
|
||||
@@ -48,7 +69,8 @@ function ceilingHeightHandle(): HandleDescriptor<CeilingNodeType> {
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_CEILING_HEIGHT,
|
||||
currentValue: (n) => n.height ?? 2.5,
|
||||
max: ceilingHeightBound,
|
||||
currentValue: (n) => resolveCeilingHeight(n, useScene.getState().nodes),
|
||||
apply: (_n, newValue) => ({ height: newValue }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
@@ -87,6 +109,8 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
category: 'structure',
|
||||
surfaceRole: 'ceiling',
|
||||
|
||||
// Height-less on purpose: a new ceiling follows the level top until the
|
||||
// user gives it an explicit custom height.
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
@@ -96,14 +120,15 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
polygon: [],
|
||||
holes: [],
|
||||
holeMetadata: [],
|
||||
height: 2.5,
|
||||
autoFromWalls: false,
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
surfaces: {
|
||||
top: { height: (n) => (n as CeilingNode).height },
|
||||
top: {
|
||||
height: (n) => resolveCeilingHeight(n as CeilingNodeType, useScene.getState().nodes),
|
||||
},
|
||||
},
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
@@ -124,7 +149,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
features: (node) =>
|
||||
polygonMeasurementFeatures({
|
||||
featurePrefix: 'ceiling',
|
||||
height: node.height,
|
||||
height: resolveCeilingHeight(node, useScene.getState().nodes),
|
||||
label: 'Ceiling',
|
||||
polygon: node.polygon,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CeilingNode, FloorplanMoveTarget } from '@pascal-app/core'
|
||||
import { type CeilingNode, type FloorplanMoveTarget, resolveCeilingHeight } from '@pascal-app/core'
|
||||
import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move'
|
||||
|
||||
/**
|
||||
@@ -6,14 +6,14 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move
|
||||
* centroid-pivot mover (same pivot semantics as slab / items). See
|
||||
* `shared/polygon-centroid-move.ts` for the rationale.
|
||||
*
|
||||
* `meshY = height − 0.01`: `CeilingSystem` parks the ceiling group at that Y
|
||||
* on rebuild, so mirroring it during the drag avoids a vertical teleport in
|
||||
* split view.
|
||||
* `meshY = resolved height − 0.01`: `CeilingSystem` parks the ceiling group
|
||||
* at that Y on rebuild, so mirroring it during the drag avoids a vertical
|
||||
* teleport in split view.
|
||||
*/
|
||||
export const ceilingFloorplanMoveTarget: FloorplanMoveTarget<CeilingNode> = ({ node, nodes }) =>
|
||||
createPolygonCentroidMoveTarget({
|
||||
node,
|
||||
nodes,
|
||||
meshY: (node.height ?? 2.5) - 0.01,
|
||||
meshY: resolveCeilingHeight(node, nodes) - 0.01,
|
||||
extraCommitData: node.autoFromWalls ? { autoFromWalls: false } : undefined,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type CeilingNode,
|
||||
resolveCeilingHeight,
|
||||
resolveLevelId,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { PolygonEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
@@ -64,7 +70,7 @@ export const CeilingHoleEditor: React.FC<{
|
||||
onPolygonChange={handlePolygonChange}
|
||||
onPolygonPreview={handlePolygonPreview}
|
||||
polygon={hole}
|
||||
surfaceHeight={ceiling.height ?? 2.5}
|
||||
surfaceHeight={resolveCeilingHeight(ceiling, useScene.getState().nodes)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type GridEvent,
|
||||
polygonAnchors,
|
||||
resolveAlignment,
|
||||
resolveCeilingHeight,
|
||||
sceneRegistry,
|
||||
snapScalar,
|
||||
useLiveTransforms,
|
||||
@@ -99,7 +100,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
||||
)
|
||||
const originalCenterRef = useRef(getPolygonCenter(originalPolygonRef.current))
|
||||
const heightRef = useRef(node.height ?? 2.5)
|
||||
// Resolved once at drag start — the ceiling plane can't change mid-move.
|
||||
const heightRef = useRef(resolveCeilingHeight(node, useScene.getState().nodes))
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const deltaRef = useRef<[number, number]>([0, 0])
|
||||
@@ -254,7 +256,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
<CeilingMovePreview
|
||||
ceilingId={node.id}
|
||||
cursorLocalPos={cursorLocalPos}
|
||||
height={node.height ?? 2.5}
|
||||
height={heightRef.current}
|
||||
originalHoles={originalHolesRef.current}
|
||||
originalPolygon={originalPolygonRef.current}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type CeilingNode,
|
||||
getCeilingClampBound,
|
||||
resolveCeilingHeight,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
formatLinearMeasurement,
|
||||
holeEditScope,
|
||||
PanelSection,
|
||||
PanelWrapper,
|
||||
SegmentedControl,
|
||||
SliderControl,
|
||||
triggerSFX,
|
||||
useEditingHole,
|
||||
@@ -27,6 +35,7 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
*/
|
||||
export function CeilingPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const editingHole = useEditingHole()
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
@@ -35,11 +44,40 @@ export function CeilingPanel() {
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
// Ceilings no longer drive the storey height — the stored level height
|
||||
// does — so height writes clamp under min(storey plane, lowest covering
|
||||
// slab underside from the level above) − margin instead of poking into
|
||||
// the level above or a deck hanging from it (clamp, never ask).
|
||||
// Selector returns a primitive, so recomputing per store update is
|
||||
// re-render-safe.
|
||||
const maxHeight = useScene((s) => {
|
||||
const parent = node?.parentId ? s.nodes[node.parentId as AnyNode['id']] : undefined
|
||||
return parent?.type === 'level'
|
||||
? getCeilingClampBound(parent.id, s.nodes, node?.polygon ?? [])
|
||||
: 6
|
||||
})
|
||||
|
||||
// Effective height: the stored custom height, or — for follows-mode
|
||||
// ceilings (absent `height`) — the live level-top bound. Primitive
|
||||
// selector so it tracks level-height / covering-slab edits.
|
||||
const resolvedHeight = useScene((s) => {
|
||||
const ceiling = selectedId
|
||||
? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
|
||||
: undefined
|
||||
return ceiling?.type === 'ceiling' ? resolveCeilingHeight(ceiling, s.nodes) : 2.5
|
||||
})
|
||||
|
||||
// Panel slider-drag fix recipe (plans/editor-node-registry.md): stable
|
||||
// handler refs so slider drags don't trigger Maximum update depth.
|
||||
const nodeRef = useRef(node)
|
||||
nodeRef.current = node
|
||||
|
||||
const maxHeightRef = useRef(maxHeight)
|
||||
maxHeightRef.current = maxHeight
|
||||
|
||||
const resolvedHeightRef = useRef(resolvedHeight)
|
||||
resolvedHeightRef.current = resolvedHeight
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<CeilingNode>) => {
|
||||
if (!selectedId) return
|
||||
@@ -48,6 +86,31 @@ export function CeilingPanel() {
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(proposed: number) => {
|
||||
handleUpdate({ height: Math.min(proposed, maxHeightRef.current) })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleTopModeChange = useCallback(
|
||||
(mode: 'storey' | 'custom') => {
|
||||
const n = nodeRef.current
|
||||
if (!n) return
|
||||
const isCustom = n.height != null
|
||||
if (mode === 'custom' && !isCustom) {
|
||||
// Seed from the current resolved height so the surface doesn't
|
||||
// jump at the moment of detaching from the level top.
|
||||
handleUpdate({ height: Math.min(resolvedHeightRef.current, maxHeightRef.current) })
|
||||
} else if (mode === 'storey' && isCustom) {
|
||||
// Absent `height` = follows the level top; the store strips
|
||||
// undefined keys.
|
||||
handleUpdate({ height: undefined })
|
||||
}
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
useInteractionScope
|
||||
@@ -156,6 +219,22 @@ export function CeilingPanel() {
|
||||
}
|
||||
|
||||
const area = calculateArea(node.polygon)
|
||||
const isFollows = node.height == null
|
||||
|
||||
// Clean preset values per display system; imperial stores exact meters
|
||||
// for 8'0" / 8'6" / 9'0" ceilings.
|
||||
const heightPresets =
|
||||
unit === 'imperial'
|
||||
? [
|
||||
{ label: 'Low (8\'0")', height: 2.4384 },
|
||||
{ label: 'Standard (8\'6")', height: 2.5908 },
|
||||
{ label: 'High (9\'0")', height: 2.7432 },
|
||||
]
|
||||
: [
|
||||
{ label: 'Low (2.4m)', height: 2.4 },
|
||||
{ label: 'Standard (2.5m)', height: 2.5 },
|
||||
{ label: 'High (3.0m)', height: 3.0 },
|
||||
]
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
@@ -165,21 +244,41 @@ export function CeilingPanel() {
|
||||
width={320}
|
||||
>
|
||||
<PanelSection title="Height">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={6}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.height * 1000) / 1000}
|
||||
<SegmentedControl
|
||||
onChange={handleTopModeChange}
|
||||
options={[
|
||||
{ label: 'Follows level', value: 'storey' },
|
||||
{ label: 'Custom height', value: 'custom' },
|
||||
]}
|
||||
value={isFollows ? 'storey' : 'custom'}
|
||||
/>
|
||||
{isFollows ? (
|
||||
<div className="px-1 text-[11px] text-muted-foreground">
|
||||
Currently {formatLinearMeasurement(resolvedHeight, unit)}
|
||||
</div>
|
||||
) : (
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={Math.min(6, maxHeight)}
|
||||
min={0}
|
||||
onChange={handleHeightChange}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.height ?? resolvedHeight) * 1000) / 1000}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Presets write an explicit height (clamped to the bound), so
|
||||
clicking one on a follows-mode ceiling switches it to custom. */}
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5 px-1 pb-1">
|
||||
<ActionButton label="Low (2.4m)" onClick={() => handleUpdate({ height: 2.4 })} />
|
||||
<ActionButton label="Standard (2.5m)" onClick={() => handleUpdate({ height: 2.5 })} />
|
||||
<ActionButton label="High (3.0m)" onClick={() => handleUpdate({ height: 3.0 })} />
|
||||
{heightPresets.map((preset) => (
|
||||
<ActionButton
|
||||
key={preset.label}
|
||||
label={preset.label}
|
||||
onClick={() => handleHeightChange(preset.height)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
type CeilingNode,
|
||||
getMaterialPresetByRef,
|
||||
resolveCeilingHeight,
|
||||
resolveMaterial,
|
||||
useLiveTransforms,
|
||||
useRegistry,
|
||||
@@ -46,7 +47,11 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
// ceiling slot references re-tints it live.
|
||||
const sceneMaterials = useScene((s) => s.materials)
|
||||
const liveTransform = useLiveTransforms((s) => s.get(node.id))
|
||||
const ceilingY = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0)
|
||||
// Resolved height: explicit when stored, else the live level-top bound
|
||||
// (primitive selector, so follows-mode ceilings track level-height edits
|
||||
// and covering-slab changes without a node write).
|
||||
const resolvedHeight = useScene((s) => resolveCeilingHeight(node, s.nodes))
|
||||
const ceilingY = resolvedHeight - 0.01 + (liveTransform?.position[1] ?? 0)
|
||||
const position: [number, number, number] = [
|
||||
liveTransform?.position[0] ?? 0,
|
||||
ceilingY,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
resolveSupportSlabPatch,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -142,9 +143,16 @@ const ColumnTool = () => {
|
||||
!isGridSnapActive(),
|
||||
)
|
||||
|
||||
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
|
||||
useScene.getState().createNode(column, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [column.id] })
|
||||
const column = ColumnNode.parse({
|
||||
...createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position),
|
||||
parentId: activeLevelId,
|
||||
})
|
||||
const committedColumn = ColumnNode.parse({
|
||||
...column,
|
||||
...resolveSupportSlabPatch(column, useScene.getState().nodes),
|
||||
})
|
||||
useScene.getState().createNode(committedColumn, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [committedColumn.id] })
|
||||
triggerSFX('sfx:structure-build')
|
||||
useAlignmentGuides.getState().clear()
|
||||
usePlacementPreview.getState().clear()
|
||||
@@ -155,7 +163,10 @@ const ColumnTool = () => {
|
||||
cursorVisibleRef.current = false
|
||||
setCursorVisible(false)
|
||||
useFacingPose.getState().clear()
|
||||
// Restore select mode with the tool — `mode: 'build'` with no tool is
|
||||
// a dead state where the selection manager ignores every click.
|
||||
useEditor.getState().setTool(null)
|
||||
useEditor.getState().setMode('select')
|
||||
}
|
||||
stopPlacementCommitPropagation(event)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
|
||||
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
|
||||
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
||||
import { readHostWallCeiling } from '../shared/wall-opening-ceiling'
|
||||
import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides'
|
||||
import { scaleHandleHeight } from './door-math'
|
||||
import { buildDoorFloorplan } from './floorplan'
|
||||
@@ -34,12 +35,6 @@ function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unk
|
||||
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
}
|
||||
|
||||
function readWallHeight(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
||||
if (!door.wallId) return Number.POSITIVE_INFINITY
|
||||
const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined
|
||||
return wall?.height ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
// Width arrow on the door-local +X (right) or -X (left) side. Drag grows
|
||||
// the door from the anchored OPPOSITE edge; the door's wall-local center
|
||||
// re-centers so the anchored edge stays put.
|
||||
@@ -100,7 +95,7 @@ function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
|
||||
const roofMax = readRoofFaceHeightMax(n, scene, 1)
|
||||
if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax)
|
||||
const bottom = n.position[1] - n.height / 2
|
||||
return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom)
|
||||
return Math.max(MIN_DOOR_HEIGHT, readHostWallCeiling(n.wallId, scene) - bottom)
|
||||
},
|
||||
currentValue: (n) => n.height,
|
||||
onDrag: (node) => publishOpeningResizeGuides(node, false),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type GridEvent,
|
||||
getCeilingAt,
|
||||
getCeilingHeightAt,
|
||||
resolveCeilingHeight,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -1132,7 +1133,7 @@ function CeilingHighlight({ ceiling }: { ceiling: CeilingNode }) {
|
||||
return pts
|
||||
}, [ceiling.polygon])
|
||||
if (!geometry) return null
|
||||
const y = ceiling.height ?? 2.5
|
||||
const y = resolveCeilingHeight(ceiling, useScene.getState().nodes)
|
||||
return (
|
||||
<group position={[0, y, 0]}>
|
||||
<mesh geometry={geometry} layers={EDITOR_LAYER} renderOrder={1}>
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
DuctTerminalNode,
|
||||
emitter,
|
||||
pointInPolygon,
|
||||
resolveCeilingHeight,
|
||||
resolveLevelId,
|
||||
resolveSupportSlabPatch,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
@@ -33,8 +36,6 @@ import { COLLAR_LENGTH, mountQuaternion } from './ports'
|
||||
const PREVIEW_OPACITY = 0.55
|
||||
/** R/T yaw step — 45°. */
|
||||
const ROTATE_STEP_RAD = Math.PI / 4
|
||||
/** Fallback height (meters) for a ceiling node that carries no `height`. */
|
||||
const DEFAULT_CEILING_HEIGHT = 2.5
|
||||
/** Snap radius (meters) for mating the collar onto a nearby duct port. */
|
||||
const PORT_SNAP_RADIUS_M = 0.5
|
||||
|
||||
@@ -227,12 +228,8 @@ const DuctTerminalTool = () => {
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node?.type !== 'ceiling') continue
|
||||
if (resolveLevelId(node, nodes) !== activeLevelId) continue
|
||||
const ceiling = node as {
|
||||
height?: number
|
||||
polygon: Array<[number, number]>
|
||||
holes?: Array<Array<[number, number]>>
|
||||
}
|
||||
const height = ceiling.height ?? DEFAULT_CEILING_HEIGHT
|
||||
const ceiling = node as CeilingNode
|
||||
const height = resolveCeilingHeight(ceiling, nodes)
|
||||
const hit = hitLocalPlane(nativeEvent, height)
|
||||
if (!hit) continue
|
||||
if (!pointInPolygon(hit.x, hit.z, ceiling.polygon)) continue
|
||||
@@ -289,9 +286,14 @@ const DuctTerminalTool = () => {
|
||||
mount: p.mount,
|
||||
position: p.position,
|
||||
rotation: p.yaw,
|
||||
parentId: activeLevelId,
|
||||
})
|
||||
useScene.getState().createNode(terminal, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [terminal.id] })
|
||||
const committedTerminal = DuctTerminalNode.parse({
|
||||
...terminal,
|
||||
...resolveSupportSlabPatch(terminal, useScene.getState().nodes),
|
||||
})
|
||||
useScene.getState().createNode(committedTerminal, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [committedTerminal.id] })
|
||||
triggerSFX('sfx:item-place')
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, FenceNode, SlabNode } from '@pascal-app/core'
|
||||
import { resolveFenceLiftElevation } from '../lift'
|
||||
|
||||
const LEVEL_ID = 'level-1'
|
||||
|
||||
function makeDeck(elevation: number, parentId: string | null = LEVEL_ID): SlabNode {
|
||||
return SlabNode.parse({
|
||||
parentId,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 4],
|
||||
[0, 4],
|
||||
],
|
||||
elevation,
|
||||
thickness: 0.05,
|
||||
})
|
||||
}
|
||||
|
||||
function makeRailing(supportSlabId: string | undefined, parentId: string | null = LEVEL_ID) {
|
||||
return FenceNode.parse({
|
||||
parentId,
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
supportSlabId,
|
||||
})
|
||||
}
|
||||
|
||||
function resolverFor(...nodes: AnyNode[]) {
|
||||
const byId = new Map(nodes.map((node) => [node.id as string, node]))
|
||||
return (id: string) => byId.get(id)
|
||||
}
|
||||
|
||||
describe('resolveFenceLiftElevation', () => {
|
||||
test('lifts onto the host slab walking surface', () => {
|
||||
const deck = makeDeck(1.25)
|
||||
const railing = makeRailing(deck.id)
|
||||
expect(resolveFenceLiftElevation(railing, resolverFor(deck))).toBe(1.25)
|
||||
})
|
||||
|
||||
test('unhosted fence stays on the level floor', () => {
|
||||
const railing = makeRailing(undefined)
|
||||
expect(resolveFenceLiftElevation(railing, resolverFor())).toBe(0)
|
||||
})
|
||||
|
||||
test('stale host (slab gone) falls back to the floor', () => {
|
||||
const deck = makeDeck(1.25)
|
||||
const railing = makeRailing(deck.id)
|
||||
expect(resolveFenceLiftElevation(railing, resolverFor())).toBe(0)
|
||||
})
|
||||
|
||||
test('host on another level does not lift the fence', () => {
|
||||
const deck = makeDeck(1.25, 'level-2')
|
||||
const railing = makeRailing(deck.id)
|
||||
expect(resolveFenceLiftElevation(railing, resolverFor(deck))).toBe(0)
|
||||
})
|
||||
|
||||
test('host id resolving to a non-slab node is ignored', () => {
|
||||
const deck = makeDeck(1.25)
|
||||
const impostor = makeRailing(undefined)
|
||||
const railing = makeRailing(impostor.id)
|
||||
expect(resolveFenceLiftElevation(railing, resolverFor(deck, impostor))).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type DragAction,
|
||||
type FenceNode,
|
||||
resolveAlignment,
|
||||
resolveFenceSupportSlabPatch,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -111,6 +112,26 @@ function snapshotLinked(
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-elect the slab lift host for the given fences from their CURRENT
|
||||
* store state (call after the endpoint writes). Only writes when the host
|
||||
* actually changes, so unaffected drags stay patch-free.
|
||||
*/
|
||||
function applyFenceSupportPatches(
|
||||
ids: readonly AnyNodeId[],
|
||||
scene: { update(id: AnyNodeId, data: Partial<AnyNode>): void },
|
||||
) {
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const id of ids) {
|
||||
const fence = nodes[id]
|
||||
if (fence?.type !== 'fence') continue
|
||||
const patch = resolveFenceSupportSlabPatch(fence as FenceNode, nodes)
|
||||
if (patch.supportSlabId !== (fence as FenceNode).supportSlabId) {
|
||||
scene.update(id, patch as Partial<AnyNode>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function linkedCascade(
|
||||
linked: LinkedFenceSnapshot[],
|
||||
origin: FencePlanPoint,
|
||||
@@ -230,6 +251,10 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
)
|
||||
dirty.push(linked.id as AnyNodeId)
|
||||
}
|
||||
// Re-elect the lift host live: a fence dragged onto / off an elevated
|
||||
// deck rises or drops with the pointer instead of waiting for commit
|
||||
// (fences run no per-frame election — `supportSlabId` IS the lift).
|
||||
applyFenceSupportPatches(dirty, scene)
|
||||
return dirty
|
||||
},
|
||||
|
||||
@@ -250,6 +275,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
scene.restoreAll()
|
||||
scene.resumeHistory()
|
||||
scene.update(ctx.fenceId, { start: draft.start, end: draft.end } as Partial<AnyNode>)
|
||||
const patched: AnyNodeId[] = [ctx.fenceId]
|
||||
if (!draft.detached) {
|
||||
for (const linked of draft.linkedUpdates) {
|
||||
scene.update(
|
||||
@@ -259,8 +285,14 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
||||
end: linked.end,
|
||||
} as Partial<AnyNode>,
|
||||
)
|
||||
patched.push(linked.id as AnyNodeId)
|
||||
}
|
||||
}
|
||||
// The restoreAll above reverted any live host patch — re-run the
|
||||
// election against the final endpoints so the committed fence stands
|
||||
// on (or leaves) its deck. Uncapped: an endpoint drag has no commit
|
||||
// pointer ray worth trusting, matching the wall move commits.
|
||||
applyFenceSupportPatches(patched, scene)
|
||||
return true
|
||||
},
|
||||
|
||||
|
||||
@@ -261,8 +261,13 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
||||
|
||||
// Stage B: pure geometry function. Generic <GeometrySystem> rebuilds
|
||||
// on dirtyNodes; <ParametricNodeRenderer> mounts the empty group.
|
||||
// `renderer` + `system` fields dropped along with their files.
|
||||
geometry: buildFenceGeometry,
|
||||
// Dependency tracker only — a hosted railing (`supportSlabId`) renders at
|
||||
// its slab's elevation, so host elevation edits must re-dirty the fence.
|
||||
system: {
|
||||
module: () => import('./system'),
|
||||
priority: 4,
|
||||
},
|
||||
// Stage C: floor-plan rendering. FloorplanRegistryLayer iterates kinds
|
||||
// with `floorplan` set and renders via FloorplanGeometryRenderer.
|
||||
// Legacy `floorplanFenceEntries` short-circuits to [] when fence is
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
normalizeWallCurveOffset,
|
||||
resolveFenceSupportSlabPatch,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
@@ -320,6 +321,21 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
data: { start: u.start, end: u.end },
|
||||
})),
|
||||
])
|
||||
// Re-elect the slab lift host as the endpoint drags (uncapped max
|
||||
// election — 2D has no camera ray). This legacy write path commits
|
||||
// via the dispatcher's snapshot diff, so patching per tick both
|
||||
// previews the lift and lands it in the committed diff. Fences run
|
||||
// no per-frame election: `supportSlabId` IS the lift.
|
||||
const patchedNodes = useScene.getState().nodes
|
||||
const supportPatches = [node.id, ...linkedUpdates.map((u) => u.id)].flatMap((id) => {
|
||||
const fence = patchedNodes[id]
|
||||
if (fence?.type !== 'fence') return []
|
||||
const patch = resolveFenceSupportSlabPatch(fence as FenceNode, patchedNodes)
|
||||
return patch.supportSlabId === (fence as FenceNode).supportSlabId
|
||||
? []
|
||||
: [{ id, data: patch }]
|
||||
})
|
||||
if (supportPatches.length > 0) useScene.getState().updateNodes(supportPatches)
|
||||
},
|
||||
canCommit() {
|
||||
// Pointer-up always runs canCommit — drop the alignment guide here
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type FenceNode,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
resolveFenceSupportSlabPatch,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -179,6 +180,35 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node
|
||||
// tracked change. Drop the override AFTER the scene write so
|
||||
// mid-commit reads still see the new position (override wins until
|
||||
// cleared; scene wins after).
|
||||
// The re-elected slab lift host rides in the same write (uncapped max
|
||||
// election — 2D has no camera ray): a fence moved onto / off an
|
||||
// elevated deck must land on the right surface, since fences run no
|
||||
// per-frame election (`supportSlabId` IS the lift). One updateNodes
|
||||
// keeps the whole move a single tracked change. Election runs on the
|
||||
// committed endpoints against the pre-write store (the patch only
|
||||
// reads the parent level + the slab grid).
|
||||
const baselineNodes = useScene.getState().nodes
|
||||
const supportFor = (
|
||||
start: PlanPoint,
|
||||
end: PlanPoint,
|
||||
path: PlanPoint[] | undefined,
|
||||
id: AnyNodeId,
|
||||
) => {
|
||||
const fence = baselineNodes[id]
|
||||
return fence?.type === 'fence'
|
||||
? resolveFenceSupportSlabPatch(
|
||||
{
|
||||
start,
|
||||
end,
|
||||
path,
|
||||
curveOffset: (fence as FenceNode).curveOffset,
|
||||
thickness: (fence as FenceNode).thickness,
|
||||
parentId: fence.parentId,
|
||||
},
|
||||
baselineNodes,
|
||||
)
|
||||
: {}
|
||||
}
|
||||
const fenceUpdate: { id: AnyNodeId; data: Partial<FenceNode> } = isNew
|
||||
? {
|
||||
id: fenceId,
|
||||
@@ -187,9 +217,18 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node
|
||||
end: lastNextEnd,
|
||||
path: lastNextPath,
|
||||
metadata: { ...originalMetadata, isNew: false },
|
||||
...supportFor(lastNextStart, lastNextEnd, lastNextPath, fenceId),
|
||||
} as Partial<FenceNode>,
|
||||
}
|
||||
: { id: fenceId, data: { start: lastNextStart, end: lastNextEnd, path: lastNextPath } }
|
||||
: {
|
||||
id: fenceId,
|
||||
data: {
|
||||
start: lastNextStart,
|
||||
end: lastNextEnd,
|
||||
path: lastNextPath,
|
||||
...supportFor(lastNextStart, lastNextEnd, lastNextPath, fenceId),
|
||||
},
|
||||
}
|
||||
const linkedUpdates = linkedOriginals.map((l) => ({
|
||||
id: l.id,
|
||||
...projectLinked(l, lastNextStart, lastNextEnd, lastDelta[0], lastDelta[1]),
|
||||
@@ -198,7 +237,12 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node
|
||||
fenceUpdate,
|
||||
...linkedUpdates.map((u) => ({
|
||||
id: u.id,
|
||||
data: { start: u.start, end: u.end, path: u.path },
|
||||
data: {
|
||||
start: u.start,
|
||||
end: u.end,
|
||||
path: u.path,
|
||||
...supportFor(u.start, u.end, u.path, u.id),
|
||||
},
|
||||
})),
|
||||
])
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core'
|
||||
import { type AnyNodeId, type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core'
|
||||
import {
|
||||
applyMaterialPresetToMaterials,
|
||||
type ColorPreset,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveSlotDefaultMaterial,
|
||||
} from '@pascal-app/viewer'
|
||||
import { FrontSide, Group, type Material, Mesh, type Texture } from 'three'
|
||||
import { resolveFenceLiftElevation } from './lift'
|
||||
import type { FenceNode } from './schema'
|
||||
import { FENCE_SLOT_DEFAULTS, type FenceSlotId } from './slots'
|
||||
|
||||
@@ -110,6 +111,14 @@ export function buildFenceGeometry(
|
||||
const group = new Group()
|
||||
const geometries = generateFenceSlotGeometries(node)
|
||||
|
||||
// A hosted railing (`supportSlabId`) stands on its slab's walking surface.
|
||||
// The builder emits local-space children, so the lift lives on an inner
|
||||
// group rather than the registered (React-transformed) root.
|
||||
const lift = ctx ? resolveFenceLiftElevation(node, (id) => ctx.resolve(id as AnyNodeId)) : 0
|
||||
const meshParent = new Group()
|
||||
meshParent.position.y = lift
|
||||
group.add(meshParent)
|
||||
|
||||
for (const slotId of FENCE_SLOT_ORDER) {
|
||||
const geometry = geometries[slotId]
|
||||
if (geometry.getAttribute('position') === undefined) continue
|
||||
@@ -126,7 +135,7 @@ export function buildFenceGeometry(
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
mesh.userData.slotId = slotId
|
||||
group.add(mesh)
|
||||
meshParent.add(mesh)
|
||||
}
|
||||
|
||||
return group
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AnyNode, SlabNode } from '@pascal-app/core'
|
||||
import type { FenceNode } from './schema'
|
||||
|
||||
/**
|
||||
* Elevation (meters above the level plane) a hosted fence stands at.
|
||||
*
|
||||
* A fence carrying `supportSlabId` is a railing on that slab's walking
|
||||
* surface (drawn onto a deck, or placed by a deck preset). The
|
||||
* host pins the lift only while it still exists as a slab on the fence's
|
||||
* own level — a stale host (deleted slab, reparented fence) silently
|
||||
* falls back to the level floor, mirroring the read-path rules of the
|
||||
* other `supportSlabId` carriers. Pure so it is unit-testable and
|
||||
* callable from the geometry builder with `ctx.resolve`.
|
||||
*/
|
||||
export function resolveFenceLiftElevation(
|
||||
node: Pick<FenceNode, 'supportSlabId' | 'parentId'>,
|
||||
resolve: (id: string) => AnyNode | undefined,
|
||||
): number {
|
||||
if (!node.supportSlabId) return 0
|
||||
const host = resolve(node.supportSlabId)
|
||||
if (host?.type !== 'slab') return 0
|
||||
if ((host.parentId ?? null) !== (node.parentId ?? null)) return 0
|
||||
const elevation = (host as SlabNode).elevation
|
||||
return Number.isFinite(elevation) ? elevation : 0
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isCurvedWall,
|
||||
isSplineFence,
|
||||
type LevelNode,
|
||||
resolveFenceSupportSlabPatch,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallMoveAxis,
|
||||
@@ -224,11 +225,35 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
path?: [number, number][]
|
||||
}>,
|
||||
) => {
|
||||
// Fold the re-elected slab lift host into the same write — a fence
|
||||
// moved onto / off an elevated deck must land on the right surface
|
||||
// (fences run no per-frame election; `supportSlabId` IS the lift),
|
||||
// and one updateNodes keeps the whole move a single undo step.
|
||||
// Election runs on the committed endpoints against the pre-write
|
||||
// store (the patch only reads the parent level + the slab grid).
|
||||
const baselineNodes = useScene.getState().nodes
|
||||
useScene.getState().updateNodes(
|
||||
updates.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: { start: entry.start, end: entry.end, path: entry.path },
|
||||
})),
|
||||
updates.map((entry) => {
|
||||
const fence = baselineNodes[entry.id as AnyNodeId]
|
||||
const support =
|
||||
fence?.type === 'fence'
|
||||
? resolveFenceSupportSlabPatch(
|
||||
{
|
||||
start: entry.start,
|
||||
end: entry.end,
|
||||
path: entry.path,
|
||||
curveOffset: (fence as FenceNode).curveOffset,
|
||||
thickness: (fence as FenceNode).thickness,
|
||||
parentId: fence.parentId,
|
||||
},
|
||||
baselineNodes,
|
||||
)
|
||||
: {}
|
||||
return {
|
||||
id: entry.id as AnyNodeId,
|
||||
data: { start: entry.start, end: entry.end, path: entry.path, ...support },
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const entry of updates) {
|
||||
useScene.getState().markDirty(entry.id as AnyNodeId)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { useEffect } from 'react'
|
||||
import { resolveFenceLiftElevation } from './lift'
|
||||
import type { FenceNode } from './schema'
|
||||
|
||||
/**
|
||||
* Hosted-railing dependency tracker. A fence with `supportSlabId` renders at
|
||||
* its host slab's elevation, but a store update only dirties the node that
|
||||
* changed — editing the slab's elevation (or restoring a deleted host) would
|
||||
* leave the railing floating at the stale height. Watch each hosted fence's
|
||||
* resolved lift and dirty the fence when it moves; `GeometrySystem` rebuilds
|
||||
* through `def.geometry` as usual. (Deleting the host needs no help here:
|
||||
* `deleteNodesAction` strips `supportSlabId` and dirties the fence itself.)
|
||||
*/
|
||||
|
||||
function fenceLiftSignatures(nodes: Record<string, AnyNode>): Map<string, number> {
|
||||
const signatures = new Map<string, number>()
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'fence') continue
|
||||
const fence = node as FenceNode
|
||||
if (!fence.supportSlabId) continue
|
||||
signatures.set(
|
||||
fence.id,
|
||||
resolveFenceLiftElevation(fence, (id) => nodes[id]),
|
||||
)
|
||||
}
|
||||
return signatures
|
||||
}
|
||||
|
||||
const FenceSystems = () => {
|
||||
useEffect(() => {
|
||||
let previous = fenceLiftSignatures(useScene.getState().nodes)
|
||||
|
||||
return useScene.subscribe((state) => {
|
||||
const current = fenceLiftSignatures(state.nodes)
|
||||
for (const [fenceId, lift] of current.entries()) {
|
||||
if (previous.get(fenceId) !== lift) {
|
||||
state.markDirty(fenceId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
previous = current
|
||||
})
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default FenceSystems
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
clearPlacementSurface,
|
||||
createFenceOnCurrentLevel,
|
||||
createSplineFenceOnCurrentLevel,
|
||||
EDITOR_LAYER,
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
markToolCancelConsumed,
|
||||
publishPlacementSurface,
|
||||
resolvePointerSupportSurface,
|
||||
type SegmentAngleReference,
|
||||
snapFenceDraftPoint,
|
||||
snapScalarToGrid,
|
||||
@@ -46,11 +49,34 @@ import {
|
||||
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three'
|
||||
import {
|
||||
BoxGeometry,
|
||||
BufferGeometry,
|
||||
type Camera,
|
||||
DoubleSide,
|
||||
type Group,
|
||||
type Mesh,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
|
||||
const FENCE_PREVIEW_HEIGHT = 1.8
|
||||
const FENCE_PREVIEW_THICKNESS = 0.08
|
||||
// Grid-plane surface publish (pointer-decided): scratch + constant normal so
|
||||
// per-move publishes don't allocate.
|
||||
const SURFACE_UP = new Vector3(0, 1, 0)
|
||||
const surfacePointScratch = new Vector3()
|
||||
|
||||
// The walking surface the pointer actually aims at (deck top when over the
|
||||
// deck, floor/ground underneath it) — only for genuine 3D pointer events.
|
||||
// The 2D floor plan emits synthetic grid events with no camera ray behind
|
||||
// them; those keep the uncapped max election and leave the grid plane alone.
|
||||
function pointedSurfaceFor(camera: Camera, event: GridEvent) {
|
||||
return event.nativeEvent?.target instanceof HTMLCanvasElement
|
||||
? resolvePointerSupportSurface(camera, event.position)
|
||||
: null
|
||||
}
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
// HUD label heights are measured from the top of the preview bar, so they
|
||||
@@ -460,6 +486,11 @@ const StraightFenceTool: React.FC = () => {
|
||||
previewHeightRef.current = previewHeight
|
||||
const previewThicknessRef = useRef(previewThickness)
|
||||
previewThicknessRef.current = previewThickness
|
||||
// Camera for the pointer-support resolution (deck top vs floor) — read
|
||||
// through a ref so the live event handlers see the current camera.
|
||||
const camera = useThree((state) => state.camera)
|
||||
const cameraRef = useRef(camera)
|
||||
cameraRef.current = camera
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const previewRef = useRef<Mesh>(null!)
|
||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||
@@ -521,6 +552,18 @@ const StraightFenceTool: React.FC = () => {
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!(cursorRef.current && previewRef.current)) return
|
||||
// Ride the grid event plane on the pointed surface: aiming at an
|
||||
// elevated deck lifts the plane to the deck top, so the draft's XZ
|
||||
// lands where the cursor points and the preview/cursor Y
|
||||
// (`event.localPosition[1]`) sits at the lift the committed fence
|
||||
// will get. Aiming past the deck edge drops it back to the floor.
|
||||
const pointed = pointedSurfaceFor(cameraRef.current, event)
|
||||
if (pointed) {
|
||||
publishPlacementSurface(
|
||||
surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]),
|
||||
SURFACE_UP,
|
||||
)
|
||||
}
|
||||
const { walls, fences } = getCurrentLevelElements()
|
||||
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
// While drafting, the segment locks to 15° rays from its start.
|
||||
@@ -630,9 +673,11 @@ const StraightFenceTool: React.FC = () => {
|
||||
const dx = snappedEnd[0] - startingPoint.current.x
|
||||
const dz = snappedEnd[1] - startingPoint.current.z
|
||||
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
||||
const pointed = pointedSurfaceFor(cameraRef.current, event)
|
||||
const createdFence = createFenceOnCurrentLevel(
|
||||
[startingPoint.current.x, startingPoint.current.z],
|
||||
snappedEnd,
|
||||
{ supportCap: pointed ? pointed.elevation : null },
|
||||
)
|
||||
if (!createdFence) return
|
||||
|
||||
@@ -681,6 +726,7 @@ const StraightFenceTool: React.FC = () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
clearPlacementSurface()
|
||||
useSegmentDraftChain.getState().clear('fence')
|
||||
useAlignmentGuides.getState().clear()
|
||||
const draftPreview = useFloorplanDraftPreview.getState()
|
||||
@@ -738,6 +784,16 @@ const SplineFenceDraft: React.FC = () => {
|
||||
: FENCE_PREVIEW_HEIGHT
|
||||
const [draftPoints, setDraftPoints] = useState<FencePlanPoint[]>([])
|
||||
const [cursor, setCursor] = useState<FencePlanPoint | null>(null)
|
||||
// Building-local Y of the grid plane (rides the pointed surface — see
|
||||
// `pointedSurfaceFor`), so the spline preview draws on the deck top when
|
||||
// the curve is being laid out on one.
|
||||
const [liftY, setLiftY] = useState(0)
|
||||
const camera = useThree((state) => state.camera)
|
||||
const cameraRef = useRef(camera)
|
||||
cameraRef.current = camera
|
||||
// Pointer cap for the commit (Enter / double-click carry no useful grid
|
||||
// event of their own) — last resolved on move/click.
|
||||
const supportCapRef = useRef<number | null>(null)
|
||||
const draftRef = useRef(draftPoints)
|
||||
|
||||
draftRef.current = draftPoints
|
||||
@@ -761,7 +817,9 @@ const SplineFenceDraft: React.FC = () => {
|
||||
const commit = () => {
|
||||
const points = draftRef.current
|
||||
if (points.length >= 2) {
|
||||
const created = createSplineFenceOnCurrentLevel(points)
|
||||
const created = createSplineFenceOnCurrentLevel(points, undefined, {
|
||||
supportCap: supportCapRef.current,
|
||||
})
|
||||
if (created) {
|
||||
triggerSFX('sfx:item-place')
|
||||
// Once the new curve fence is selected for direct editing, leave
|
||||
@@ -775,11 +833,24 @@ const SplineFenceDraft: React.FC = () => {
|
||||
setCursor(null)
|
||||
}
|
||||
|
||||
const trackPointedSurface = (event: GridEvent) => {
|
||||
const pointed = pointedSurfaceFor(cameraRef.current, event)
|
||||
if (!pointed) return
|
||||
supportCapRef.current = pointed.elevation
|
||||
publishPlacementSurface(
|
||||
surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]),
|
||||
SURFACE_UP,
|
||||
)
|
||||
setLiftY(event.localPosition[1])
|
||||
}
|
||||
|
||||
const onMove = (event: GridEvent) => {
|
||||
trackPointedSurface(event)
|
||||
setCursor(snapPoint([event.localPosition[0], event.localPosition[2]]))
|
||||
}
|
||||
|
||||
const onClick = (event: GridEvent) => {
|
||||
trackPointedSurface(event)
|
||||
if (event.nativeEvent.detail >= 2) {
|
||||
commit()
|
||||
return
|
||||
@@ -807,6 +878,7 @@ const SplineFenceDraft: React.FC = () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
emitter.off('grid:click', onClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
clearPlacementSurface()
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [])
|
||||
@@ -820,18 +892,18 @@ const SplineFenceDraft: React.FC = () => {
|
||||
SPLINE_PREVIEW_SEGMENTS,
|
||||
)
|
||||
return new BufferGeometry().setFromPoints(
|
||||
sampled.map((point) => new Vector3(point.x, previewHeight, point.y)),
|
||||
sampled.map((point) => new Vector3(point.x, liftY + previewHeight, point.y)),
|
||||
)
|
||||
}, [previewHeight, previewPoints])
|
||||
}, [liftY, previewHeight, previewPoints])
|
||||
|
||||
return (
|
||||
<group>
|
||||
{cursor && <CursorSphere height={previewHeight} position={[cursor[0], 0, cursor[1]]} />}
|
||||
{cursor && <CursorSphere height={previewHeight} position={[cursor[0], liftY, cursor[1]]} />}
|
||||
{draftPoints.map((point, index) => (
|
||||
<mesh
|
||||
key={`fence-spline-pt-${index}`}
|
||||
layers={EDITOR_LAYER}
|
||||
position={[point[0], previewHeight, point[1]]}
|
||||
position={[point[0], liftY + previewHeight, point[1]]}
|
||||
>
|
||||
<sphereGeometry args={[0.07, 16, 12]} />
|
||||
<meshBasicMaterial color={SPLINE_PREVIEW_COLOR} depthTest={false} />
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, HvacEquipmentNode, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
emitter,
|
||||
type GridEvent,
|
||||
HvacEquipmentNode,
|
||||
resolveSupportSlabPatch,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
@@ -76,9 +82,14 @@ const HvacEquipmentTool = () => {
|
||||
name: 'Furnace',
|
||||
position,
|
||||
rotation: yawRef.current,
|
||||
parentId: activeLevelId,
|
||||
})
|
||||
useScene.getState().createNode(unit, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [unit.id] })
|
||||
const committedUnit = HvacEquipmentNode.parse({
|
||||
...unit,
|
||||
...resolveSupportSlabPatch(unit, useScene.getState().nodes),
|
||||
})
|
||||
useScene.getState().createNode(committedUnit, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [committedUnit.id] })
|
||||
triggerSFX('sfx:item-place')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { LevelNode as LevelNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import {
|
||||
DEFAULT_LEVEL_HEIGHT,
|
||||
LevelNode as LevelNodeSchema,
|
||||
type NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { levelParametrics } from './parametrics'
|
||||
import { LevelNode } from './schema'
|
||||
|
||||
@@ -14,7 +18,11 @@ export const levelDefinition: NodeDefinition<typeof LevelNode> = {
|
||||
category: 'site',
|
||||
|
||||
defaults: () => {
|
||||
const stub = LevelNodeSchema.parse({ id: 'level_default' as never, type: 'level' })
|
||||
const stub = LevelNodeSchema.parse({
|
||||
id: 'level_default' as never,
|
||||
type: 'level',
|
||||
height: DEFAULT_LEVEL_HEIGHT,
|
||||
})
|
||||
const { id: _id, type: _type, ...rest } = stub
|
||||
return rest
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
resolveAlignment,
|
||||
resolveSupportSlabPatch,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
sceneRegistry,
|
||||
@@ -404,23 +405,38 @@ export const MoveRoofTool: React.FC<{
|
||||
useAlignmentGuides.getState().clear()
|
||||
wasCommitted = true
|
||||
|
||||
const position: [number, number, number] = [localX, movingNode.position[1], localZ]
|
||||
const effectiveNode = {
|
||||
...movingNode,
|
||||
position,
|
||||
rotation: pendingRotation,
|
||||
} as typeof movingNode
|
||||
const supportPatch = isFloorPlaced
|
||||
? resolveSupportSlabPatch(effectiveNode, {
|
||||
...useScene.getState().nodes,
|
||||
[movingNode.id]: effectiveNode,
|
||||
})
|
||||
: {}
|
||||
|
||||
let committedId = movingNode.id as AnyNodeId
|
||||
if (isNew) {
|
||||
committedId =
|
||||
commitFreshPlacementSubtree(movingNode.id as AnyNodeId, {
|
||||
position: [localX, movingNode.position[1], localZ],
|
||||
position,
|
||||
rotation: pendingRotation,
|
||||
metadata: committedMeta,
|
||||
visible: true,
|
||||
...supportPatch,
|
||||
}) ?? committedId
|
||||
} else {
|
||||
// The store still holds the original values (we didn't update during drag).
|
||||
// Resume temporal and apply the final state as a single undoable step.
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(movingNode.id, {
|
||||
position: [localX, movingNode.position[1], localZ],
|
||||
position,
|
||||
rotation: pendingRotation,
|
||||
metadata: committedMeta,
|
||||
...supportPatch,
|
||||
})
|
||||
useScene.temporal.getState().pause()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor'
|
||||
import { resolveWallOpeningCeiling } from './wall-opening-ceiling'
|
||||
|
||||
// Parity with `snapLocalXToNeighbors`' along-wall threshold.
|
||||
const SILL_SNAP_THRESHOLD_M = 0.08
|
||||
@@ -94,7 +95,7 @@ export function publishOpeningGuides3D(args: {
|
||||
}): void {
|
||||
const { wall, centerS, centerY, width, toWorld } = args
|
||||
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
const wallHeight = wall.height ?? 2.5
|
||||
const wallHeight = resolveWallOpeningCeiling(wall, args.nodes)
|
||||
const siblings = collectOpeningSiblings(wall, args.movingId, args.nodes)
|
||||
const guides = computeOpeningGuides({
|
||||
moving: { id: args.movingId, centerS, width, centerY, height: args.height },
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
type GeometryContext,
|
||||
isCurvedWall,
|
||||
type OpeningSpan,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { resolveWallOpeningCeiling } from './wall-opening-ceiling'
|
||||
|
||||
/**
|
||||
* Build placement-measurement dimension lines for a door / window
|
||||
@@ -94,7 +96,10 @@ export function buildOpeningPlacementDimensions(
|
||||
height: opening.height,
|
||||
},
|
||||
siblings,
|
||||
wall: { length: wallLength, height: wall.height ?? 2.5 },
|
||||
wall: {
|
||||
length: wallLength,
|
||||
height: resolveWallOpeningCeiling(wall, useScene.getState().nodes),
|
||||
},
|
||||
// The 2D plan is top-down: sill/head height and vertical alignment aren't
|
||||
// representable here — those belong to the 3D viewport.
|
||||
includeVertical: false,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getWallPlaneTop,
|
||||
resolveWallEffectiveHeight,
|
||||
spatialGridManager,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Structural subset of `SceneApi` the opening-cap readers need — matches
|
||||
* both handle-descriptor callbacks (which receive the full SceneApi) and
|
||||
* tools holding a nodes snapshot.
|
||||
*/
|
||||
export type WallCeilingSceneReader = {
|
||||
get: (id: AnyNodeId) => unknown
|
||||
nodes: () => Readonly<Record<AnyNodeId, AnyNode>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Available wall-local Y span for an opening hosted on `wall`: the wall's
|
||||
* resolved top (storey plane for plane-bound walls, stored height for
|
||||
* explicit ones) minus the wall's elected slab base. Wall-local Y = 0 sits
|
||||
* at the elected base (where the viewer positions the wall mesh), so this
|
||||
* is the ceiling an opening's top edge must stay under.
|
||||
*
|
||||
* Uses the same slab election as the viewer's WallSystem
|
||||
* (`spatialGridManager.getSlabSupportForWall`) so the cap agrees with the
|
||||
* rendered wall; headless callers with an empty spatial grid elect base 0
|
||||
* and fall back to the full storey height.
|
||||
*/
|
||||
export function resolveWallOpeningCeiling(
|
||||
wall: WallNode,
|
||||
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
): number {
|
||||
const levelId = wall.parentId ?? 'default'
|
||||
const support = spatialGridManager.getSlabSupportForWall(
|
||||
levelId,
|
||||
wall.start,
|
||||
wall.end,
|
||||
wall.curveOffset ?? 0,
|
||||
wall.thickness,
|
||||
wall.supportSlabId,
|
||||
)
|
||||
// Covering-clamped plane: openings cap under a flush/thick slab from the
|
||||
// level above, matching the shortened wall body.
|
||||
const planeTop = getWallPlaneTop(wall, levelId, nodes as Record<AnyNodeId, AnyNode>)
|
||||
return resolveWallEffectiveHeight(wall, planeTop, support.elevation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Height cap for a wall-hosted opening's resize handles. Infinity only when
|
||||
* the opening is unhosted (no wallId, or the wall is gone) — roof-hosted
|
||||
* openings clamp elsewhere.
|
||||
*/
|
||||
export function readHostWallCeiling(
|
||||
wallId: string | null | undefined,
|
||||
scene: WallCeilingSceneReader,
|
||||
): number {
|
||||
if (!wallId) return Number.POSITIVE_INFINITY
|
||||
const wall = scene.get(wallId as AnyNodeId) as WallNode | undefined
|
||||
if (!wall) return Number.POSITIVE_INFINITY
|
||||
return resolveWallOpeningCeiling(wall, scene.nodes())
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
resolveSupportSlabPatch,
|
||||
ShelfNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -132,9 +133,14 @@ const ShelfTool = () => {
|
||||
name: 'Shelf',
|
||||
position,
|
||||
rotation: [0, 0, 0],
|
||||
parentId: activeLevelId,
|
||||
})
|
||||
useScene.getState().createNode(shelf, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [shelf.id] })
|
||||
const committedShelf = ShelfNode.parse({
|
||||
...shelf,
|
||||
...resolveSupportSlabPatch(shelf, useScene.getState().nodes),
|
||||
})
|
||||
useScene.getState().createNode(committedShelf, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [committedShelf.id] })
|
||||
triggerSFX('sfx:item-place')
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (useEditor.getState().getContinuation('point') === 'repeat') {
|
||||
|
||||
@@ -9,6 +9,7 @@ describe('getRecessedSlabGroundHoles', () => {
|
||||
id: 'slab_ground-holes',
|
||||
parentId,
|
||||
elevation: -0.15,
|
||||
recessed: true,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
@@ -51,4 +52,21 @@ describe('getRecessedSlabGroundHoles', () => {
|
||||
|
||||
expect(getRecessedSlabGroundHoles({ [slab.id]: slab })).toEqual([])
|
||||
})
|
||||
|
||||
test('keys on the recessed flag, not the elevation sign', () => {
|
||||
// A below-plane SOLID (deck underside) must not punch a ground hole.
|
||||
const slab = SlabNode.parse({
|
||||
id: 'slab_ground-holes-below-plane',
|
||||
elevation: -0.15,
|
||||
thickness: 0.3,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
|
||||
expect(getRecessedSlabGroundHoles({ [slab.id]: slab })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,10 +36,7 @@ export function getRecessedSlabGroundHoles(
|
||||
return nodeList
|
||||
.filter(
|
||||
(node): node is SlabNode =>
|
||||
node.type === 'slab' &&
|
||||
node.visible &&
|
||||
node.polygon.length >= 3 &&
|
||||
(node.elevation ?? 0.05) < 0,
|
||||
node.type === 'slab' && node.visible && node.polygon.length >= 3 && node.recessed === true,
|
||||
)
|
||||
.filter((slab) => {
|
||||
if (!Number.isFinite(lowestLevelIndex)) return true
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('slabDefinition handles', () => {
|
||||
expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false)
|
||||
})
|
||||
|
||||
test('allows the elevation arrow to cross zero into a recessed slab', () => {
|
||||
test('routes the elevation arrow through adaptive slab top changes', () => {
|
||||
const slab = SlabNode.parse({
|
||||
elevation: 0.05,
|
||||
polygon: [
|
||||
@@ -58,6 +58,24 @@ describe('slabDefinition handles', () => {
|
||||
const heightHandle = getHeightHandle(slab)
|
||||
|
||||
expect(heightHandle.min).toBe(-1)
|
||||
expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({ elevation: -0.15 })
|
||||
// Crossing zero flips the recessed intent in the same patch; coming back
|
||||
// above the plane clears it.
|
||||
expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({
|
||||
elevation: -0.15,
|
||||
recessed: true,
|
||||
})
|
||||
expect(heightHandle.apply(slab, 0.1, {} as never)).toEqual({
|
||||
elevation: 0.1,
|
||||
thickness: 0.1,
|
||||
recessed: false,
|
||||
})
|
||||
// The arrow is the drag surface: past SLAB_UNSTICK_THRESHOLD a
|
||||
// grounded slab pops to the default deck thickness instead of
|
||||
// stretching further.
|
||||
expect(heightHandle.apply(slab, 0.6, {} as never)).toEqual({
|
||||
elevation: 0.6,
|
||||
thickness: 0.05,
|
||||
recessed: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { SlabNode } from '@pascal-app/core'
|
||||
import {
|
||||
applySlabElevationPreset,
|
||||
applySlabTopChange,
|
||||
SLAB_UNSTICK_THRESHOLD,
|
||||
} from '../elevation-limit'
|
||||
|
||||
function slab(overrides: Partial<SlabNode> = {}): SlabNode {
|
||||
return SlabNode.parse({ polygon: [], ...overrides })
|
||||
}
|
||||
|
||||
const drag = (node: SlabNode, newTop: number) => applySlabTopChange(node, newTop, { mode: 'drag' })
|
||||
const panel = (node: SlabNode, newTop: number) =>
|
||||
applySlabTopChange(node, newTop, { mode: 'panel' })
|
||||
|
||||
describe('applySlabTopChange — drag (viewport arrow)', () => {
|
||||
test('stretches a grounded slab up to the unstick threshold', () => {
|
||||
const grounded = slab({ elevation: 0.1, thickness: 0.1 })
|
||||
|
||||
expect(drag(grounded, 0.25)).toEqual({
|
||||
elevation: 0.25,
|
||||
thickness: 0.25,
|
||||
recessed: false,
|
||||
})
|
||||
expect(drag(grounded, 0.04)).toEqual({
|
||||
elevation: 0.04,
|
||||
thickness: 0.04,
|
||||
recessed: false,
|
||||
})
|
||||
// The threshold itself still stretches — unstick starts strictly past it.
|
||||
expect(drag(grounded, SLAB_UNSTICK_THRESHOLD)).toEqual({
|
||||
elevation: SLAB_UNSTICK_THRESHOLD,
|
||||
thickness: SLAB_UNSTICK_THRESHOLD,
|
||||
recessed: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('unsticks past the threshold: pops to the default deck thickness', () => {
|
||||
const grounded = slab({ elevation: 0.1, thickness: 0.1 })
|
||||
|
||||
expect(drag(grounded, 0.55)).toEqual({
|
||||
elevation: 0.55,
|
||||
thickness: 0.05,
|
||||
recessed: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('crosses a grounded slab into a pool and back out', () => {
|
||||
const grounded = slab({ elevation: 0.1, thickness: 0.1 })
|
||||
const intoPool = drag(grounded, -0.15)
|
||||
|
||||
expect(intoPool).toEqual({ elevation: -0.15, recessed: true })
|
||||
|
||||
const pool = { ...grounded, ...intoPool }
|
||||
expect(drag(pool, 0.08)).toEqual({
|
||||
elevation: 0.08,
|
||||
recessed: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('moves a floating deck and clamps its underside to ground', () => {
|
||||
const floating = slab({ elevation: 0.5, thickness: 0.2 })
|
||||
|
||||
expect(drag(floating, 0.4)).toEqual({
|
||||
elevation: 0.4,
|
||||
recessed: false,
|
||||
})
|
||||
|
||||
const landedChange = drag(floating, 0.1)
|
||||
expect(landedChange).toEqual({ elevation: 0.2, recessed: false })
|
||||
|
||||
// Landed (underside 0) → grounded again: below the threshold the way
|
||||
// back up stretches, past it the slab unsticks to the default deck.
|
||||
const landed = { ...floating, ...landedChange }
|
||||
expect(drag(landed, 0.3)).toEqual({
|
||||
elevation: 0.3,
|
||||
thickness: 0.3,
|
||||
recessed: false,
|
||||
})
|
||||
expect(drag(landed, 0.5)).toEqual({
|
||||
elevation: 0.5,
|
||||
thickness: 0.05,
|
||||
recessed: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps a recessed pool thickness unchanged', () => {
|
||||
const pool = slab({ elevation: -0.15, thickness: 0.08, recessed: true })
|
||||
|
||||
expect(drag(pool, -0.3)).toEqual({
|
||||
elevation: -0.3,
|
||||
recessed: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('allows a grounded stretch below the edit-time minimum thickness', () => {
|
||||
const grounded = slab({ elevation: 0.01, thickness: 0.01 })
|
||||
|
||||
expect(drag(grounded, 0.015)).toEqual({
|
||||
elevation: 0.015,
|
||||
thickness: 0.015,
|
||||
recessed: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('applySlabTopChange — panel (pure placement)', () => {
|
||||
test('moves a grounded slab without coupling thickness', () => {
|
||||
// The panel never stretches: raising a grounded slab lifts the body
|
||||
// (thickness preserved by omission) instead of thickening it.
|
||||
const grounded = slab({ elevation: 0.1, thickness: 0.1 })
|
||||
|
||||
expect(panel(grounded, 0.3)).toEqual({ elevation: 0.3, recessed: false })
|
||||
expect(panel(grounded, 0.55)).toEqual({ elevation: 0.55, recessed: false })
|
||||
})
|
||||
|
||||
test('clamps a grounded slab at underside 0 instead of shrinking it', () => {
|
||||
const grounded = slab({ elevation: 0.2, thickness: 0.2 })
|
||||
|
||||
expect(panel(grounded, 0.1)).toEqual({ elevation: 0.2, recessed: false })
|
||||
})
|
||||
|
||||
test('moves a floating deck preserving thickness and clamps its underside', () => {
|
||||
const floating = slab({ elevation: 0.5, thickness: 0.2 })
|
||||
|
||||
expect(panel(floating, 0.4)).toEqual({ elevation: 0.4, recessed: false })
|
||||
expect(panel(floating, 0.1)).toEqual({ elevation: 0.2, recessed: false })
|
||||
})
|
||||
|
||||
test('keeps the pool cross-zero gesture', () => {
|
||||
const grounded = slab({ elevation: 0.1, thickness: 0.1 })
|
||||
const intoPool = panel(grounded, -0.15)
|
||||
|
||||
expect(intoPool).toEqual({ elevation: -0.15, recessed: true })
|
||||
|
||||
const pool = { ...grounded, ...intoPool }
|
||||
expect(panel(pool, -0.3)).toEqual({ elevation: -0.3, recessed: true })
|
||||
expect(panel(pool, 0.08)).toEqual({ elevation: 0.08, recessed: false })
|
||||
})
|
||||
})
|
||||
|
||||
test('slab elevation presets keep their explicit writes', () => {
|
||||
expect(applySlabElevationPreset(-0.15)).toEqual({ elevation: -0.15, recessed: true })
|
||||
expect(applySlabElevationPreset(0)).toEqual({
|
||||
elevation: 0,
|
||||
thickness: 0,
|
||||
recessed: false,
|
||||
})
|
||||
expect(applySlabElevationPreset(0.05)).toEqual({
|
||||
elevation: 0.05,
|
||||
thickness: 0.05,
|
||||
recessed: false,
|
||||
})
|
||||
expect(applySlabElevationPreset(0.15)).toEqual({
|
||||
elevation: 0.15,
|
||||
thickness: 0.15,
|
||||
recessed: false,
|
||||
})
|
||||
})
|
||||
@@ -28,4 +28,31 @@ describe('buildSlabGeometry', () => {
|
||||
expect(Array.from(uv2.array)).toEqual(Array.from(uv.array))
|
||||
}
|
||||
})
|
||||
|
||||
test('solid slab meshes stay at the level plane; recessed meshes sink to the elevation', () => {
|
||||
const polygon: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
]
|
||||
|
||||
const solid = SlabNode.parse({ elevation: 0.3, thickness: 0.1, polygon })
|
||||
const solidGroup = buildSlabGeometry(solid, undefined, 'solid', false)
|
||||
for (const mesh of solidGroup.children.filter(
|
||||
(child): child is Mesh => child instanceof Mesh,
|
||||
)) {
|
||||
expect(mesh.position.y).toBe(0)
|
||||
}
|
||||
|
||||
const recessed = SlabNode.parse({ elevation: -0.2, recessed: true, polygon })
|
||||
const recessedGroup = buildSlabGeometry(recessed, undefined, 'solid', false)
|
||||
const recessedMeshes = recessedGroup.children.filter(
|
||||
(child): child is Mesh => child instanceof Mesh,
|
||||
)
|
||||
expect(recessedMeshes.length).toBeGreaterThan(0)
|
||||
for (const mesh of recessedMeshes) {
|
||||
expect(mesh.position.y).toBeCloseTo(-0.2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type SlabNode as SlabNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
|
||||
import { applySlabTopChange, slabElevationUpperBound } from './elevation-limit'
|
||||
import { buildSlabFloorplan } from './floorplan'
|
||||
import {
|
||||
slabAddVertexAffordance,
|
||||
@@ -91,19 +92,23 @@ function slabHandleAnchor(slab: SlabNodeType): [number, number] {
|
||||
return best ?? fallback
|
||||
}
|
||||
|
||||
// Slab height arrow — vertical chevron on solid slab surface near the
|
||||
// polygon center. Drags elevation through zero: positive values extrude
|
||||
// upward from ground while negative values create a recessed floor whose
|
||||
// depth follows the pointer. Same registry-handle pipeline as the column
|
||||
// height arrow, so live override + commit-on-release come for free.
|
||||
// Slab elevation arrow — vertical chevron on solid slab surface near the
|
||||
// polygon center. The shared top-change policy stretches grounded slabs up
|
||||
// to SLAB_UNSTICK_THRESHOLD (past it the slab pops to a thin floating
|
||||
// deck), moves floating slabs, and preserves the drag-through-zero pool
|
||||
// gesture. Same registry-handle pipeline as the column height arrow, so
|
||||
// live override + commit-on-release come for free. `max` clamps the drag
|
||||
// under the storey plane while plane-bound walls elect this slab as their
|
||||
// base.
|
||||
function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_SLAB_ELEVATION,
|
||||
max: (n, sceneApi) => slabElevationUpperBound(sceneApi.nodes(), n),
|
||||
currentValue: (n) => n.elevation ?? 0.05,
|
||||
apply: (_n, newValue) => ({ elevation: newValue }),
|
||||
apply: (n, newValue) => applySlabTopChange(n, newValue, { mode: 'drag' }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const [cx, cz] = slabHandleAnchor(n)
|
||||
@@ -151,6 +156,8 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
||||
holes: [],
|
||||
holeMetadata: [],
|
||||
elevation: 0.05,
|
||||
thickness: 0.05,
|
||||
recessed: false,
|
||||
autoFromWalls: false,
|
||||
}),
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
clampSlabElevationForWalls,
|
||||
getSlabElevationUpperBound,
|
||||
getStoredLevelHeight,
|
||||
type LevelNode,
|
||||
type SlabElevationClamp,
|
||||
type SlabNode,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
type SlabLevelContext = {
|
||||
storeyHeight: number
|
||||
walls: WallNode[]
|
||||
slabs: SlabNode[]
|
||||
}
|
||||
|
||||
const GROUNDED_SLAB_EPSILON = 1e-3
|
||||
/** Deck thickness an unsticking slab pops to — the schema default. */
|
||||
const UNSTUCK_DECK_THICKNESS = 0.05
|
||||
/**
|
||||
* Grounded-stretch ceiling for the 3D elevation arrow (m) — above any
|
||||
* plausible step/platform height. While grounded, dragging the top up to
|
||||
* here stretches the body; dragging past it unsticks the slab into a
|
||||
* thin floating deck and the drag continues as pure placement.
|
||||
*/
|
||||
export const SLAB_UNSTICK_THRESHOLD = 0.4
|
||||
|
||||
export type SlabTopChangeMode = 'drag' | 'panel'
|
||||
|
||||
/**
|
||||
* The one owner of the slab vertical-editing rules. Both edit surfaces
|
||||
* route through it: the viewport arrow as `mode: 'drag'`, the panel
|
||||
* elevation input as `mode: 'panel'`.
|
||||
*
|
||||
* Hysteresis-free state machine (pure in current state + newTop):
|
||||
* - recessed → move the pool floor; rising to ≥ 0 un-recesses.
|
||||
* - grounded, newTop ≤ 0 → pool gesture (both modes).
|
||||
* - grounded drag, newTop ≤ {@link SLAB_UNSTICK_THRESHOLD} → stretch
|
||||
* (elevation and thickness move together, underside stays at 0).
|
||||
* - grounded drag past the threshold → unstick: pop to the default deck
|
||||
* thickness and continue as placement.
|
||||
* - otherwise (floating, or any panel edit) → placement: move the body
|
||||
* preserving thickness, clamping the underside to the level plane —
|
||||
* landing re-grounds the slab, so the way back up stretches again
|
||||
* below the threshold.
|
||||
*/
|
||||
export function applySlabTopChange(
|
||||
slab: SlabNode,
|
||||
newTop: number,
|
||||
options: { mode: SlabTopChangeMode },
|
||||
): Partial<SlabNode> {
|
||||
if (slab.recessed) return { elevation: newTop, recessed: newTop < 0 }
|
||||
|
||||
const grounded = Math.abs(slab.elevation - slab.thickness) < GROUNDED_SLAB_EPSILON
|
||||
if (grounded && newTop <= 0) return { elevation: newTop, recessed: true }
|
||||
|
||||
if (grounded && options.mode === 'drag') {
|
||||
return newTop <= SLAB_UNSTICK_THRESHOLD
|
||||
? { elevation: newTop, thickness: newTop, recessed: false }
|
||||
: { elevation: newTop, thickness: UNSTUCK_DECK_THICKNESS, recessed: false }
|
||||
}
|
||||
|
||||
return { elevation: Math.max(newTop, slab.thickness), recessed: false }
|
||||
}
|
||||
|
||||
export function applySlabElevationPreset(newTop: number): Partial<SlabNode> {
|
||||
return newTop < 0
|
||||
? { elevation: newTop, recessed: true }
|
||||
: { elevation: newTop, thickness: Math.max(newTop, 0), recessed: false }
|
||||
}
|
||||
|
||||
function resolveSlabLevelContext(
|
||||
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
slab: SlabNode,
|
||||
): SlabLevelContext | null {
|
||||
const parent = slab.parentId ? nodes[slab.parentId as AnyNodeId] : undefined
|
||||
if (parent?.type !== 'level') return null
|
||||
const level = parent as LevelNode
|
||||
const children = level.children.map((childId) => nodes[childId as AnyNodeId])
|
||||
return {
|
||||
storeyHeight: getStoredLevelHeight(level),
|
||||
walls: children.filter((child): child is WallNode => child?.type === 'wall'),
|
||||
slabs: children.filter((child): child is SlabNode => child?.type === 'slab'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Level-context wrapper over the pure core clamp: a slab under
|
||||
* plane-bound walls may not rise past the storey plane minus the
|
||||
* minimum wall height. Slabs outside a level (no parent) are
|
||||
* unconstrained.
|
||||
*/
|
||||
export function clampSlabElevation(
|
||||
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
slab: SlabNode,
|
||||
proposedElevation: number,
|
||||
): SlabElevationClamp {
|
||||
const context = resolveSlabLevelContext(nodes, slab)
|
||||
if (!context) return { elevation: proposedElevation, clamped: false }
|
||||
return clampSlabElevationForWalls(
|
||||
proposedElevation,
|
||||
slab,
|
||||
context.walls,
|
||||
context.slabs,
|
||||
context.storeyHeight,
|
||||
)
|
||||
}
|
||||
|
||||
/** Drag-time upper bound for the slab height arrow; +Infinity when unconstrained. */
|
||||
export function slabElevationUpperBound(
|
||||
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
slab: SlabNode,
|
||||
): number {
|
||||
const context = resolveSlabLevelContext(nodes, slab)
|
||||
if (!context) return Number.POSITIVE_INFINITY
|
||||
return getSlabElevationUpperBound(slab, context.walls, context.slabs, context.storeyHeight)
|
||||
}
|
||||
@@ -209,7 +209,10 @@ export function buildSlabGeometry(
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
mesh.userData.slotId = slotId
|
||||
if (elevation < 0) mesh.position.y = elevation
|
||||
// Solid slabs bake [elevation − thickness, elevation] into the geometry;
|
||||
// recessed shells are authored at local Y=0 and sink so the recess floor
|
||||
// sits at `elevation` (< 0) with the shell rim on the level plane.
|
||||
if (node.recessed) mesh.position.y = elevation
|
||||
group.add(mesh)
|
||||
}
|
||||
return group
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
|
||||
import { type AnyNode, MIN_SLAB_THICKNESS, type SlabNode, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { applySlabElevationPreset, applySlabTopChange, clampSlabElevation } from './elevation-limit'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage E — slab inspector (kind-owned).
|
||||
@@ -30,6 +31,7 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
*/
|
||||
export function SlabPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const editingHole = useEditingHole()
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
@@ -52,6 +54,33 @@ export function SlabPanel() {
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const handleElevationChange = useCallback(
|
||||
(proposed: number) => {
|
||||
const current = nodeRef.current
|
||||
if (!current) return
|
||||
const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed)
|
||||
handleUpdate(applySlabTopChange(current, elevation, { mode: 'panel' }))
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleThicknessChange = useCallback(
|
||||
(proposed: number) => {
|
||||
handleUpdate({ thickness: Math.max(MIN_SLAB_THICKNESS, proposed) })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleElevationPreset = useCallback(
|
||||
(proposed: number) => {
|
||||
const current = nodeRef.current
|
||||
if (!current) return
|
||||
const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed)
|
||||
handleUpdate(applySlabElevationPreset(elevation))
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
useInteractionScope
|
||||
@@ -162,6 +191,23 @@ export function SlabPanel() {
|
||||
|
||||
const area = calculateArea(node.polygon)
|
||||
|
||||
// Clean preset values per display system; imperial stores exact meters
|
||||
// for whole-inch offsets.
|
||||
const elevationPresets =
|
||||
unit === 'imperial'
|
||||
? [
|
||||
{ label: 'Sunken (-6")', elevation: -0.1524 },
|
||||
{ label: 'Ground (0")', elevation: 0 },
|
||||
{ label: 'Raised (+2")', elevation: 0.0508 },
|
||||
{ label: 'Step (+6")', elevation: 0.1524 },
|
||||
]
|
||||
: [
|
||||
{ label: 'Sunken (-15cm)', elevation: -0.15 },
|
||||
{ label: 'Ground (0m)', elevation: 0 },
|
||||
{ label: 'Raised (+5cm)', elevation: 0.05 },
|
||||
{ label: 'Step (+15cm)', elevation: 0.15 },
|
||||
]
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/floor.webp"
|
||||
@@ -174,18 +220,34 @@ export function SlabPanel() {
|
||||
label="Height"
|
||||
max={1}
|
||||
min={-1}
|
||||
onChange={(v) => handleUpdate({ elevation: v })}
|
||||
onChange={handleElevationChange}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.elevation * 1000) / 1000}
|
||||
/>
|
||||
|
||||
{!node.recessed && (
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
max={0.5}
|
||||
min={MIN_SLAB_THICKNESS}
|
||||
onChange={handleThicknessChange}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.thickness ?? 0.05) * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1">
|
||||
<ActionButton label="Sunken (-15cm)" onClick={() => handleUpdate({ elevation: -0.15 })} />
|
||||
<ActionButton label="Ground (0m)" onClick={() => handleUpdate({ elevation: 0 })} />
|
||||
<ActionButton label="Raised (+5cm)" onClick={() => handleUpdate({ elevation: 0.05 })} />
|
||||
<ActionButton label="Step (+15cm)" onClick={() => handleUpdate({ elevation: 0.15 })} />
|
||||
{elevationPresets.map((preset) => (
|
||||
<ActionButton
|
||||
key={preset.label}
|
||||
label={preset.label}
|
||||
onClick={() => handleElevationPreset(preset.elevation)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import { MIN_SLAB_THICKNESS, type ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { SlabNode } from './schema'
|
||||
|
||||
/**
|
||||
@@ -15,7 +15,18 @@ export const slabParametrics: ParametricDescriptor<SlabNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Elevation',
|
||||
fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 }],
|
||||
fields: [
|
||||
{ key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 },
|
||||
{
|
||||
key: 'thickness',
|
||||
kind: 'number',
|
||||
unit: 'm',
|
||||
min: MIN_SLAB_THICKNESS,
|
||||
max: 0.5,
|
||||
step: 0.01,
|
||||
visibleIf: (n) => !n.recessed,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
customPanel: () => import('./panel'),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
resolveSupportSlabPatch,
|
||||
SpawnNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -105,10 +106,18 @@ const SpawnTool = () => {
|
||||
let placedId: SpawnNode['id']
|
||||
|
||||
if (existingSpawnId) {
|
||||
const live = useScene.getState().nodes[existingSpawnId]
|
||||
const effectiveSpawn = SpawnNode.parse({
|
||||
...live,
|
||||
parentId: activeLevelId,
|
||||
position: next,
|
||||
rotation: 0,
|
||||
})
|
||||
useScene.getState().updateNode(existingSpawnId, {
|
||||
parentId: activeLevelId,
|
||||
position: next,
|
||||
rotation: 0,
|
||||
...resolveSupportSlabPatch(effectiveSpawn, useScene.getState().nodes),
|
||||
})
|
||||
if (duplicates.length > 0) {
|
||||
useScene.getState().deleteNodes(duplicates)
|
||||
@@ -119,9 +128,14 @@ const SpawnTool = () => {
|
||||
name: 'Spawn Point',
|
||||
position: next,
|
||||
rotation: 0,
|
||||
parentId: activeLevelId,
|
||||
})
|
||||
useScene.getState().createNode(spawn, activeLevelId)
|
||||
placedId = spawn.id
|
||||
const committedSpawn = SpawnNode.parse({
|
||||
...spawn,
|
||||
...resolveSupportSlabPatch(spawn, useScene.getState().nodes),
|
||||
})
|
||||
useScene.getState().createNode(committedSpawn, activeLevelId)
|
||||
placedId = committedSpawn.id
|
||||
}
|
||||
|
||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
resolveStairTotalRise,
|
||||
type SceneApi,
|
||||
StairNode as StairNodeSchema,
|
||||
type StairNode as StairNodeType,
|
||||
type StairSegmentNode,
|
||||
stairFootprintAABB,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
const MIN_CURVED_RISE = 0.3
|
||||
@@ -53,10 +55,14 @@ type StairMoveBounds = {
|
||||
height: number
|
||||
}
|
||||
|
||||
function readTotalRise(node: StairNodeType): number {
|
||||
return Math.max(resolveStairTotalRise(node, useScene.getState().nodes), 0.1)
|
||||
}
|
||||
|
||||
function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom {
|
||||
const isSpiral = node.stairType === 'spiral'
|
||||
const stepCount = Math.max(2, Math.round(node.stepCount ?? 10))
|
||||
const totalRise = Math.max(node.totalRise ?? 2.5, 0.1)
|
||||
const totalRise = readTotalRise(node)
|
||||
const width = Math.max(node.width ?? 1, MIN_CURVED_WIDTH)
|
||||
const minInnerRadius = isSpiral ? MIN_CURVED_INNER_RADIUS_SPIRAL : MIN_CURVED_INNER_RADIUS_CURVED
|
||||
const innerRadius = Math.max(minInnerRadius, node.innerRadius ?? 0.9)
|
||||
@@ -96,7 +102,7 @@ function fallbackStraightStairMoveBounds(node: StairNodeType): StairMoveBounds {
|
||||
maxX: width / 2,
|
||||
minZ: 0,
|
||||
maxZ: depth,
|
||||
height: Math.max(node.totalRise ?? 2.5, 0.1),
|
||||
height: readTotalRise(node),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +167,7 @@ function curvedRiseHandle(): HandleDescriptor<StairNodeType> {
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_CURVED_RISE,
|
||||
currentValue: (n) => Math.max(n.totalRise ?? 2.5, 0.1),
|
||||
currentValue: readTotalRise,
|
||||
apply: (_n, newRise) => ({ totalRise: newRise }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
@@ -307,7 +313,7 @@ function stairRotateGizmoPosition(n: StairNodeType): [number, number, number] {
|
||||
return [radius * Math.cos(angle), g.totalRise / 2, radius * Math.sin(angle)]
|
||||
}
|
||||
const width = Math.max(n.width ?? 1, MIN_CURVED_WIDTH)
|
||||
const yMid = Math.max(n.totalRise ?? 2.5, 0.1) / 2
|
||||
const yMid = readTotalRise(n) / 2
|
||||
return [width / 2 + STAIR_ROTATE_CORNER_OFFSET, yMid, -STAIR_ROTATE_CORNER_OFFSET]
|
||||
}
|
||||
|
||||
@@ -345,7 +351,7 @@ function stairRotateHandle(): HandleDescriptor<StairNodeType> {
|
||||
STAIR_ROTATE_RING_OFFSET
|
||||
)
|
||||
},
|
||||
y: (n) => Math.max(n.totalRise ?? 2.5, 0.1) / 2,
|
||||
y: (n) => readTotalRise(n) / 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { LevelNode, SlabNode, StairNode } from '@pascal-app/core'
|
||||
import { getStairDestinationUpdates } from './destination'
|
||||
|
||||
function makeStair(overrides: Record<string, unknown> = {}) {
|
||||
return StairNode.parse({
|
||||
id: 'stair_1',
|
||||
type: 'stair',
|
||||
position: [0, 0, 0],
|
||||
slabOpeningMode: 'destination',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
const deck = SlabNode.parse({
|
||||
id: 'slab_deck',
|
||||
type: 'slab',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
elevation: 1.25,
|
||||
thickness: 0.05,
|
||||
})
|
||||
|
||||
const level = LevelNode.parse({
|
||||
id: 'level_2',
|
||||
type: 'level',
|
||||
level: 1,
|
||||
children: [],
|
||||
})
|
||||
|
||||
describe('getStairDestinationUpdates', () => {
|
||||
it('attaching to a deck disables the auto cutout and clears the custom rise', () => {
|
||||
const stair = makeStair({ totalRise: 2.0 })
|
||||
const updates = getStairDestinationUpdates(stair, deck, deck.id)
|
||||
expect(updates.deckSlabId).toBe(deck.id)
|
||||
expect(updates.slabOpeningMode).toBe('none')
|
||||
// The key must be PRESENT with an undefined value so the store merge
|
||||
// clears the field.
|
||||
expect('totalRise' in updates && updates.totalRise === undefined).toBe(true)
|
||||
})
|
||||
|
||||
it('detaching back to a level restores the placement-default cutout and follows mode', () => {
|
||||
const stair = makeStair({ deckSlabId: deck.id, slabOpeningMode: 'none' })
|
||||
const updates = getStairDestinationUpdates(stair, level, level.id)
|
||||
expect(updates.toLevelId).toBe(level.id)
|
||||
expect(updates.slabOpeningMode).toBe('destination')
|
||||
expect('deckSlabId' in updates && updates.deckSlabId === undefined).toBe(true)
|
||||
expect('totalRise' in updates && updates.totalRise === undefined).toBe(true)
|
||||
})
|
||||
|
||||
it('a plain level-to-level switch leaves rise and opening mode alone', () => {
|
||||
const stair = makeStair({ totalRise: 2.0 })
|
||||
const updates = getStairDestinationUpdates(stair, level, level.id)
|
||||
expect(updates.toLevelId).toBe(level.id)
|
||||
expect('deckSlabId' in updates && updates.deckSlabId === undefined).toBe(true)
|
||||
expect('totalRise' in updates).toBe(false)
|
||||
expect('slabOpeningMode' in updates).toBe(false)
|
||||
})
|
||||
|
||||
it('detaching a stale deck reference still resets to follows mode', () => {
|
||||
const stair = makeStair({ deckSlabId: 'slab_gone', totalRise: 1.4 })
|
||||
const updates = getStairDestinationUpdates(stair, level, level.id)
|
||||
expect(updates.slabOpeningMode).toBe('destination')
|
||||
expect('totalRise' in updates && updates.totalRise === undefined).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { AnyNode, StairNode } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Computes the stair patch for a destination ("To") switch.
|
||||
*
|
||||
* Attaching to a deck clears any explicit custom rise (the rise follows the
|
||||
* deck's elevation from now on) and disables the auto cutout — a deck stair
|
||||
* lands ON its destination slab, not through it. Switching a deck-attached
|
||||
* stair back to a level clears both again so the rise re-derives from the
|
||||
* storey height, and restores `slabOpeningMode: 'destination'` — the
|
||||
* placement default (the schema default is 'none', but the stair tool places
|
||||
* ordinary stairs with 'destination', so that is what a level-destination
|
||||
* stair regains). Plain level-to-level switches leave rise and opening mode
|
||||
* alone.
|
||||
*/
|
||||
export function getStairDestinationUpdates(
|
||||
stair: StairNode,
|
||||
target: AnyNode | undefined,
|
||||
targetId: string,
|
||||
): Partial<StairNode> {
|
||||
if (target?.type === 'slab') {
|
||||
return { deckSlabId: targetId, totalRise: undefined, slabOpeningMode: 'none' }
|
||||
}
|
||||
const updates: Partial<StairNode> = { toLevelId: targetId, deckSlabId: undefined }
|
||||
if (stair.deckSlabId) {
|
||||
updates.totalRise = undefined
|
||||
updates.slabOpeningMode = 'destination'
|
||||
}
|
||||
return updates
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type LevelNode,
|
||||
resolveStairTotalRise,
|
||||
type SlabNode,
|
||||
type StairNode,
|
||||
type StairRailingMode,
|
||||
type StairSegmentNode,
|
||||
@@ -35,6 +37,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { getStairDestinationUpdates } from './destination'
|
||||
|
||||
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
|
||||
{ label: 'None', value: 'none' },
|
||||
@@ -59,6 +62,10 @@ const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }
|
||||
{ label: 'Destination', value: 'destination' },
|
||||
]
|
||||
|
||||
// Slabs at least this high off the storey floor read as decks (mezzanines) —
|
||||
// lower slabs are floor coverings, never useful stair destinations.
|
||||
const DECK_DESTINATION_MIN_ELEVATION = 0.5
|
||||
|
||||
export default function StairPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
@@ -75,6 +82,20 @@ export default function StairPanel() {
|
||||
() => (node?.type === 'stair' ? getStairLevelOptions(nodes, node) : []),
|
||||
[node, nodes],
|
||||
)
|
||||
const candidateDecks = useMemo<SlabNode[]>(() => {
|
||||
if (node?.type !== 'stair') return []
|
||||
const level = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined
|
||||
if (level?.type !== 'level') return []
|
||||
const decks: SlabNode[] = []
|
||||
for (const childId of level.children) {
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (child?.type !== 'slab') continue
|
||||
if (child.id === node.deckSlabId || child.elevation >= DECK_DESTINATION_MIN_ELEVATION) {
|
||||
decks.push(child)
|
||||
}
|
||||
}
|
||||
return decks
|
||||
}, [node, nodes])
|
||||
const segments = useScene(
|
||||
useShallow((s) => {
|
||||
if (!selectedId) return []
|
||||
@@ -133,6 +154,15 @@ export default function StairPanel() {
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleDestinationChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!node) return
|
||||
const target = useScene.getState().nodes[value as AnyNodeId]
|
||||
handleUpdate(getStairDestinationUpdates(node, target, value))
|
||||
},
|
||||
[node, handleUpdate],
|
||||
)
|
||||
|
||||
const getLastSegmentFillDefaults = useCallback(() => {
|
||||
if (!node) return { fillToFloor: true }
|
||||
const children = node.children ?? []
|
||||
@@ -223,6 +253,9 @@ export default function StairPanel() {
|
||||
|
||||
const resolvedFromLevelId = resolveStairFromLevelId(nodes, node, levels)
|
||||
const resolvedToLevelId = resolveStairToLevelId(nodes, node, resolvedFromLevelId, levels)
|
||||
const deckNode = node.deckSlabId ? nodes[node.deckSlabId as AnyNodeId] : undefined
|
||||
const attachedDeck = deckNode?.type === 'slab' ? deckNode : undefined
|
||||
const resolvedRise = Math.round(resolveStairTotalRise(node, nodes) * 100) / 100
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
@@ -251,11 +284,13 @@ export default function StairPanel() {
|
||||
|
||||
<PanelSection title="Opening">
|
||||
<div className="space-y-3">
|
||||
<ToggleControl
|
||||
checked={(node.slabOpeningMode ?? 'none') === 'destination'}
|
||||
label="Auto Cutout"
|
||||
onChange={handleAutoCutoutChange}
|
||||
/>
|
||||
{attachedDeck ? null : (
|
||||
<ToggleControl
|
||||
checked={(node.slabOpeningMode ?? 'none') === 'destination'}
|
||||
label="Auto Cutout"
|
||||
onChange={handleAutoCutoutChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
|
||||
@@ -276,40 +311,85 @@ export default function StairPanel() {
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
|
||||
To Level
|
||||
To
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
|
||||
onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
|
||||
value={resolvedToLevelId ?? ''}
|
||||
onChange={(event) => handleDestinationChange(event.target.value)}
|
||||
value={attachedDeck ? attachedDeck.id : (resolvedToLevelId ?? '')}
|
||||
>
|
||||
{levels.map((level) => (
|
||||
<option key={level.id} value={level.id}>
|
||||
{level.name || `Level ${level.level + 1}`}
|
||||
</option>
|
||||
))}
|
||||
{candidateDecks.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
{deck.name || 'Deck'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<SegmentedControl
|
||||
onChange={(value) => handleAutoCutoutChange(value === 'destination')}
|
||||
options={STAIR_SLAB_OPENING_OPTIONS}
|
||||
value={node.slabOpeningMode ?? 'none'}
|
||||
/>
|
||||
|
||||
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
|
||||
<MetricControl
|
||||
label="Opening Offset"
|
||||
max={0.5}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ openingOffset: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.openingOffset ?? 0) * 100) / 100}
|
||||
/>
|
||||
{attachedDeck ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
|
||||
Rise
|
||||
</div>
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate(
|
||||
value === 'custom' ? { totalRise: resolvedRise } : { totalRise: undefined },
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{ label: 'Follows deck', value: 'follows' },
|
||||
{ label: 'Custom rise', value: 'custom' },
|
||||
]}
|
||||
value={node.totalRise == null ? 'follows' : 'custom'}
|
||||
/>
|
||||
{node.totalRise == null ? (
|
||||
<div className="px-1 text-[11px] text-muted-foreground">
|
||||
Currently {resolvedRise} m
|
||||
</div>
|
||||
) : (
|
||||
<MetricControl
|
||||
label="Rise"
|
||||
max={10}
|
||||
min={0.2}
|
||||
onChange={(value) => handleUpdate({ totalRise: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={resolvedRise}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attachedDeck ? null : (
|
||||
<>
|
||||
<SegmentedControl
|
||||
onChange={(value) => handleAutoCutoutChange(value === 'destination')}
|
||||
options={STAIR_SLAB_OPENING_OPTIONS}
|
||||
value={node.slabOpeningMode ?? 'none'}
|
||||
/>
|
||||
|
||||
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
|
||||
<MetricControl
|
||||
label="Opening Offset"
|
||||
max={0.5}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ openingOffset: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.openingOffset ?? 0) * 100) / 100}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{node.stairType === 'spiral' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
@@ -389,7 +469,7 @@ export default function StairPanel() {
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
|
||||
value={Math.round(resolveStairTotalRise(node, nodes) * 100) / 100}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Steps"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
resolveStairTotalRise,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
useLiveNodeOverrides,
|
||||
@@ -216,7 +217,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
|
||||
const sweepAngle =
|
||||
stair.sweepAngle ?? (stair.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
|
||||
const stepSweep = sweepAngle / stepCount
|
||||
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
|
||||
const stepHeight = Math.max(resolveStairTotalRise(stair, nodes), 0.1) / stepCount
|
||||
const innerRadius = Math.max(
|
||||
stair.stairType === 'spiral' ? 0.05 : 0.2,
|
||||
stair.innerRadius ?? 0.9,
|
||||
@@ -516,9 +517,10 @@ function CurvedStairBody({
|
||||
stair: StairNode
|
||||
bodyMaterials: StairBodyMaterials
|
||||
}) {
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const sideMaterial = bodyMaterials[1]
|
||||
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
||||
const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1)
|
||||
const totalRise = Math.max(resolveStairTotalRise(stair, nodes), 0.1)
|
||||
const stepHeight = totalRise / stepCount
|
||||
const isSpiral = stair.stairType === 'spiral'
|
||||
const innerRadius = Math.max(isSpiral ? 0.05 : 0.2, stair.innerRadius ?? 0.9)
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getWallCurveFrameAt,
|
||||
getWallThickness,
|
||||
type MeasurementFeature,
|
||||
type MeasurementFeatureBinding,
|
||||
type MeasurementFeatureReference,
|
||||
sampleWallCenterline,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling'
|
||||
|
||||
const point = (x: number, y: number, z: number) => [x, y, z] as [number, number, number]
|
||||
|
||||
export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] {
|
||||
const height = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||
const height = resolveWallOpeningCeiling(wall, useScene.getState().nodes)
|
||||
const centerline = sampleWallCenterline(wall).map(({ x, y }) => point(x, 0, y))
|
||||
const midpoint = getWallCurveFrameAt(wall, 0.5).point
|
||||
const halfThickness = getWallThickness(wall) / 2
|
||||
@@ -168,7 +169,10 @@ export function matchWallMeasurementFeature(
|
||||
const faceDistance = Math.hypot(hit[0] - faceX, hit[2] - faceZ)
|
||||
const threshold = Math.max(maxDistance, halfThickness + 0.03)
|
||||
if (faceDistance <= threshold && (!best || faceDistance < best.distance)) {
|
||||
const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, hit[1]))
|
||||
const height = Math.max(
|
||||
0,
|
||||
Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes), hit[1]),
|
||||
)
|
||||
best = {
|
||||
featureId: side > 0 ? 'wall:face:left' : 'wall:face:right',
|
||||
point: point(faceX, height, faceZ),
|
||||
@@ -204,7 +208,10 @@ export function resolveWallMeasurementFeature(
|
||||
if (typeof heightValue !== 'number' || feature.geometry.kind !== 'path') {
|
||||
return normal ? { ...feature, normal } : feature
|
||||
}
|
||||
const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, heightValue))
|
||||
const height = Math.max(
|
||||
0,
|
||||
Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes), heightValue),
|
||||
)
|
||||
return {
|
||||
...feature,
|
||||
...(normal ? { normal } : {}),
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getWallCurveLength,
|
||||
getWallThickness,
|
||||
pauseSceneHistory,
|
||||
resolveAlignment,
|
||||
resolveWallSupportSlabPatch,
|
||||
resumeSceneHistory,
|
||||
runAsSingleSceneHistoryStep,
|
||||
useLiveNodeOverrides,
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling'
|
||||
|
||||
/**
|
||||
* Wall endpoint move tool (kind-owned).
|
||||
@@ -521,6 +522,16 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
},
|
||||
})),
|
||||
])
|
||||
const affectedIds = [nodeId as AnyNodeId, ...linkedUpdates.map((u) => u.id as AnyNodeId)]
|
||||
const committedNodes = useScene.getState().nodes
|
||||
useScene.getState().updateNodes(
|
||||
affectedIds.flatMap((id) => {
|
||||
const wall = committedNodes[id]
|
||||
return wall?.type === 'wall'
|
||||
? [{ id, data: resolveWallSupportSlabPatch(wall, committedNodes) }]
|
||||
: []
|
||||
}),
|
||||
)
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
for (const u of linkedUpdates) {
|
||||
useScene.getState().markDirty(u.id as AnyNodeId)
|
||||
@@ -613,7 +624,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
end: previewEnd,
|
||||
curveOffset: target.wall.curveOffset,
|
||||
})
|
||||
const wallHeight = target.wall.height ?? DEFAULT_WALL_HEIGHT
|
||||
const wallHeight = resolveWallOpeningCeiling(target.wall, useScene.getState().nodes)
|
||||
const dimMidX = (previewStart[0] + previewEnd[0]) / 2
|
||||
const dimMidZ = (previewStart[1] + previewEnd[1]) / 2
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getMaterialPresetByRef,
|
||||
parseMaterialRef,
|
||||
resolveMaterial,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
WallNode as WallSchema,
|
||||
} from '@pascal-app/core'
|
||||
import { isSegmentLongEnough } from '@pascal-app/editor'
|
||||
import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling'
|
||||
|
||||
/**
|
||||
* Pure helpers shared by the 3D `MoveWallTool` and the 2D
|
||||
@@ -241,7 +241,7 @@ export function buildBridgeWallPreviews(args: {
|
||||
start: [...plan.originalPoint] as WallPlanPoint,
|
||||
end: [...nextPoint] as WallPlanPoint,
|
||||
color: getWallGhostColor(plan.wall),
|
||||
height: plan.wall.height ?? DEFAULT_WALL_HEIGHT,
|
||||
height: resolveWallOpeningCeiling(plan.wall, useScene.getState().nodes),
|
||||
}
|
||||
previews.push({ ghost, wall })
|
||||
wallsForDuplicateCheck.push(wall)
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
detectSpacesForLevel,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getCeilingClampBound,
|
||||
getPerpendicularWallMoveAxis,
|
||||
getPlannedLinkedWallUpdates,
|
||||
getStoredLevelHeight,
|
||||
type LevelNode,
|
||||
pauseSceneHistory,
|
||||
planAutoCeilingsForLevel,
|
||||
planAutoSlabsForLevel,
|
||||
planWallMoveJunctions,
|
||||
projectAutoSlabsForPlan,
|
||||
resolveWallSupportSlabPatch,
|
||||
resumeSceneHistory,
|
||||
type SlabNode,
|
||||
useLiveNodeOverrides,
|
||||
@@ -284,12 +287,14 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
// re-flowed through the planner.
|
||||
const existingSlabs = getLevelSlabs(levelId, sceneState.nodes)
|
||||
const slabPlan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
|
||||
const levelNode = sceneState.nodes[levelId as AnyNodeId]
|
||||
const ceilingPlan = planAutoCeilingsForLevel(
|
||||
roomPolygons,
|
||||
getLevelCeilings(levelId, sceneState.nodes),
|
||||
{
|
||||
walls: levelWalls,
|
||||
slabs: projectAutoSlabsForPlan(existingSlabs, slabPlan),
|
||||
storeyHeight:
|
||||
levelNode?.type === 'level' ? getStoredLevelHeight(levelNode as LevelNode) : undefined,
|
||||
ceilingClampBound: (polygon) => getCeilingClampBound(levelId, sceneState.nodes, polygon),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -594,6 +599,19 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
// undoable step. Then drop the live overrides — the renderer
|
||||
// now reads the committed walls + polygons directly.
|
||||
commitSurfacesToStore()
|
||||
const affectedWallIds = [
|
||||
...commitUpdates.map((entry) => entry.id),
|
||||
...bridgeCreates.map((entry) => entry.node.id as AnyNodeId),
|
||||
]
|
||||
const committedNodes = useScene.getState().nodes
|
||||
useScene.getState().updateNodes(
|
||||
affectedWallIds.flatMap((id) => {
|
||||
const wall = committedNodes[id]
|
||||
return wall?.type === 'wall'
|
||||
? [{ id, data: resolveWallSupportSlabPatch(wall, committedNodes) }]
|
||||
: []
|
||||
}),
|
||||
)
|
||||
clearSurfaceOverrides()
|
||||
clearWallOverrides()
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
createSlotPaintCapability,
|
||||
previewSlotByUserData,
|
||||
} from '../shared/slot-paint'
|
||||
import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling'
|
||||
|
||||
const WALL_SLOT_IDS = new Set<string>(Object.keys(WALL_SURFACE_SLOT_DEFAULTS))
|
||||
const WALL_ARRAY_SLOT_INDEX: Partial<Record<WallSurfaceSlotId, number>> = {
|
||||
@@ -104,9 +105,13 @@ export function resolveWallRole(args: {
|
||||
}
|
||||
|
||||
if (sideFromIndex && localPosition) {
|
||||
const bands = getWallFaceBandConfig(node)
|
||||
const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes)
|
||||
const bands = getWallFaceBandConfig(node, effectiveWallHeight)
|
||||
if (!bands.enabled) return sideFromIndex
|
||||
return getWallBandSlotId(sideFromIndex, getWallFaceBandForHeight(node, localPosition[1]))
|
||||
return getWallBandSlotId(
|
||||
sideFromIndex,
|
||||
getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight),
|
||||
)
|
||||
}
|
||||
if (sideFromIndex) return sideFromIndex
|
||||
|
||||
@@ -128,15 +133,23 @@ export function resolveWallRole(args: {
|
||||
const semantic = hitFace === 'front' ? node.frontSide : node.backSide
|
||||
|
||||
if (semantic === 'interior' || semantic === 'exterior') {
|
||||
const bands = getWallFaceBandConfig(node)
|
||||
const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes)
|
||||
const bands = getWallFaceBandConfig(node, effectiveWallHeight)
|
||||
if (!bands.enabled) return semantic
|
||||
return getWallBandSlotId(semantic, getWallFaceBandForHeight(node, localPosition[1]))
|
||||
return getWallBandSlotId(
|
||||
semantic,
|
||||
getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight),
|
||||
)
|
||||
}
|
||||
|
||||
const side = hitFace === 'front' ? 'interior' : 'exterior'
|
||||
const bands = getWallFaceBandConfig(node)
|
||||
const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes)
|
||||
const bands = getWallFaceBandConfig(node, effectiveWallHeight)
|
||||
if (!bands.enabled) return side
|
||||
return getWallBandSlotId(side, getWallFaceBandForHeight(node, localPosition[1]))
|
||||
return getWallBandSlotId(
|
||||
side,
|
||||
getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
curveReshapeScope,
|
||||
formatLinearMeasurement,
|
||||
getLinearUnitLabel,
|
||||
linearControlValueToMeters,
|
||||
metersToLinearUnit,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Spline } from 'lucide-react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling'
|
||||
|
||||
type WallTrimKey = 'skirting' | 'crown' | 'chairRail'
|
||||
|
||||
@@ -104,6 +106,15 @@ export default function WallPanel() {
|
||||
})
|
||||
})
|
||||
|
||||
// Effective height while the wall is plane-bound (`height` absent): the
|
||||
// storey plane minus the elected slab base — what the wall currently
|
||||
// renders at. `undefined` for walls with an explicit custom height.
|
||||
const planeBoundHeightMeters = useScene((s) => {
|
||||
const wall = selectedId ? (s.nodes[selectedId as AnyNodeId] as WallNode | undefined) : undefined
|
||||
if (!wall || wall.type !== 'wall' || wall.height != null) return undefined
|
||||
return resolveWallOpeningCeiling(wall, s.nodes)
|
||||
})
|
||||
|
||||
// Mirror the latest node into a ref so the slider handlers below have
|
||||
// stable identities across re-renders. Without this, every store tick
|
||||
// (one per pointermove during a slider drag) rebuilt the handler
|
||||
@@ -145,6 +156,24 @@ export default function WallPanel() {
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleTopModeChange = useCallback(
|
||||
(mode: 'storey' | 'custom') => {
|
||||
const n = nodeRef.current
|
||||
if (!n) return
|
||||
const isCustom = n.height != null
|
||||
if (mode === 'custom' && !isCustom) {
|
||||
// Seed from the current effective height so the geometry doesn't
|
||||
// jump at the moment of detaching from the storey plane.
|
||||
const seeded = resolveWallOpeningCeiling(n, useScene.getState().nodes)
|
||||
handleUpdate({ height: Math.max(0.1, seeded) })
|
||||
} else if (mode === 'storey' && isCustom) {
|
||||
// Absent `height` = plane-bound; the store strips undefined keys.
|
||||
handleUpdate({ height: undefined })
|
||||
}
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -160,7 +189,8 @@ export default function WallPanel() {
|
||||
|
||||
const length = getWallCurveLength(node)
|
||||
|
||||
const height = node.height ?? 2.5
|
||||
const isPlaneBound = node.height == null
|
||||
const height = node.height ?? planeBoundHeightMeters ?? 2.5
|
||||
const thickness = node.thickness ?? 0.1
|
||||
const curveOffset = getClampedWallCurveOffset(node)
|
||||
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||
@@ -171,7 +201,7 @@ export default function WallPanel() {
|
||||
const displayCurveOffset = metersToLinearUnit(curveOffset, unit)
|
||||
const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit)
|
||||
const curveOffsetLimit = Math.max(0.01, maxCurveOffset)
|
||||
const wallHeightMeters = node.height ?? 2.5
|
||||
const wallHeightMeters = height
|
||||
|
||||
const skirting = { ...WALL_SKIRTING_DEFAULT, ...(node.skirting ?? {}) }
|
||||
const crown = { ...WALL_CROWN_DEFAULT, ...(node.crown ?? {}) }
|
||||
@@ -199,20 +229,37 @@ export default function WallPanel() {
|
||||
unit={unitLabel}
|
||||
value={displayLength}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={metersToLinearUnit(6, unit)}
|
||||
min={metersToLinearUnit(0.1, unit)}
|
||||
onChange={(v) =>
|
||||
handleUpdate({
|
||||
height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit={unitLabel}
|
||||
value={Math.round(displayHeight * 100) / 100}
|
||||
<div className="px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Top
|
||||
</div>
|
||||
<SegmentedControl
|
||||
onChange={handleTopModeChange}
|
||||
options={[
|
||||
{ label: 'Follows level', value: 'storey' },
|
||||
{ label: 'Custom height', value: 'custom' },
|
||||
]}
|
||||
value={isPlaneBound ? 'storey' : 'custom'}
|
||||
/>
|
||||
{isPlaneBound ? (
|
||||
<div className="px-1 text-[11px] text-muted-foreground">
|
||||
Currently {formatLinearMeasurement(height, unit)}
|
||||
</div>
|
||||
) : (
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={metersToLinearUnit(6, unit)}
|
||||
min={metersToLinearUnit(0.1, unit)}
|
||||
onChange={(v) =>
|
||||
handleUpdate({
|
||||
height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit={unitLabel}
|
||||
value={Math.round(displayHeight * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
max={metersToLinearUnit(1, unit)}
|
||||
@@ -318,7 +365,7 @@ function WallFaceBandSection({
|
||||
unitLabel: string
|
||||
wallHeightMeters: number
|
||||
}) {
|
||||
const bandConfig = getWallFaceBandConfig(node)
|
||||
const bandConfig = getWallFaceBandConfig(node, wallHeightMeters)
|
||||
const bandCount = bandConfig.count
|
||||
const lowerHeight = bandConfig.lowerHeight
|
||||
const middleHeight = bandConfig.middleHeight
|
||||
|
||||
@@ -19,6 +19,8 @@ export const wallParametrics: ParametricDescriptor<WallNode> = {
|
||||
label: 'Dimensions',
|
||||
fields: [
|
||||
{ key: 'thickness', kind: 'number', unit: 'm', min: 0.05, max: 0.6, step: 0.01 },
|
||||
// `height` may be absent (plane-bound top); the custom panel owns the
|
||||
// Follows storey / Custom height mode switch, so this is metadata only.
|
||||
{ key: 'height', kind: 'number', unit: 'm', min: 1.5, max: 6, step: 0.05 },
|
||||
{ key: 'curveOffset', kind: 'number', unit: 'm', min: -3, max: 3, step: 0.05 },
|
||||
],
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
getWallThickness,
|
||||
type QuickMeasurementReport,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling'
|
||||
|
||||
export function wallQuickMeasurement(node: WallNode): QuickMeasurementReport {
|
||||
const length = getWallCurveLength(node)
|
||||
const height = node.height ?? DEFAULT_WALL_HEIGHT
|
||||
const height = resolveWallOpeningCeiling(node, useScene.getState().nodes)
|
||||
const frame = getWallCurveFrameAt(node, 0.5)
|
||||
|
||||
return {
|
||||
|
||||
@@ -44,7 +44,7 @@ const WallTreatmentMiterSystem = () => {
|
||||
*
|
||||
* - **`WallSystem`** — reads `dirtyNodes`, batches by level, runs
|
||||
* `calculateLevelMiters(levelWalls)`, rebuilds geometry via
|
||||
* `generateExtrudedWall(node, children, miterData, slabElevation, baseElevation, baseSegments)`,
|
||||
* `generateExtrudedWall(node, children, miterData, slabElevation, baseElevation, baseSegments, storeyHeight)`,
|
||||
* and cascades to adjacent walls that share a junction. This is the
|
||||
* bulk of the wall runtime (~820 lines in viewer).
|
||||
* - **`WallCutout`** — cutaway-mode hide/show logic based on camera
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AnyNode,
|
||||
calculateLevelMiters,
|
||||
collectAlignmentAnchors,
|
||||
DEFAULT_LEVEL_HEIGHT,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getWallMiterBoundaryPoints,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import {
|
||||
CursorSphere,
|
||||
chainEndJoinsExistingWall,
|
||||
clearPlacementSurface,
|
||||
createWallOnCurrentLevel,
|
||||
EDITOR_LAYER,
|
||||
formatAngleRadians,
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
isAngleSnapActive,
|
||||
isMagneticSnapActive,
|
||||
markToolCancelConsumed,
|
||||
publishPlacementSurface,
|
||||
resolvePointerSupportSurface,
|
||||
type SegmentAngleReference,
|
||||
snapWallDraftPointDetailed,
|
||||
triggerSFX,
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
} from '@pascal-app/editor'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three'
|
||||
|
||||
@@ -58,7 +63,6 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
|
||||
*
|
||||
* Mounted via `def.tool` from `wall/definition.ts`.
|
||||
*/
|
||||
const WALL_HEIGHT = 2.5
|
||||
const DRAFT_WALL_THICKNESS = 0.1
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
@@ -83,6 +87,11 @@ const AXIS_ANGLE_REFERENCES: SegmentAngleReference[] = [
|
||||
{ vector: [0, 1], orientation: 'axis' },
|
||||
]
|
||||
|
||||
// Grid-plane surface publish (pointer-decided): scratch + constant normal so
|
||||
// per-move publishes don't allocate.
|
||||
const SURFACE_UP = new Vector3(0, 1, 0)
|
||||
const surfacePointScratch = new Vector3()
|
||||
|
||||
type DraftAngleLabel = {
|
||||
id: string
|
||||
label: string
|
||||
@@ -513,13 +522,24 @@ function getBelowLevelWalls(): WallNode[] {
|
||||
export const WallTool: React.FC = () => {
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||
const activeLevelHeight = useScene((state) => {
|
||||
const level = activeLevelId ? state.nodes[activeLevelId] : undefined
|
||||
return level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT
|
||||
})
|
||||
// A placed wall preset seeds `toolDefaults.wall` (height / thickness …)
|
||||
// before the tool mounts, so the draft preview is drawn at the preset's
|
||||
// dimensions rather than the generic fallbacks — matching the wall that
|
||||
// will be created. Read through refs so the live event handlers below see
|
||||
// the latest values without re-subscribing.
|
||||
const wallDefaults = useEditor((s) => s.toolDefaults.wall)
|
||||
const previewHeight = typeof wallDefaults?.height === 'number' ? wallDefaults.height : WALL_HEIGHT
|
||||
// Camera for the pointer-support resolution (deck top vs floor) — read
|
||||
// through a ref so the event handlers below see the live camera.
|
||||
const camera = useThree((state) => state.camera)
|
||||
const cameraRef = useRef(camera)
|
||||
cameraRef.current = camera
|
||||
const previewHeight =
|
||||
typeof wallDefaults?.height === 'number' ? wallDefaults.height : activeLevelHeight
|
||||
const previewThickness =
|
||||
typeof wallDefaults?.thickness === 'number' ? wallDefaults.thickness : DRAFT_WALL_THICKNESS
|
||||
const previewHeightRef = useRef(previewHeight)
|
||||
@@ -591,6 +611,16 @@ export const WallTool: React.FC = () => {
|
||||
: point
|
||||
}
|
||||
|
||||
// The walking surface the pointer actually aims at (deck top when over
|
||||
// the deck, floor/ground underneath it) — only for genuine 3D pointer
|
||||
// events. The 2D floor plan emits synthetic grid events with no camera
|
||||
// ray behind them; those keep the uncapped max election and leave the
|
||||
// grid plane alone.
|
||||
const pointedSurfaceFor = (event: GridEvent) =>
|
||||
event.nativeEvent?.target instanceof HTMLCanvasElement
|
||||
? resolvePointerSupportSurface(cameraRef.current, event.position)
|
||||
: null
|
||||
|
||||
const stopDrafting = () => {
|
||||
buildingState.current = 0
|
||||
chainFirstVertex.current = null
|
||||
@@ -611,6 +641,19 @@ export const WallTool: React.FC = () => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!(cursorRef.current && wallPreviewRef.current)) return
|
||||
|
||||
// Ride the grid event plane on the pointed surface: aiming at an
|
||||
// elevated deck lifts the plane to the deck top, so the draft's XZ
|
||||
// lands where the cursor points and the preview/cursor Y
|
||||
// (`event.localPosition[1]`) sits at the base the committed wall
|
||||
// will elect. Aiming past the deck edge drops it back to the floor.
|
||||
const pointed = pointedSurfaceFor(event)
|
||||
if (pointed) {
|
||||
publishPlacementSurface(
|
||||
surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]),
|
||||
SURFACE_UP,
|
||||
)
|
||||
}
|
||||
|
||||
const walls = getCurrentLevelWalls()
|
||||
// Add walls on the floor below as extra snap references so the new wall
|
||||
// can align with the level beneath it. Kept separate from `walls` so the
|
||||
@@ -745,10 +788,12 @@ export const WallTool: React.FC = () => {
|
||||
const dx = snappedEnd[0] - startingPoint.current.x
|
||||
const dz = snappedEnd[1] - startingPoint.current.z
|
||||
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
||||
const pointed = pointedSurfaceFor(event)
|
||||
// Both start and end are building-local ✓
|
||||
const createdWall = createWallOnCurrentLevel(
|
||||
[startingPoint.current.x, startingPoint.current.z],
|
||||
snappedEnd,
|
||||
{ supportCap: pointed ? pointed.elevation : null },
|
||||
)
|
||||
if (!createdWall) return
|
||||
chainWallIds.current.push(createdWall.id)
|
||||
@@ -831,6 +876,7 @@ export const WallTool: React.FC = () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
clearPlacementSurface()
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
useSegmentDraftChain.getState().clear('wall')
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isCurvedWall,
|
||||
type SceneMaterial,
|
||||
type SceneMaterialId,
|
||||
useScene,
|
||||
WALL_CHAIR_RAIL_DEFAULT,
|
||||
WALL_CROWN_DEFAULT,
|
||||
WALL_SKIRTING_DEFAULT,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import { memo, useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling'
|
||||
import { treatmentMiterDataForProud, type WallTreatmentLevelData } from './treatment-level-data'
|
||||
|
||||
const CURVE_SEGMENTS = 24
|
||||
@@ -502,7 +504,7 @@ export function buildTrimGeometry(
|
||||
childrenNodes: OpeningLike[],
|
||||
levelData: WallTreatmentLevelData,
|
||||
) {
|
||||
const wallHeight = node.height ?? 2.5
|
||||
const wallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes)
|
||||
const height = trim.height
|
||||
const yBottom =
|
||||
kind === 'crown'
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
|
||||
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
|
||||
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
||||
import { readHostWallCeiling } from '../shared/wall-opening-ceiling'
|
||||
import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides'
|
||||
import { buildWindowFloorplan } from './floorplan'
|
||||
import { windowWidthAffordance } from './floorplan-affordances'
|
||||
@@ -33,12 +34,6 @@ function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unkn
|
||||
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
}
|
||||
|
||||
function readWallHeight(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
||||
if (!w.wallId) return Number.POSITIVE_INFINITY
|
||||
const wall = scene.get(w.wallId as AnyNodeId) as WallNode | undefined
|
||||
return wall?.height ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor<WindowNodeType> {
|
||||
const sign = side === 'right' ? 1 : -1
|
||||
return {
|
||||
@@ -96,9 +91,9 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor<WindowNode
|
||||
const roofMax = readRoofFaceHeightMax(n, scene, sign)
|
||||
if (roofMax !== null) return Math.max(MIN_WINDOW_HEIGHT, roofMax)
|
||||
// Maximum: distance from the anchored edge to the wall's allowed Y
|
||||
// bounds. Top arrow caps at wall.height - bottom; bottom arrow caps
|
||||
// at top (positive Y room above the floor).
|
||||
const wallH = readWallHeight(n, scene)
|
||||
// bounds. Top arrow caps at the wall's resolved ceiling - bottom;
|
||||
// bottom arrow caps at top (positive Y room above the floor).
|
||||
const wallH = readHostWallCeiling(n.wallId, scene)
|
||||
const anchored = edge === 'top' ? n.position[1] - n.height / 2 : n.position[1] + n.height / 2
|
||||
return edge === 'top'
|
||||
? Math.max(MIN_WINDOW_HEIGHT, wallH - anchored)
|
||||
|
||||
@@ -202,6 +202,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
startLocalY,
|
||||
node.width,
|
||||
node.height,
|
||||
nodes,
|
||||
)
|
||||
|
||||
// One click per real position step, keyed on the SNAPPED along-wall value
|
||||
|
||||
@@ -356,6 +356,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
targetLocalY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
useScene.getState().nodes,
|
||||
)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
|
||||
@@ -339,7 +339,14 @@ const WindowTool: React.FC = () => {
|
||||
width,
|
||||
height,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height)
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
wall,
|
||||
localX,
|
||||
localY,
|
||||
width,
|
||||
height,
|
||||
useScene.getState().nodes,
|
||||
)
|
||||
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
||||
return { clampedX, clampedY, valid }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WallNode } from '@pascal-app/core'
|
||||
import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core'
|
||||
import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling'
|
||||
|
||||
/**
|
||||
* Default sill height (metres from the floor to the BOTTOM of a window) for a
|
||||
@@ -35,7 +36,11 @@ export function wallLocalToWorld(
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps window center position so it stays fully within wall bounds.
|
||||
* Clamps window center position so it stays fully within wall bounds. The Y
|
||||
* ceiling is the wall's RESOLVED top (storey plane for plane-bound walls,
|
||||
* stored height for explicit ones, minus the elected slab base) — `nodes` is
|
||||
* required because a plane-bound wall's top lives on its level, not on the
|
||||
* wall record.
|
||||
*/
|
||||
export function clampToWall(
|
||||
wallNode: WallNode,
|
||||
@@ -43,11 +48,12 @@ export function clampToWall(
|
||||
localY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
): { clampedX: number; clampedY: number } {
|
||||
const dx = wallNode.end[0] - wallNode.start[0]
|
||||
const dz = wallNode.end[1] - wallNode.start[1]
|
||||
const wallLength = Math.sqrt(dx * dx + dz * dz)
|
||||
const wallHeight = wallNode.height ?? 2.5
|
||||
const wallHeight = resolveWallOpeningCeiling(wallNode, nodes)
|
||||
|
||||
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
|
||||
const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY))
|
||||
|
||||
Reference in New Issue
Block a user