viewer: Fix Dutch roof trim artifacts (#452)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* Fix spiral stair openings and fence handle arrows

* Implement roof trim planes and ridge vent clipping

* Fix mansard roof and ridge vent placement

* Fix mansard merged roof cutouts

* Fix Dutch roof gable overhang

* Refactor roof segment, ridge vent, and surface geometry

Remove Dutch ridge axis abstraction and rework roof edit system,
ridge vent clipping geometry, and roof surface placement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Simplify Dutch roof shape

* Add Dutch roof gable top geometry controls

* Fix Dutch roof slope material slots

* Render dutch roof tops as double-sided faces

* Add auto ridge vent toggle to roof segments

Track ridge vent auto-generation via an `autoRidgeVent` metadata flag so
geometry changes only regenerate default vents when enabled, treating
legacy segments with generated vents as auto-enabled for back-compat.
Expose a panel toggle to opt in/out per segment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Snap new walls to the floor below

Feed the walls of the level directly beneath the active one into the
draft snap pipeline as extra references, so a new wall can align with
the floor below. They share the same local XZ origin, and the list is
kept separate from the current-level walls so the measurement HUD and
wall splitting only act on the active level.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Set Dutch roof shape defaults on type switch

Seed the Dutch shape parameters (waist width/height/length, top rake
thickness/length) with sensible defaults whenever a segment is created
as or switched to Dutch, so the gablet is well-formed regardless of
leftover values from the previous roof type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Use green accent for corner and endpoint snap markers

Color the corner/endpoint snap markers and the vertical cursor pillar
green across the 2D floorplan beacon, the 3D alignment guide dots, and
the wall snap beacon so snap targets read as a consistent accent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add magnetic wall snapping to the roof tool

Snap roof draft corners onto wall corners, midpoints, crossings, and
bodies on the active level and the floor below, reusing the wall tool's
snap pipeline so the beacon and coloring match. The cursor's ground
dot/ring is hidden while a wall snap is active to avoid overlapping the
beacon glyph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Update auto-generated Next.js route types path

Regenerated next-env.d.ts now references ./.next/dev/types/routes.d.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Show cutaway outline while dragging roof trim

Slice an untrimmed segment volume generated from the live node instead of
the registry mesh, whose CSG rebuild lags a few frames behind the drag and
may still hold placeholder geometry — so the section outline now renders
deterministically. Use LineBasicNodeMaterial so the outline draws under the
WebGPU pipeline, and export generateRoofSegmentGeometry for the slice source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Fill and clip roof trim cutaway, gate it to active drag

Add a violet silhouette fill behind the cutaway outline, extend the
section slicing to angled diagonal/corner trims via a generic vertical
cut plane, and clip each slice to its footprint span so the infinite
plane no longer sprouts stray lines across the rest of the roof. The
cutaway now renders only while a trim handle is being dragged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Separate and extend Dutch roof end slopes

Pull the Dutch hip end slopes out of the watertight shingle shell into
their own slab wedge so they can be reshaped independently, and extend
each end slope inward up its own hip plane until the top edge meets the
gablet's inner triangle. Refactor roof-segment shape geometry into a
shared roof-segment-shape module.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Render roof trim cutaway as a material-only section cut

Replace the triangle-mesh slicer with a CSG intersection of a thin slab
against the untrimmed roof shell, so the cutaway shows red only on real
material (wall + deck bands) and leaves the hollow attic empty. Add an
analytic surface-edge outline, style both solid red like a SketchUp
section, and make the cutaway persist whenever a segment is trimmed.

Keep the merged roof shell visible during trim editing (re-trimmed live
from each segment's drag override) instead of swapping in the per-segment
meshes, whose abutting end-cap faces showed as stray white planes the
commit never had. Extend each slab past free cut-line ends only — trimmed
ends clamp to the cut line — so the red section stays inside the trim box.

Re-export INTERSECTION from the viewer CSG surface for the editor.

* Outline roof cutaway by fill silhouette, restyle to destructive red

Derive the section-cut outline from the fill geometry's edges
(EdgesGeometry) so it traces the real cut shape — wall/deck band
boundaries and the hollow-attic edge — instead of just the top surface
line. Drop the fill to 85% opacity and recolor both fill and outline to
the app's destructive red, matching the delete/destructive UI.

* Include roof accessories in trim clipping and red cutaway

Roof accessories (chimney, vents, skylight, dormer, gutter, downspout,
solar-panel, cupola) now slice at the trim plane like the roof shell and
appear in the red section-cut while dragging a trim handle:

- Export clipGeometryBySegmentTrim from the viewer as a reusable
  segment-local trim-clip primitive.
- Add a shared useSegmentTrimClippedGeometry hook + TrimClippedMesh
  wrapper (nodes) that slice accessory geometry by the host segment's
  live trim override, so the cut tracks the drag.
- Wire the clip into all 11 accessory renderers, including skylight
  glass panes and dormer window glass/frame/sill.
- Feed every hosted accessory mesh into the editor's red cutaway, welding
  triangle-soup geometry (e.g. ridge vent) so CSG INTERSECTION yields a
  cross-section.
- Register skylight in the scene-graph tree-node map so it shows in the
  outliner when placed on a roof.

Co-Authored-By: Claude <noreply@anthropic.com>

* Add smooth spline fences with editable curve handles

Fences can now be drawn as one continuous Catmull-Rom/Bezier curve via an
optional `path` (+ per-point `tangents`), selectable in a Straight/Curved
mode toggle. Selected spline fences expose draggable control-point dots
(hexagon) and symmetric tangent handles (circle) joined by a violet line,
editable in both 2D plan and 3D. Side-move arrows are dropped for splines.

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix dutch roof ridge vent handling

* Fix Dutch ridge vent placement and support

* Fix Dutch roof trim artifacts

* Fix Dutch roof trim preview geometry

* Tag roof trim overlay meshes with EDITOR_LAYER

Child meshes relied on a parent group's layer, which three.js does not
propagate, so the trim section/rail/plane overlays rendered on the scene
layer — getting inked/SSGI-darkened and leaking into thumbnail exports.

Co-Authored-By: Claude <noreply@anthropic.com>

* Apply Biome cleanup

* fix(core): address Dutch roof review feedback

* chore: apply biome check cleanup

* fix(core): relax Dutch roof surface helper input

* fix

* Fix biome checks and dev verification

* fixes

* Remove unsupported Biome noShadow override

* Improve roof interactions and fence editing

* Fix fence drag and ridge vent default handling

* editor: drop wall-snap debug log, gate curved-fence finish hint on draft start

Remove the leftover TEMP DIAGNOSTIC console.log in the wall tool's onMove
hot path.

Curved fences commit on a closing gesture (double-click / Enter) rather than
per-click, so surface a 'Finish curve' hint in the fence HUD — but only once a
point has been placed and a curve is actually in flight. The draft point count
is published from SplineFenceDraft into a small ephemeral editor store
(useFenceCurveDraft) that the contextual helper reads, mirroring the existing
useSegmentDraftChain pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
This commit is contained in:
Sudhir Yadav
2026-06-30 16:41:13 -04:00
committed by GitHub
co-authored by Claude Opus 4.8 Wassim SAMAD
parent c51e673421
commit bf25af6add
179 changed files with 10809 additions and 1467 deletions
@@ -97,7 +97,7 @@ function snapshotLinked(
const { nodes } = useScene.getState()
const out: LinkedFenceSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!node || node.type !== 'fence') continue
if (node?.type !== 'fence') continue
if (node.id === fenceId) continue
if ((node.parentId ?? null) !== parentId) continue
if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) continue
+82 -10
View File
@@ -1,6 +1,17 @@
import type { FenceNode as FenceNodeType, HandleDescriptor, NodeDefinition } from '@pascal-app/core'
import {
type FenceNode as FenceNodeType,
getFenceControlHandle,
type HandleDescriptor,
isSplineFence,
type NodeDefinition,
} from '@pascal-app/core'
import { buildFenceFloorplan } from './floorplan'
import { fenceCurveAffordance, fenceMoveEndpointAffordance } from './floorplan-affordances'
import {
fenceControlPointAffordance,
fenceCurveAffordance,
fenceMoveEndpointAffordance,
fenceTangentAffordance,
} from './floorplan-affordances'
import { fenceFloorplanMoveTarget } from './floorplan-move'
import { buildFenceGeometry } from './geometry'
import { fencePaint } from './paint'
@@ -57,7 +68,9 @@ function fenceSideMoveHandle(side: 'front' | 'back'): HandleDescriptor<FenceNode
return Math.atan2(-sign * normalZ, sign * normalX)
},
},
cursor: 'move',
// The move itself is constrained by `MoveFenceTool`; keep the visual as a
// one-direction chevron instead of the generic four-way move cross.
cursor: 'ew-resize',
}
}
@@ -111,13 +124,68 @@ function fenceCornerPicker(endpoint: 'start' | 'end'): HandleDescriptor<FenceNod
}
}
const fenceHandles: HandleDescriptor<FenceNodeType>[] = [
fenceSideMoveHandle('front'),
fenceSideMoveHandle('back'),
fenceHeightHandle(),
fenceCornerPicker('start'),
fenceCornerPicker('end'),
]
const TANGENT_HANDLE_ARM_SCALE = 3
function fenceControlPointPicker(index: number): HandleDescriptor<FenceNodeType> {
return {
kind: 'tap-action',
shape: 'corner-picker',
cursor: 'move',
nodeHeight: (n) => n.height ?? 1.8,
onActivate: (node, _scene, editor) => editor.engageControlPointMove(node, index),
placement: {
position: (n) => {
const point = n.path?.[index] ?? n.start
return [point[0], 0, point[1]]
},
},
}
}
function fenceTangentPicker(index: number, side: 'in' | 'out'): HandleDescriptor<FenceNodeType> {
const sign = side === 'out' ? 1 : -1
return {
kind: 'tap-action',
shape: 'corner-picker',
round: true,
cursor: 'move',
nodeHeight: (n) => (n.height ?? 1.8) * 0.6,
onActivate: (node, _scene, editor) => editor.engageTangentMove(node, index, side),
placement: {
position: (n) => {
const point = n.path?.[index] ?? n.start
if (!n.path) return [point[0], 0, point[1]]
const handle = getFenceControlHandle(n.path, n.tangents, index)
return [
point[0] + sign * handle.x * TANGENT_HANDLE_ARM_SCALE,
0,
point[1] + sign * handle.y * TANGENT_HANDLE_ARM_SCALE,
]
},
},
}
}
const fenceHandles = (node: FenceNodeType): HandleDescriptor<FenceNodeType>[] => {
if (isSplineFence(node) && node.path) {
return [
fenceHeightHandle(),
...node.path.flatMap((_, index) => [
fenceControlPointPicker(index),
fenceTangentPicker(index, 'out'),
fenceTangentPicker(index, 'in'),
]),
]
}
return [
fenceSideMoveHandle('front'),
fenceSideMoveHandle('back'),
fenceHeightHandle(),
fenceCornerPicker('start'),
fenceCornerPicker('end'),
]
}
/**
* Fence — Phase 5 batch kind. Stage B complete: `def.geometry` drives
@@ -209,6 +277,8 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
// pointer-up).
floorplanAffordances: {
'move-endpoint': fenceMoveEndpointAffordance,
'move-control-point': fenceControlPointAffordance,
'move-tangent': fenceTangentAffordance,
curve: fenceCurveAffordance,
},
// Body move on the fence is driven by the two `move-arrow` chevrons
@@ -225,6 +295,8 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
affordanceTools: {
curve: () => import('./curve-tool'),
'move-endpoint': () => import('./move-endpoint-tool'),
'move-control-point': () => import('./move-control-point-tool'),
'move-tangent': () => import('./move-tangent-tool'),
move: () => import('./move-tool'),
},
@@ -16,6 +16,7 @@ import {
type FencePlanPoint,
getSegmentGridStep,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
isSegmentLongEnough,
snapBuildingLocalToWorldGrid,
@@ -43,6 +44,13 @@ import {
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
type FenceEndpointPayload = { fenceId: AnyNodeId; endpoint: 'start' | 'end' }
type FenceControlPointPayload = { fenceId: AnyNodeId; index: number }
type FenceTangentPayload = { fenceId: AnyNodeId; index: number; side: 'in' | 'out' }
// Must match the floorplan builder's TANGENT_HANDLE_ARM_SCALE: the on-screen
// arm is this many times the raw tangent vector, so dividing the dragged
// offset back out recovers the stored tangent.
const TANGENT_HANDLE_ARM_SCALE = 3
function pointsNearlyEqual(a: FencePlanPoint, b: FencePlanPoint): boolean {
return (
@@ -88,7 +96,7 @@ function collectLinkedFences(
/**
* Fence curve sagitta drag — 1:1 mirror of `wallCurveAffordance`. Drag
* projects the pointer onto the chord normal to compute `curveOffset`,
* snaps to grid (Shift bypasses), clamps to `getMaxWallCurveOffset`,
* snaps to grid when that mode is active, clamps to `getMaxWallCurveOffset`,
* normalizes via `normalizeWallCurveOffset`. Same single-undo dance — the
* dispatcher handles snapshot / pause / resume around `apply`. Lives in
* the same file as the endpoint affordance to keep the two fence
@@ -105,17 +113,16 @@ export const fenceCurveAffordance: FloorplanAffordance<FenceNode> = {
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const snapStep = getSegmentGridStep()
const x = modifiers.shiftKey ? planPoint[0] : snapScalarToGrid(planPoint[0], snapStep)
const y = modifiers.shiftKey ? planPoint[1] : snapScalarToGrid(planPoint[1], snapStep)
const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0
const x = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0]
const y = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1]
const offsetFromMidpoint = -(
(x - chord.midpoint.x) * chord.normal.x +
(y - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = modifiers.shiftKey
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const snappedOffset =
snapStep > 0 ? snapScalarToGrid(offsetFromMidpoint, snapStep) : offsetFromMidpoint
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxOffset, Math.min(maxOffset, snappedOffset)),
@@ -136,6 +143,114 @@ export const fenceCurveAffordance: FloorplanAffordance<FenceNode> = {
},
}
/**
* Spline control-point drag — reshapes one point of the fence `path`. Grid
* snap follows the active mode; start/end stay pinned to the path ends so endpoint-
* dependent code stays valid. Publishes a live override per tick, commits the
* final path as one tracked change. No linked-fence cascade: a spline's shape
* is self-contained.
*/
export const fenceControlPointAffordance: FloorplanAffordance<FenceNode> = {
start({ node, payload }): FloorplanAffordanceSession {
const { index } = payload as FenceControlPointPayload
const fenceId = node.id as AnyNodeId
const originalPath: FencePlanPoint[] = (node.path ?? []).map((p) => [p[0], p[1]])
let lastPath = originalPath
const buildPatch = (point: FencePlanPoint): Record<string, unknown> => {
const nextPath = originalPath.map((p, i): FencePlanPoint => (i === index ? point : p))
lastPath = nextPath
const patch: Record<string, unknown> = { path: nextPath }
if (index === 0) patch.start = point
if (index === nextPath.length - 1) patch.end = point
return patch
}
return {
affectedIds: [fenceId],
apply({ planPoint, modifiers }) {
const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0
const x = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0]
const y = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1]
useLiveNodeOverrides.getState().set(fenceId, buildPatch([x, y]))
useScene.getState().markDirty(fenceId)
},
canCommit() {
return lastPath.length >= 2
},
commit() {
const data: Partial<FenceNode> = { path: lastPath }
data.start = lastPath[0]
data.end = lastPath[lastPath.length - 1]
useScene.getState().updateNodes([{ id: fenceId, data }])
useLiveNodeOverrides.getState().clear(fenceId)
},
}
},
}
/**
* Spline tangent-handle drag — bends the curve through one control point. The
* dragged end (in / out) gives the OUT-handle vector (negated for the IN end);
* the IN handle is always the mirror so the curve stays smooth (symmetric).
* The visual arm is `TANGENT_HANDLE_ARM_SCALE`× the stored vector, so we divide
* that factor out before storing. Writes `tangents[index]`, padding the array
* to the path length with nulls so untouched points keep their auto tangent.
*/
export const fenceTangentAffordance: FloorplanAffordance<FenceNode> = {
start({ node, payload }): FloorplanAffordanceSession {
const { index, side } = payload as FenceTangentPayload
const fenceId = node.id as AnyNodeId
const path = node.path ?? []
const anchor = path[index] ?? node.start
let lastTangents: Array<[number, number] | null> = (node.tangents ?? []).map((t) =>
t ? [t[0], t[1]] : null,
)
const buildTangents = (vec: [number, number]): Array<[number, number] | null> => {
const next: Array<[number, number] | null> = Array.from(
{ length: path.length },
(_, i) => lastTangents[i] ?? null,
)
next[index] = vec
lastTangents = next
return next
}
return {
affectedIds: [fenceId],
apply({ planPoint, modifiers }) {
const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0
const px = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0]
const py = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1]
// Arm vector from the anchor to the dragged handle, in plan meters.
let armX = px - anchor[0]
let armY = py - anchor[1]
// The IN end is the mirror, so its drag describes the negated OUT vector.
if (side === 'in') {
armX = -armX
armY = -armY
}
const vec: [number, number] = [
armX / TANGENT_HANDLE_ARM_SCALE,
armY / TANGENT_HANDLE_ARM_SCALE,
]
useLiveNodeOverrides.getState().set(fenceId, { tangents: buildTangents(vec) })
useScene.getState().markDirty(fenceId)
},
canCommit() {
return true
},
commit() {
useScene
.getState()
.updateNodes([{ id: fenceId, data: { tangents: lastTangents } as Partial<FenceNode> }])
useLiveNodeOverrides.getState().clear(fenceId)
},
}
},
}
export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
start({ node, payload, nodes }): FloorplanAffordanceSession {
const { endpoint } = payload as FenceEndpointPayload
+58 -23
View File
@@ -6,7 +6,12 @@ import {
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { getSegmentGridStep, isSegmentLongEnough, snapPointToGrid } from '@pascal-app/editor'
import {
getSegmentGridStep,
isGridSnapActive,
isSegmentLongEnough,
snapPointToGrid,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
type PlanPoint = [number, number]
@@ -15,7 +20,7 @@ function pointsEqual(a: PlanPoint, b: PlanPoint): boolean {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedFenceSnapshot = { id: AnyNodeId; start: PlanPoint; end: PlanPoint }
type LinkedFenceSnapshot = { id: AnyNodeId; start: PlanPoint; end: PlanPoint; path?: PlanPoint[] }
function getLinkedFenceSnapshots(args: {
fenceId: AnyNodeId
@@ -40,12 +45,21 @@ function getLinkedFenceSnapshots(args: {
id: fence.id as AnyNodeId,
start: [fence.start[0], fence.start[1]],
end: [fence.end[0], fence.end[1]],
path: fence.path?.map((point) => [point[0], point[1]]),
})
}
}
return snapshots
}
function translatePath(
path: PlanPoint[] | undefined,
dx: number,
dz: number,
): PlanPoint[] | undefined {
return path?.map((point) => [point[0] + dx, point[1] + dz])
}
/**
* 2D floor-plan body move for fence. Mirrors `wallFloorplanMoveTarget`
* but without bridge-wall planning: fence corners cascade through
@@ -76,23 +90,32 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node
let lastDelta: PlanPoint = [0, 0]
let lastNextStart: PlanPoint = originalStart
let lastNextEnd: PlanPoint = originalEnd
let lastNextPath: PlanPoint[] | undefined = node.path?.map((point) => [point[0], point[1]])
const projectLinked = (
snapshot: LinkedFenceSnapshot,
nextStart: PlanPoint,
nextEnd: PlanPoint,
): { start: PlanPoint; end: PlanPoint } => ({
start: pointsEqual(snapshot.start, originalStart)
dx: number,
dz: number,
): { start: PlanPoint; end: PlanPoint; path?: PlanPoint[] } => {
const start = pointsEqual(snapshot.start, originalStart)
? nextStart
: pointsEqual(snapshot.start, originalEnd)
? nextEnd
: snapshot.start,
end: pointsEqual(snapshot.end, originalStart)
: snapshot.start
const end = pointsEqual(snapshot.end, originalStart)
? nextStart
: pointsEqual(snapshot.end, originalEnd)
? nextEnd
: snapshot.end,
})
: snapshot.end
return {
start,
end,
path: translatePath(snapshot.path, dx, dz),
}
}
const session: FloorplanMoveTargetSession = {
affectedIds: [fenceId, ...linkedOriginals.map((l) => l.id)],
@@ -104,10 +127,8 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node
}
const rawDx = planPoint[0] - rawAnchor[0]
const rawDz = planPoint[1] - rawAnchor[1]
const step = getSegmentGridStep()
const nextStart: PlanPoint = modifiers.shiftKey
? [originalStart[0] + rawDx, originalStart[1] + rawDz]
: snapPointToGrid([originalStart[0] + rawDx, originalStart[1] + rawDz], step)
const step = isGridSnapActive() ? getSegmentGridStep() : 0
const nextStart = snapPointToGrid([originalStart[0] + rawDx, originalStart[1] + rawDz], step)
const dx = nextStart[0] - originalStart[0]
const dz = nextStart[1] - originalStart[1]
if (dx === lastDelta[0] && dz === lastDelta[1]) return
@@ -115,17 +136,29 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node
const nextEnd: PlanPoint = [originalEnd[0] + dx, originalEnd[1] + dz]
lastNextStart = nextStart
lastNextEnd = nextEnd
lastNextPath = translatePath(
node.path?.map((point) => [point[0], point[1]]),
dx,
dz,
)
const linkedUpdates = modifiers.altKey
? []
: linkedOriginals.map((l) => ({ id: l.id, ...projectLinked(l, nextStart, nextEnd) }))
: linkedOriginals.map((l) => ({
id: l.id,
...projectLinked(l, nextStart, nextEnd, dx, dz),
}))
useLiveNodeOverrides
.getState()
.setMany([
[fenceId, { start: nextStart, end: nextEnd }],
[fenceId, { start: nextStart, end: nextEnd, path: lastNextPath }],
...linkedUpdates.map(
(u) => [u.id, { start: u.start, end: u.end }] as [string, Record<string, unknown>],
(u) =>
[u.id, { start: u.start, end: u.end, path: u.path }] as [
string,
Record<string, unknown>,
],
),
])
const sceneState = useScene.getState()
@@ -152,20 +185,22 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node
data: {
start: lastNextStart,
end: lastNextEnd,
path: lastNextPath,
metadata: { ...originalMetadata, isNew: false },
} as Partial<FenceNode>,
}
: { id: fenceId, data: { start: lastNextStart, end: lastNextEnd } }
: { id: fenceId, data: { start: lastNextStart, end: lastNextEnd, path: lastNextPath } }
const linkedUpdates = linkedOriginals.map((l) => ({
id: l.id,
...projectLinked(l, lastNextStart, lastNextEnd),
...projectLinked(l, lastNextStart, lastNextEnd, lastDelta[0], lastDelta[1]),
}))
useScene
.getState()
.updateNodes([
fenceUpdate,
...linkedUpdates.map((u) => ({ id: u.id, data: { start: u.start, end: u.end } })),
])
useScene.getState().updateNodes([
fenceUpdate,
...linkedUpdates.map((u) => ({
id: u.id,
data: { start: u.start, end: u.end, path: u.path },
})),
])
const overrides = useLiveNodeOverrides.getState()
overrides.clear(fenceId)
for (const l of linkedOriginals) overrides.clear(l.id)
+150 -55
View File
@@ -1,11 +1,13 @@
import {
type FloorplanGeometry,
type GeometryContext,
getWallCurveFrameAt,
getWallCurveLength,
getFenceCenterlineFrameAt,
getFenceCenterlineLength,
getFenceControlHandle,
getWallMidpointHandlePoint,
isCurvedWall,
sampleWallCenterline,
isSplineFence,
sampleFenceCenterline,
} from '@pascal-app/core'
import type { FenceNode } from './schema'
@@ -32,14 +34,22 @@ import type { FenceNode } from './schema'
* in the legacy panel and is fence-specific, so it lives with the kind.
*/
// The tangent handle arm is drawn this many times longer than the raw curve
// handle vector so it's easy to grab on screen even at the default (small)
// tangent. The `move-tangent` affordance divides this factor back out so the
// stored tangent matches the visual arm length. Must stay in sync with the
// 3D tool's arm scale.
const TANGENT_HANDLE_ARM_SCALE = 3
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function getFloorplanFenceLength(fence: FenceNode): number {
return isCurvedWall(fence)
? getWallCurveLength(fence)
: Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1])
if (isSplineFence(fence) || isCurvedWall(fence)) {
return getFenceCenterlineLength(fence)
}
return Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1])
}
/**
@@ -217,14 +227,15 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
const isActive = isSelected || isHighlighted
const showInteractiveChrome = isActive || isHovered
// Centerline path — sampled for curved fences so the underlay /
// Centerline path — sampled for spline / arc fences so the underlay /
// accent / glow all trace the same shape.
const centerlinePoints = isCurvedWall(node)
? sampleWallCenterline(node, 24)
: [
{ x: node.start[0], y: node.start[1] },
{ x: node.end[0], y: node.end[1] },
]
const centerlinePoints =
isSplineFence(node) || isCurvedWall(node)
? sampleFenceCenterline(node, 24)
: [
{ x: node.start[0], y: node.start[1] },
{ x: node.end[0], y: node.end[1] },
]
const pathD = buildCenterlinePathD(centerlinePoints)
// Stroke shifts: selected wins; hover (not selected) → `wallHoverStroke`
@@ -259,7 +270,7 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
// still sees end posts (matches legacy).
const markerTs = getFloorplanFenceMarkerTs(node)
const markerFrames = markerTs.map((t) => {
const frame = getWallCurveFrameAt(node, t)
const frame = getFenceCenterlineFrameAt(node, t)
return {
point: frame.point,
angle: Math.atan2(frame.tangent.y, frame.tangent.x),
@@ -329,36 +340,113 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
)
}
// 5. Hit-line for click detection.
children.push({
kind: 'hit-line',
x1: node.start[0],
y1: node.start[1],
x2: node.end[0],
y2: node.end[1],
strokeWidthPx: 18,
cursor: 'pointer',
})
// 5. Hit-line(s) for click detection. A straight/arc fence uses a single
// chord-spanning line; a spline fence emits one short hit-line per sampled
// span so the clickable region follows the curve instead of cutting the
// chord (there's no curved `hit-path` primitive yet).
if (isSplineFence(node)) {
for (let i = 1; i < centerlinePoints.length; i += 1) {
const a = centerlinePoints[i - 1]!
const b = centerlinePoints[i]!
children.push({
kind: 'hit-line',
x1: a.x,
y1: a.y,
x2: b.x,
y2: b.y,
strokeWidthPx: 18,
cursor: 'pointer',
})
}
} else {
children.push({
kind: 'hit-line',
x1: node.start[0],
y1: node.start[1],
x2: node.end[0],
y2: node.end[1],
strokeWidthPx: 18,
cursor: 'pointer',
})
}
// 6. Endpoint handles + side move-arrows + curve handle + length when
// selected. Mirrors the wall builder so fences gain the same set of
// in-plan affordances (drag endpoints, drag body via either side
// arrow, drag the midpoint sagitta to curve).
if (isSelected) {
children.push({
kind: 'endpoint-handle',
point: [node.start[0], node.start[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'start' as const },
})
children.push({
kind: 'endpoint-handle',
point: [node.end[0], node.end[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'end' as const },
})
if (isSplineFence(node) && node.path) {
// Spline fence: per control point draw (a) the symmetric tangent line
// through the point with a small handle dot on each end — dragging an
// end bends the curve on both sides via `move-tangent` — and (b) the
// larger control-point dot itself, which moves the point.
for (let i = 0; i < node.path.length; i += 1) {
const point = node.path[i]!
const handle = getFenceControlHandle(node.path, node.tangents, i)
// Scale the on-screen handle arm so even the default (small) tangent
// is grabbable; the affordance divides this back out on apply.
const armX = handle.x * TANGENT_HANDLE_ARM_SCALE
const armY = handle.y * TANGENT_HANDLE_ARM_SCALE
const out: [number, number] = [point[0] + armX, point[1] + armY]
const inn: [number, number] = [point[0] - armX, point[1] - armY]
// Connecting line (the "tangent" through the point). Violet to match
// the 3D tangent line + the handle dots.
children.push({
kind: 'line',
x1: inn[0],
y1: inn[1],
x2: out[0],
y2: out[1],
stroke: '#8381ed',
strokeWidth: 1.25,
strokeOpacity: 0.85,
vectorEffect: 'non-scaling-stroke',
})
// Handle dot on each end. Both drive the same `move-tangent`
// affordance; `side` tells it which end is being dragged so the
// stored OUT vector gets the correct sign.
children.push({
kind: 'endpoint-handle',
point: out,
state: 'idle',
variant: 'curve',
affordance: 'move-tangent',
payload: { fenceId: node.id, index: i, side: 'out' as const },
})
children.push({
kind: 'endpoint-handle',
point: inn,
state: 'idle',
variant: 'curve',
affordance: 'move-tangent',
payload: { fenceId: node.id, index: i, side: 'in' as const },
})
// The control-point dot last so it sits on top of the tangent line.
children.push({
kind: 'endpoint-handle',
point: [point[0], point[1]],
state: 'idle',
affordance: 'move-control-point',
payload: { fenceId: node.id, index: i },
})
}
} else {
children.push({
kind: 'endpoint-handle',
point: [node.start[0], node.start[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'start' as const },
})
children.push({
kind: 'endpoint-handle',
point: [node.end[0], node.end[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'end' as const },
})
}
// Two perpendicular `move-arrow` chevrons at the fence midpoint.
// No `affordance` → the registry layer routes pointer-down through
@@ -371,7 +459,8 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
const dz = node.end[1] - node.start[1]
const lineLength = Math.hypot(dx, dz)
if (lineLength > 1e-6) {
const frame = isCurvedWall(node) ? getWallCurveFrameAt(node, 0.5) : null
const frame =
isSplineFence(node) || isCurvedWall(node) ? getFenceCenterlineFrameAt(node, 0.5) : null
const midX = frame ? frame.point.x : (node.start[0] + node.end[0]) / 2
const midZ = frame ? frame.point.y : (node.start[1] + node.end[1]) / 2
const nx = frame ? frame.normal.x : -dz / lineLength
@@ -393,29 +482,35 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
// Curve sagitta handle — teal dot at the visual midpoint that drives
// `curveOffset`. Routes through `fenceCurveAffordance`. Fences host
// no children, so there's no equivalent of wall's curve-blocking
// check to gate this.
const curveHandle = getWallMidpointHandlePoint(node)
children.push({
kind: 'endpoint-handle',
point: [curveHandle.x, curveHandle.y],
state: 'idle',
variant: 'curve',
affordance: 'curve',
payload: { fenceId: node.id },
})
// check to gate this. Suppressed for spline fences: the single sagitta
// is meaningless against a multi-point curve.
if (!isSplineFence(node)) {
const curveHandle = getWallMidpointHandlePoint(node)
children.push({
kind: 'endpoint-handle',
point: [curveHandle.x, curveHandle.y],
state: 'idle',
variant: 'curve',
affordance: 'curve',
payload: { fenceId: node.id },
})
}
const length = getWallCurveLength(node)
const length = getFloorplanFenceLength(node)
if (length >= 0.1) {
const midX = (node.start[0] + node.end[0]) / 2
const midZ = (node.start[1] + node.end[1]) / 2
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const labelFrame =
isSplineFence(node) || isCurvedWall(node) ? getFenceCenterlineFrameAt(node, 0.5) : null
const midX = labelFrame ? labelFrame.point.x : (node.start[0] + node.end[0]) / 2
const midZ = labelFrame ? labelFrame.point.y : (node.start[1] + node.end[1]) / 2
const angle = labelFrame
? Math.atan2(labelFrame.tangent.y, labelFrame.tangent.x)
: Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
children.push({
kind: 'dimension-label',
cx: midX,
cy: midZ,
text: `${Number.parseFloat(length.toFixed(2))}m`,
angle: Math.atan2(dz, dx),
angle,
})
}
}
@@ -0,0 +1,129 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import {
CursorSphere,
getSegmentGridStep,
isGridSnapActive,
markToolCancelConsumed,
snapScalarToGrid,
triggerSFX,
useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
export const MoveFenceControlPointTool: React.FC<{
target: { fence: FenceNode; index: number }
}> = ({ target }) => {
const fenceId = target.fence.id as AnyNodeId
const index = target.index
const originalPath = target.fence.path ?? []
const originalPoint = originalPath[index] ?? target.fence.start
const [cursor, setCursor] = useState<[number, number, number]>([
originalPoint[0],
0,
originalPoint[1],
])
useEffect(() => {
pauseSceneHistory(useScene)
let committed = false
let lastPoint: [number, number] = [originalPoint[0], originalPoint[1]]
const buildPatch = (point: [number, number]): Partial<FenceNode> => {
const nextPath = originalPath.map((pathPoint, pathIndex) =>
pathIndex === index ? point : pathPoint,
)
const patch: Partial<FenceNode> = { path: nextPath }
if (index === 0) patch.start = point
if (index === nextPath.length - 1) patch.end = point
return patch
}
const previewPath = (point: [number, number]) => {
useLiveNodeOverrides.getState().set(fenceId, buildPatch(point))
useScene.getState().markDirty(fenceId)
}
const restore = () => {
useLiveNodeOverrides.getState().clear(fenceId)
useScene.getState().markDirty(fenceId)
}
const exit = (didCommit: boolean) => {
if (didCommit) triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [fenceId] })
useInteractionScope
.getState()
.endIf(
(scope) =>
scope.kind === 'reshaping' &&
scope.reshape === 'control-point' &&
scope.nodeId === fenceId &&
scope.index === index,
)
}
const onGridMove = (event: GridEvent) => {
const step = isGridSnapActive() ? getSegmentGridStep() : 0
const x = step > 0 ? snapScalarToGrid(event.localPosition[0], step) : event.localPosition[0]
const z = step > 0 ? snapScalarToGrid(event.localPosition[2], step) : event.localPosition[2]
if (x !== lastPoint[0] || z !== lastPoint[1]) {
if (step > 0) triggerSFX('sfx:grid-snap')
lastPoint = [x, z]
setCursor([x, 0, z])
previewPath([x, z])
}
}
const onGridClick = (event: GridEvent) => {
committed = true
resumeSceneHistory(useScene)
useScene.getState().updateNode(fenceId, buildPatch(lastPoint))
useLiveNodeOverrides.getState().clear(fenceId)
useScene.getState().markDirty(fenceId)
exit(true)
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restore()
resumeSceneHistory(useScene)
markToolCancelConsumed()
exit(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!committed) {
restore()
resumeSceneHistory(useScene)
}
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [fenceId, index, originalPath, originalPoint, originalPoint[0], originalPoint[1]])
return (
<group>
<CursorSphere position={cursor} showTooltip={false} />
</group>
)
}
export default MoveFenceControlPointTool
@@ -0,0 +1,132 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import {
CursorSphere,
getSegmentGridStep,
isGridSnapActive,
markToolCancelConsumed,
snapScalarToGrid,
triggerSFX,
useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
const TANGENT_HANDLE_ARM_SCALE = 3
export const MoveFenceTangentTool: React.FC<{
target: { fence: FenceNode; index: number; side: 'in' | 'out' }
}> = ({ target }) => {
const fenceId = target.fence.id as AnyNodeId
const { index, side } = target
const anchor = target.fence.path?.[index] ?? target.fence.start
const [cursor, setCursor] = useState<[number, number, number]>([anchor[0], 0, anchor[1]])
useEffect(() => {
pauseSceneHistory(useScene)
let committed = false
const originalTangents: Array<[number, number] | null> = (target.fence.tangents ?? []).map(
(t) => (t ? [t[0], t[1]] : null),
)
let lastTangents = originalTangents
const writeTangent = (vector: [number, number]) => {
const pathLength = target.fence.path?.length ?? originalTangents.length
const next: Array<[number, number] | null> = Array.from(
{ length: pathLength },
(_, tangentIndex) => lastTangents[tangentIndex] ?? null,
)
next[index] = vector
lastTangents = next
useLiveNodeOverrides.getState().set(fenceId, { tangents: next })
useScene.getState().markDirty(fenceId)
}
const restore = () => {
useLiveNodeOverrides.getState().clear(fenceId)
useScene.getState().markDirty(fenceId)
lastTangents = originalTangents
}
const exit = (didCommit: boolean) => {
if (didCommit) triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [fenceId] })
useInteractionScope
.getState()
.endIf(
(scope) =>
scope.kind === 'reshaping' &&
scope.reshape === 'tangent' &&
scope.nodeId === fenceId &&
scope.index === index &&
scope.side === side,
)
}
const onGridMove = (event: GridEvent) => {
const step = isGridSnapActive() ? getSegmentGridStep() : 0
const px = step > 0 ? snapScalarToGrid(event.localPosition[0], step) : event.localPosition[0]
const pz = step > 0 ? snapScalarToGrid(event.localPosition[2], step) : event.localPosition[2]
setCursor([px, 0, pz])
let armX = px - anchor[0]
let armZ = pz - anchor[1]
if (side === 'in') {
armX = -armX
armZ = -armZ
}
writeTangent([armX / TANGENT_HANDLE_ARM_SCALE, armZ / TANGENT_HANDLE_ARM_SCALE])
}
const onGridClick = (event: GridEvent) => {
committed = true
const finalTangents = lastTangents
resumeSceneHistory(useScene)
useScene.getState().updateNode(fenceId, { tangents: finalTangents })
useLiveNodeOverrides.getState().clear(fenceId)
useScene.getState().markDirty(fenceId)
lastTangents = finalTangents
exit(true)
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restore()
resumeSceneHistory(useScene)
markToolCancelConsumed()
exit(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!committed) {
restore()
resumeSceneHistory(useScene)
}
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [anchor[0], anchor[1], fenceId, index, side, target.fence])
return (
<group>
<CursorSphere position={cursor} showTooltip={false} />
</group>
)
}
export default MoveFenceTangentTool
+110 -37
View File
@@ -7,7 +7,10 @@ import {
type FenceNode,
type GridEvent,
getPerpendicularWallMoveAxis,
isCurvedWall,
isSplineFence,
type LevelNode,
useLiveNodeOverrides,
useScene,
type WallMoveAxis,
type WallNode,
@@ -15,6 +18,8 @@ import {
import {
CursorSphere,
consumePlacementDragRelease,
getSegmentGridStep,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
snapFenceDraftPoint,
@@ -27,10 +32,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Phase 5 Stage D — fence whole-move tool.
*
* Live-drag pattern: translate the fence via direct scene updates while
* temporal history is paused. On commit we restore the original, resume
* history, apply the final position (single undo step), then re-pause.
* `constrainWallMoveDeltaToAxis` keeps moves axis-aligned.
* Live-drag pattern: preview data-driven reshapes through live node
* overrides. On commit we write the final position once for a single undo
* step.
* Straight fences use `constrainWallMoveDeltaToAxis` to keep side-moves
* perpendicular; curved fences translate freely because their path shape
* already carries direction.
*
* Wired via `def.affordanceTools.move`. The editor's `MoveTool`
* dispatcher picks this up before its legacy chain.
@@ -43,6 +50,7 @@ type LinkedFenceSnapshot = {
id: FenceNode['id']
start: [number, number]
end: [number, number]
path?: [number, number][]
}
function getLinkedFenceSnapshots(args: {
@@ -70,11 +78,32 @@ function getLinkedFenceSnapshots(args: {
id: node.id,
start: [...node.start] as [number, number],
end: [...node.end] as [number, number],
path: node.path?.map((point) => [...point] as [number, number]),
})
}
return snapshots
}
function translatePath(
path: [number, number][] | undefined,
deltaX: number,
deltaZ: number,
): [number, number][] | undefined {
return path?.map((point) => [point[0] + deltaX, point[1] + deltaZ])
}
function projectLinkedPath(
path: [number, number][] | undefined,
start: [number, number],
end: [number, number],
): [number, number][] | undefined {
if (!path || path.length === 0) return path
const nextPath = path.map((point) => [...point] as [number, number])
nextPath[0] = start
nextPath[nextPath.length - 1] = end
return nextPath
}
function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[],
originalStart: [number, number],
@@ -82,19 +111,25 @@ function getLinkedFenceUpdates(
nextStart: [number, number],
nextEnd: [number, number],
) {
return linkedFences.map((fence) => ({
id: fence.id,
start: samePoint(fence.start, originalStart)
return linkedFences.map((fence) => {
const start = samePoint(fence.start, originalStart)
? nextStart
: samePoint(fence.start, originalEnd)
? nextEnd
: fence.start,
end: samePoint(fence.end, originalStart)
: fence.start
const end = samePoint(fence.end, originalStart)
? nextStart
: samePoint(fence.end, originalEnd)
? nextEnd
: fence.end,
}))
: fence.end
return {
id: fence.id,
start,
end,
path: projectLinkedPath(fence.path, start, end),
}
})
}
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
@@ -102,6 +137,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
const originalPathRef = useRef(node.path?.map((point) => [...point] as [number, number]))
const meta =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>)
@@ -119,9 +155,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
const previewRef = useRef<{
start: [number, number]
end: [number, number]
path?: [number, number][]
} | null>(null)
const canMoveFreely = isSplineFence(node) || isCurvedWall(node)
const moveAxisRef = useRef<WallMoveAxis | null>(
getPerpendicularWallMoveAxis(node.start, node.end),
canMoveFreely ? null : getPerpendicularWallMoveAxis(node.start, node.end),
)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
@@ -155,12 +196,38 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
updates: Array<{
id: FenceNode['id']
start: [number, number]
end: [number, number]
path?: [number, number][]
}>,
) => {
useLiveNodeOverrides
.getState()
.setMany(
updates.map((entry) => [
entry.id as AnyNodeId,
{ start: entry.start, end: entry.end, path: entry.path },
]),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyCommittedUpdates = (
updates: Array<{
id: FenceNode['id']
start: [number, number]
end: [number, number]
path?: [number, number][]
}>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
data: { start: entry.start, end: entry.end, path: entry.path },
})),
)
for (const entry of updates) {
@@ -169,19 +236,27 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}
const restoreOriginal = () => {
applyNodePreview([
{ id: fenceId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
const overrides = useLiveNodeOverrides.getState()
overrides.clear(fenceId)
for (const linkedFence of linkedOriginalsRef.current) {
overrides.clear(linkedFence.id)
}
useScene.getState().markDirty(fenceId)
for (const linkedFence of linkedOriginalsRef.current) {
useScene.getState().markDirty(linkedFence.id as AnyNodeId)
}
}
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
previewRef.current = { start: nextStart, end: nextEnd }
const deltaX = nextStart[0] - originalStart[0]
const deltaZ = nextStart[1] - originalStart[1]
const nextPath = translatePath(originalPathRef.current, deltaX, deltaZ)
previewRef.current = { start: nextStart, end: nextEnd, path: nextPath }
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
const previewUpdates = [
{ id: fenceId, start: nextStart, end: nextEnd },
{ id: fenceId, start: nextStart, end: nextEnd, path: nextPath },
...getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
@@ -195,18 +270,19 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}
const onGridMove = (event: GridEvent) => {
const bypassSnap = event.nativeEvent?.shiftKey === true
const gridSnapActive = isGridSnapActive()
const magneticSnapActive = isMagneticSnapActive()
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
ignoreFenceIds: [fenceId],
bypassSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
magnetic: magneticSnapActive,
step: gridSnapActive ? getSegmentGridStep() : 0,
})
if (
!bypassSnap &&
(gridSnapActive || magneticSnapActive) &&
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
@@ -217,11 +293,11 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
localX - anchor[0],
localZ - anchor[1],
moveAxisRef.current,
)
const rawDeltaX = localX - anchor[0]
const rawDeltaZ = localZ - anchor[1]
const [deltaX, deltaZ] = canMoveFreely
? [rawDeltaX, rawDeltaZ]
: constrainWallMoveDeltaToAxis(rawDeltaX, rawDeltaZ, moveAxisRef.current)
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
@@ -245,13 +321,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
return
}
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
restoreOriginal()
useScene.temporal.getState().resume()
applyNodePreview([
{ id: fenceId, start: preview.start, end: preview.end },
applyCommittedUpdates([
{ id: fenceId, start: preview.start, end: preview.end, path: preview.path },
...getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
@@ -260,6 +332,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
preview.end,
),
])
restoreOriginal()
useScene.temporal.getState().pause()
triggerSFX('sfx:item-place')
@@ -316,7 +389,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [exitMoveMode, node])
}, [exitMoveMode, node, canMoveFreely])
return (
<group>
+15 -3
View File
@@ -1,4 +1,4 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import { isSplineFence, type ParametricDescriptor } from '@pascal-app/core'
import { FenceCurveEditor, FenceLengthEditor } from './inspector-editors'
import type { FenceNode } from './schema'
@@ -37,8 +37,20 @@ export const fenceParametrics: ParametricDescriptor<FenceNode> = {
{
label: 'Dimensions',
fields: [
{ key: 'length', kind: 'custom', component: FenceLengthEditor },
{ key: 'curve', kind: 'custom', component: FenceCurveEditor },
// Length / Curve drive start/end + the single sagitta — meaningless
// for a multi-point spline fence, so hide them when `path` is set.
{
key: 'length',
kind: 'custom',
component: FenceLengthEditor,
visibleIf: (n) => !isSplineFence(n),
},
{
key: 'curve',
kind: 'custom',
component: FenceCurveEditor,
visibleIf: (n) => !isSplineFence(n),
},
{ key: 'height', kind: 'number', unit: 'm', min: 0.4, max: 4, step: 0.05 },
{ key: 'thickness', kind: 'number', unit: 'm', min: 0.03, max: 0.5, step: 0.005 },
],
+142 -1
View File
@@ -6,10 +6,12 @@ import {
emitter,
type FenceNode,
type GridEvent,
getTwoPointFenceCurveTangents,
getWallMiterBoundaryPoints,
type LevelNode,
type Point2D,
resolveAlignment,
sampleFenceSpline,
useScene,
type WallMiterData,
type WallNode,
@@ -17,6 +19,7 @@ import {
import {
CursorSphere,
createFenceOnCurrentLevel,
createSplineFenceOnCurrentLevel,
EDITOR_LAYER,
type FencePlanPoint,
formatAngleRadians,
@@ -24,14 +27,18 @@ import {
getAngleArcToSegmentReference,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
getSegmentGridStep,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
type SegmentAngleReference,
snapFenceDraftPoint,
snapScalarToGrid,
triggerSFX,
useAlignmentGuides,
useEditor,
useFenceCurveDraft,
useSegmentDraftChain,
} from '@pascal-app/editor'
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
@@ -418,7 +425,7 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } {
const { nodes } = useScene.getState()
if (!currentLevelId) return { walls: [], fences: [] }
const levelNode = nodes[currentLevelId]
if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] }
if (levelNode?.type !== 'level') return { walls: [], fences: [] }
const children = (levelNode as LevelNode).children.map((childId) => nodes[childId])
return {
walls: children.filter((n): n is WallNode => n?.type === 'wall'),
@@ -427,6 +434,14 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } {
}
export const FenceTool: React.FC = () => {
const fenceMode = useEditor((s) => s.continuationByContext.fence)
if (fenceMode === 'curved') {
return <SplineFenceDraft />
}
return <StraightFenceTool />
}
const StraightFenceTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
// A placed preset seeds `toolDefaults.fence` before the tool mounts, so
@@ -693,6 +708,132 @@ export const FenceTool: React.FC = () => {
)
}
const SPLINE_PREVIEW_COLOR = '#8381ed'
const SPLINE_PREVIEW_SEGMENTS = 40
const SplineFenceDraft: React.FC = () => {
const previewHeight =
typeof useEditor.getState().toolDefaults.fence?.height === 'number'
? (useEditor.getState().toolDefaults.fence?.height as number)
: FENCE_PREVIEW_HEIGHT
const [draftPoints, setDraftPoints] = useState<FencePlanPoint[]>([])
const [cursor, setCursor] = useState<FencePlanPoint | null>(null)
const draftRef = useRef(draftPoints)
draftRef.current = draftPoints
// Mirror the draft length into the HUD store so the "finish curve" hint only
// shows once drafting has started; always clear it when the tool unmounts.
useEffect(() => {
useFenceCurveDraft.getState().setPointCount(draftPoints.length)
}, [draftPoints])
useEffect(() => () => useFenceCurveDraft.getState().reset(), [])
useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), [])
useEffect(() => {
const snapPoint = (local: FencePlanPoint): FencePlanPoint => {
const step = isGridSnapActive() ? getSegmentGridStep() : 0
if (step <= 0) return local
return [snapScalarToGrid(local[0], step), snapScalarToGrid(local[1], step)]
}
const commit = () => {
const points = draftRef.current
if (points.length >= 2) {
const created = createSplineFenceOnCurrentLevel(points)
if (created) {
triggerSFX('sfx:item-place')
// Once the new curve fence is selected for direct editing, leave
// placement mode so the toolbar matches the active interaction.
useViewer.getState().setSelection({ selectedIds: [created.id] })
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}
}
setDraftPoints([])
setCursor(null)
}
const onMove = (event: GridEvent) => {
setCursor(snapPoint([event.localPosition[0], event.localPosition[2]]))
}
const onClick = (event: GridEvent) => {
if (event.nativeEvent.detail >= 2) {
commit()
return
}
const point = snapPoint([event.localPosition[0], event.localPosition[2]])
triggerSFX('sfx:grid-snap')
setDraftPoints((prev) => [...prev, point])
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter') commit()
}
const onCancel = () => {
if (draftRef.current.length === 0) return
markToolCancelConsumed()
setDraftPoints((prev) => prev.slice(0, -1))
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
}
}, [])
const previewPoints = cursor ? [...draftPoints, cursor] : draftPoints
const curveGeometry = useMemo(() => {
if (previewPoints.length < 2) return null
const sampled = sampleFenceSpline(
previewPoints,
getTwoPointFenceCurveTangents(previewPoints),
SPLINE_PREVIEW_SEGMENTS,
)
return new BufferGeometry().setFromPoints(
sampled.map((point) => new Vector3(point.x, previewHeight, point.y)),
)
}, [previewHeight, previewPoints])
return (
<group>
{cursor && <CursorSphere height={previewHeight} position={[cursor[0], 0, cursor[1]]} />}
{draftPoints.map((point, index) => (
<mesh
key={`fence-spline-pt-${index}`}
layers={EDITOR_LAYER}
position={[point[0], previewHeight, point[1]]}
>
<sphereGeometry args={[0.07, 16, 12]} />
<meshBasicMaterial color={SPLINE_PREVIEW_COLOR} depthTest={false} />
</mesh>
))}
{curveGeometry && (
// @ts-expect-error - R3F accepts Three line primitives here.
<line frustumCulled={false} geometry={curveGeometry} layers={EDITOR_LAYER} renderOrder={2}>
<lineBasicNodeMaterial
color={SPLINE_PREVIEW_COLOR}
depthTest={false}
depthWrite={false}
linewidth={2}
opacity={0.95}
transparent
/>
</line>
)}
</group>
)
}
function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) {
const geometry = useMemo(() => {
const segmentCount = Math.max(