editor: green/red placement box for shelf + tool sync for presets (#366)

* feat(editor): shelf placement validity box + sync tool for positioned presets

Give the generic move tool a green/red footprint box for shelf placement,
matching the GLB item cursor, and drop the vertical-arrow CursorSphere for
shelves. Box colour comes from canPlaceOnFloor; an invalid (red) drop is
refused unless Shift forces it, and R/T play the rotate sfx. Re-sync the box
transform on node change so a re-armed clone isn't left at the previous
rotation/position.

Extract the box wireframe geometry helpers into a shared
placement-box-geometry module reused by the item coordinator and the new
declarative PlacementBox component. Export the Tool type so host apps can set
the active tool for a positioned preset.

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

* chore: apply biome formatting + ignore generated next-env.d.ts

Format files that predate the current biome config (lineWidth 100, single
quotes, semicolons as-needed) so they stop showing as dirty on every checkout.
Formatting only — no behavior change.

Also exclude **/next-env.d.ts from biome: Next regenerates it (double quotes +
semicolon) on every build, so biome kept reformatting it into a perpetual dirty
diff. Ignoring it lets Next own the file.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-03 09:52:57 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent e741ba677a
commit c3199e7540
22 changed files with 400 additions and 185 deletions
+1 -1
View File
@@ -4,8 +4,8 @@ import {
type HandleDescriptor,
type NodeDefinition,
} from '@pascal-app/core'
import { buildBoxVentFloorplan } from './floorplan'
import { surfacePaintCapability } from '../shared/surface-paint'
import { buildBoxVentFloorplan } from './floorplan'
import { boxVentParametrics } from './parametrics'
import { BoxVentNode } from './schema'
+1 -1
View File
@@ -1,7 +1,7 @@
import {
type AnyNodeId,
type ChimneyNode as ChimneyNodeType,
ChimneyNode as ChimneyNodeSchema,
type ChimneyNode as ChimneyNodeType,
getActiveRoofHeight,
type HandleDescriptor,
type NodeDefinition,
+1 -6
View File
@@ -183,12 +183,7 @@ export function buildChimneyFloorplan(
if (node.flueShape === 'square') {
children.push({
kind: 'polygon',
points: [
toPlan(fx - r, -r),
toPlan(fx + r, -r),
toPlan(fx + r, r),
toPlan(fx - r, r),
],
points: [toPlan(fx - r, -r), toPlan(fx + r, -r), toPlan(fx + r, r), toPlan(fx - r, r)],
fill: 'none',
stroke: flueStroke,
strokeWidth: lineWidth * 0.8,
+1 -7
View File
@@ -169,13 +169,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
ref={ref}
rotation-y={node.rotation ?? 0}
>
<mesh
castShadow
geometry={geometry}
material={material}
name="dormer-body"
receiveShadow
/>
<mesh castShadow geometry={geometry} material={material} name="dormer-body" receiveShadow />
<DormerWindowAssembly
frameMaterial={frameSideMat}
glassMaterial={glassMat}
@@ -140,11 +140,7 @@ const DormerWindowAssembly = ({
// the sill always extrudes along the group's local +Z, so its position
// no longer needs to flip per-face.
const renderFace = (zPos: number, yRot: number, keyPrefix: string) => (
<group
name={`dormer-window-${keyPrefix}`}
position={[winX, winY, zPos]}
rotation-y={yRot}
>
<group name={`dormer-window-${keyPrefix}`} position={[winX, winY, zPos]} rotation-y={yRot}>
{winGeo.glassPanes.map((pane, i) => (
<mesh
geometry={pane.geo}
+1 -5
View File
@@ -81,11 +81,7 @@ function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor<GutterNode
}
},
placement: {
position: (n) => [
sign * (n.length / 2 + SIDE_HANDLE_OFFSET),
getBodyMidY(n),
getRimZ(n),
],
position: (n) => [sign * (n.length / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), getRimZ(n)],
rotationY: () => (side === 'right' ? 0 : Math.PI),
},
}
+1 -1
View File
@@ -4,8 +4,8 @@ import {
RidgeVentNode as RidgeVentNodeSchema,
type RidgeVentNode as RidgeVentNodeType,
} from '@pascal-app/core'
import { buildRidgeVentFloorplan } from './floorplan'
import { surfacePaintCapability } from '../shared/surface-paint'
import { buildRidgeVentFloorplan } from './floorplan'
import { ridgeVentParametrics } from './parametrics'
import { RidgeVentNode } from './schema'
+10 -9
View File
@@ -96,7 +96,10 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan {
const rot = -(roof.rotation + seg.rotation)
const cos = Math.cos(rot)
const sin = Math.sin(rot)
const tp = (lx: number, lz: number): Pt => [segCx + lx * cos - lz * sin, segCz + lx * sin + lz * cos]
const tp = (lx: number, lz: number): Pt => [
segCx + lx * cos - lz * sin,
segCz + lx * sin + lz * cos,
]
const hw = Math.max(seg.width, 0.01) / 2
const hd = Math.max(seg.depth, 0.01) / 2
const lw = getRoofSegmentPlanLinework(seg)
@@ -110,7 +113,10 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan {
hips: lw.hips.map(mapSeg),
breaks: lw.breaks.map(mapSeg),
slope: lw.slope
? { tail: tp(lw.slope.tail[0], lw.slope.tail[1]), head: tp(lw.slope.head[0], lw.slope.head[1]) }
? {
tail: tp(lw.slope.tail[0], lw.slope.tail[1]),
head: tp(lw.slope.head[0], lw.slope.head[1]),
}
: null,
}
}
@@ -133,13 +139,8 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan {
* group is decorative (`pointerEvents: 'none'`) — clicks fall through to the
* segment hit-targets.
*/
export function buildRoofFloorplan(
node: RoofNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const segments = ctx.children.filter(
(c): c is RoofSegmentNode => c.type === 'roof-segment',
)
export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): FloorplanGeometry | null {
const segments = ctx.children.filter((c): c is RoofSegmentNode => c.type === 'roof-segment')
if (segments.length === 0) return null
const plans = segments.map((s) => buildSegPlan(node, s))
+2 -6
View File
@@ -6,12 +6,12 @@ import {
SkylightNode as SkylightNodeSchema,
type SkylightNode as SkylightNodeType,
} from '@pascal-app/core'
import { buildSkylightFloorplan } from './floorplan'
import {
closeSkylightOpenState,
isOperableSkylightNode,
toggleSkylightOpenState,
} from './interaction'
import { buildSkylightFloorplan } from './floorplan'
import { skylightParametrics } from './parametrics'
import { buildSkylightRoofCut } from './roof-cut'
import { SkylightNode } from './schema'
@@ -193,11 +193,7 @@ function skylightFrameThicknessHandle(): HandleDescriptor<SkylightNodeType> {
currentValue: (n) => n.frameThickness ?? 0.05,
apply: (_n, newValue) => ({ frameThickness: newValue }),
placement: {
position: (n) => [
-(n.width / 2) - SIDE_HANDLE_OFFSET,
0,
n.height / 2 + SIDE_HANDLE_OFFSET,
],
position: (n) => [-(n.width / 2) - SIDE_HANDLE_OFFSET, 0, n.height / 2 + SIDE_HANDLE_OFFSET],
rotationY: () => -Math.PI / 4,
},
portal: 'grandparent',