Fix room surfaces, wall openings, and paint scope (#498)
* chore(core): point material catalog at KTX2 tiers for wood/flooring/roofing finishes All 48 remaining webp/jpg/png finish entries now reference _512.ktx2 maps and 256px _thumb.webp previews, matching the fabric/leather/concrete/metal convention. flipY set to false on the converted entries — compressed textures can't be flipped at upload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: stabilize room surfaces and wall openings * style(core): format KTX2 material catalog --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4fca38a3ef
commit
a524da1574
@@ -71,6 +71,8 @@ export type PolygonEdgeSnapContext<N extends PolygonShape & { id: AnyNodeId }> =
|
||||
}
|
||||
|
||||
type PolygonAffordanceOptions<N extends PolygonShape & { id: AnyNodeId }> = {
|
||||
/** Data committed only when the outer boundary (not a hole) is edited. */
|
||||
boundaryCommitData?: Partial<N>
|
||||
resolvePlanPoint?: (context: PolygonAffordanceSnapContext<N>) => WallPlanPoint
|
||||
/**
|
||||
* `move-edge` only: absolute edge snap. The point-based resolver runs
|
||||
@@ -107,9 +109,10 @@ function buildRingPatch(
|
||||
node: PolygonShape,
|
||||
holeIndex: number | undefined,
|
||||
nextRing: ReadonlyArray<[number, number]>,
|
||||
boundaryCommitData?: object,
|
||||
): unknown {
|
||||
if (holeIndex === undefined) {
|
||||
return { polygon: nextRing }
|
||||
return { ...boundaryCommitData, polygon: nextRing }
|
||||
}
|
||||
const nextHoles = (node.holes ?? []).map((hole, i) =>
|
||||
i === holeIndex ? nextRing : hole.map(([x, y]) => [x, y] as [number, number]),
|
||||
@@ -163,7 +166,7 @@ export function createPolygonVertexAffordance<N extends PolygonShape & { id: Any
|
||||
const nextRing: [number, number][] = originalRing.map((p, i) =>
|
||||
i === vertexIndex ? [snapped[0], snapped[1]] : p,
|
||||
)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing, options?.boundaryCommitData)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
|
||||
@@ -225,7 +228,7 @@ export function createPolygonAddVertexAffordance<N extends PolygonShape & { id:
|
||||
|
||||
// Apply the insert immediately so the user sees the new vertex
|
||||
// before they even move.
|
||||
const initialPatch = buildRingPatch(node, holeIndex, initialRing)
|
||||
const initialPatch = buildRingPatch(node, holeIndex, initialRing, options?.boundaryCommitData)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: initialPatch as Partial<unknown> as never }])
|
||||
@@ -251,7 +254,7 @@ export function createPolygonAddVertexAffordance<N extends PolygonShape & { id:
|
||||
const nextRing: [number, number][] = initialRing.map((p, i) =>
|
||||
i === newVertexIndex ? [snapped[0], snapped[1]] : p,
|
||||
)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing, options?.boundaryCommitData)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
|
||||
@@ -385,7 +388,7 @@ export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: A
|
||||
}
|
||||
return [p[0], p[1]] as [number, number]
|
||||
})
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing, options?.boundaryCommitData)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DoorNode, RoofSegmentNode, WindowNode } from '@pascal-app/core'
|
||||
import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core'
|
||||
import { buildOpeningCutoutGeometry, hasFlatOpeningCutoutBottom } from '@pascal-app/viewer'
|
||||
import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding } from '@pascal-app/viewer'
|
||||
import * as THREE from 'three'
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ export function buildRoofWallOpeningCut(
|
||||
// Only a flat bottom chord may extend; a rounded bottom is never
|
||||
// coplanar and shifting it would distort the profile.
|
||||
const bottom = node.position[1] - node.height / 2
|
||||
const bottomPad = bottom < 0.005 && hasFlatOpeningCutoutBottom(node) ? 0.02 : 0
|
||||
const bottomPad = getOpeningCutoutBottomPadding(node, bottom)
|
||||
|
||||
const center = roofFacePointToSegment(hostSegment, node.roofFace, [
|
||||
node.position[0],
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, SlabNode, WallNode } from '@pascal-app/core'
|
||||
import { getRecessedSlabGroundHoles } from './recessed-slab-ground-holes'
|
||||
|
||||
describe('getRecessedSlabGroundHoles', () => {
|
||||
test('uses the rendered wall-face footprint instead of the stored centerline polygon', () => {
|
||||
const parentId = 'level_ground-holes'
|
||||
const slab = SlabNode.parse({
|
||||
id: 'slab_ground-holes',
|
||||
parentId,
|
||||
elevation: -0.15,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
const walls = [
|
||||
WallNode.parse({ id: 'wall_ground-holes-a', parentId, start: [0, 0], end: [2, 0] }),
|
||||
WallNode.parse({ id: 'wall_ground-holes-b', parentId, start: [2, 0], end: [2, 2] }),
|
||||
WallNode.parse({ id: 'wall_ground-holes-c', parentId, start: [2, 2], end: [0, 2] }),
|
||||
WallNode.parse({ id: 'wall_ground-holes-d', parentId, start: [0, 2], end: [0, 0] }),
|
||||
]
|
||||
const nodes = Object.fromEntries([slab, ...walls].map((node) => [node.id, node])) as Record<
|
||||
string,
|
||||
AnyNode
|
||||
>
|
||||
|
||||
const [hole] = getRecessedSlabGroundHoles(nodes)
|
||||
const xs = hole!.map(([x]) => x)
|
||||
const zs = hole!.map(([, z]) => z)
|
||||
|
||||
expect(Math.min(...xs)).toBeCloseTo(-0.05)
|
||||
expect(Math.max(...xs)).toBeCloseTo(2.05)
|
||||
expect(Math.min(...zs)).toBeCloseTo(-0.05)
|
||||
expect(Math.max(...zs)).toBeCloseTo(2.05)
|
||||
})
|
||||
|
||||
test('excludes non-recessed slabs', () => {
|
||||
const slab = SlabNode.parse({
|
||||
id: 'slab_ground-holes-raised',
|
||||
elevation: 0.15,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
|
||||
expect(getRecessedSlabGroundHoles({ [slab.id]: slab })).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
getRenderableSlabPolygon,
|
||||
type SlabNode,
|
||||
type SlabPolygonContext,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export function getRecessedSlabGroundHoles(
|
||||
nodes: Record<string, AnyNode>,
|
||||
): Array<Array<[number, number]>> {
|
||||
const nodeList = Object.values(nodes)
|
||||
const levelIndexById = new Map<string, number>()
|
||||
const wallsByLevel = new Map<string | null, WallNode[]>()
|
||||
const slabsByLevel = new Map<string | null, SlabNode[]>()
|
||||
let lowestLevelIndex = Number.POSITIVE_INFINITY
|
||||
|
||||
const pushByLevel = <T>(map: Map<string | null, T[]>, levelId: string | null, node: T) => {
|
||||
const entries = map.get(levelId)
|
||||
if (entries) entries.push(node)
|
||||
else map.set(levelId, [node])
|
||||
}
|
||||
|
||||
for (const node of nodeList) {
|
||||
if (node.type === 'level') {
|
||||
levelIndexById.set(node.id, node.level)
|
||||
lowestLevelIndex = Math.min(lowestLevelIndex, node.level)
|
||||
continue
|
||||
}
|
||||
|
||||
const levelId = node.parentId ?? null
|
||||
if (node.type === 'wall') pushByLevel(wallsByLevel, levelId, node)
|
||||
else if (node.type === 'slab') pushByLevel(slabsByLevel, levelId, node)
|
||||
}
|
||||
|
||||
return nodeList
|
||||
.filter(
|
||||
(node): node is SlabNode =>
|
||||
node.type === 'slab' &&
|
||||
node.visible &&
|
||||
node.polygon.length >= 3 &&
|
||||
(node.elevation ?? 0.05) < 0,
|
||||
)
|
||||
.filter((slab) => {
|
||||
if (!Number.isFinite(lowestLevelIndex)) return true
|
||||
const parentLevel = slab.parentId ? levelIndexById.get(slab.parentId) : undefined
|
||||
return parentLevel === lowestLevelIndex
|
||||
})
|
||||
.map((slab) => {
|
||||
const levelId = slab.parentId ?? null
|
||||
const context: SlabPolygonContext = {
|
||||
walls: wallsByLevel.get(levelId) ?? [],
|
||||
siblingSlabs: (slabsByLevel.get(levelId) ?? []).filter((sibling) => sibling.id !== slab.id),
|
||||
}
|
||||
return getRenderableSlabPolygon(slab, context)
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type SiteNode,
|
||||
type SlabNode,
|
||||
useLiveNodeOverrides,
|
||||
useRegistry,
|
||||
useScene,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
CircleGeometry,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
Path,
|
||||
@@ -30,6 +28,7 @@ import {
|
||||
} from 'three'
|
||||
import { cameraPosition, color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl'
|
||||
import { MeshLambertNodeMaterial } from 'three/webgpu'
|
||||
import { getRecessedSlabGroundHoles } from './recessed-slab-ground-holes'
|
||||
|
||||
const Y_OFFSET = 0.01
|
||||
|
||||
@@ -66,6 +65,45 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom
|
||||
|
||||
type S = ReturnType<typeof useScene.getState>
|
||||
|
||||
function polygonsMatch(
|
||||
a: Array<Array<[number, number]>>,
|
||||
b: Array<Array<[number, number]>>,
|
||||
): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(polygon, polygonIndex) =>
|
||||
polygon.length === b[polygonIndex]?.length &&
|
||||
polygon.every(
|
||||
(point, pointIndex) =>
|
||||
point[0] === b[polygonIndex]?.[pointIndex]?.[0] &&
|
||||
point[1] === b[polygonIndex]?.[pointIndex]?.[1],
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function addSlabHoles(
|
||||
shape: Shape,
|
||||
slabPolygons: Array<Array<[number, number]>>,
|
||||
originX = 0,
|
||||
originZ = 0,
|
||||
) {
|
||||
const localPolygons = slabPolygons.map((polygon) =>
|
||||
polygon.map(([x, z]): [number, number] => [x - originX, -(z - originZ)]),
|
||||
)
|
||||
for (const ring of unionPolygons(localPolygons)) {
|
||||
if (ring.length < 3) continue
|
||||
const hole = new Path()
|
||||
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||
for (let index = 1; index < ring.length; index += 1) {
|
||||
hole.lineTo(ring[index]![0], ring[index]![1])
|
||||
}
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
}
|
||||
|
||||
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
|
||||
@@ -164,45 +202,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
return material
|
||||
}, [bgColor, backgroundColor, skyColor, appearance, maxLightIntensity, fadeBounds])
|
||||
|
||||
const horizonGeometry = useMemo(() => {
|
||||
if (!fadeBounds) return null
|
||||
return new CircleGeometry(Math.max(fadeBounds.radius * 8, 400), 64)
|
||||
}, [fadeBounds])
|
||||
useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry])
|
||||
|
||||
// Cache slab polygon references to keep the selector stable across unrelated store updates
|
||||
// Cache computed polygons to keep the selector stable across unrelated store updates.
|
||||
const slabPolygonsCache = useRef<[number, number][][]>([])
|
||||
const slabPolygons = useScene((state: S) => {
|
||||
const nodeList = Object.values(state.nodes)
|
||||
|
||||
const levelIndexById = new Map<string, number>()
|
||||
let lowestLevelIndex = Number.POSITIVE_INFINITY
|
||||
nodeList.forEach((n) => {
|
||||
if (n.type !== 'level') return
|
||||
levelIndexById.set(n.id, n.level)
|
||||
lowestLevelIndex = Math.min(lowestLevelIndex, n.level)
|
||||
})
|
||||
|
||||
const next = nodeList
|
||||
.filter(
|
||||
(n): n is SlabNode =>
|
||||
n.type === 'slab' &&
|
||||
n.visible &&
|
||||
n.polygon.length >= 3 &&
|
||||
// Only recessed slabs should punch through the site ground.
|
||||
// Positive slabs are real floor geometry and should not create a
|
||||
// ghost footprint in the background ground fill.
|
||||
(n.elevation ?? 0.05) < 0,
|
||||
)
|
||||
.filter((n) => {
|
||||
if (!Number.isFinite(lowestLevelIndex)) return true
|
||||
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
|
||||
return parentLevel === lowestLevelIndex
|
||||
})
|
||||
.map((n) => n.polygon as [number, number][])
|
||||
const next = getRecessedSlabGroundHoles(state.nodes)
|
||||
|
||||
const prev = slabPolygonsCache.current
|
||||
if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev
|
||||
if (polygonsMatch(next, prev)) return prev
|
||||
slabPolygonsCache.current = next
|
||||
return next
|
||||
})
|
||||
@@ -217,20 +223,27 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
|
||||
shape.closePath()
|
||||
|
||||
if (slabPolygons.length > 0) {
|
||||
for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) {
|
||||
if (ring.length < 3) continue
|
||||
const hole = new Path()
|
||||
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
}
|
||||
addSlabHoles(shape, slabPolygons)
|
||||
|
||||
return shape
|
||||
}, [polygonPoints, slabPolygons])
|
||||
|
||||
const horizonGeometry = useMemo(() => {
|
||||
if (!fadeBounds) return null
|
||||
const radius = Math.max(fadeBounds.radius * 8, 400)
|
||||
const shape = new Shape()
|
||||
const segments = 64
|
||||
shape.moveTo(radius, 0)
|
||||
for (let index = 1; index <= segments; index += 1) {
|
||||
const angle = (index / segments) * Math.PI * 2
|
||||
shape.lineTo(Math.cos(angle) * radius, Math.sin(angle) * radius)
|
||||
}
|
||||
shape.closePath()
|
||||
addSlabHoles(shape, slabPolygons, fadeBounds.cx, fadeBounds.cz)
|
||||
return new ShapeGeometry(shape)
|
||||
}, [fadeBounds, slabPolygons])
|
||||
useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry])
|
||||
|
||||
// Create boundary line geometry
|
||||
const lineGeometry = useMemo(() => {
|
||||
if (!polygonPoints || polygonPoints.length < 2) return null
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'
|
||||
import { pointInPolygon2D, SlabNode } from '@pascal-app/core'
|
||||
import { slabDefinition } from '../definition'
|
||||
|
||||
function getHeightHandlePosition(slab: SlabNode) {
|
||||
function getHeightHandle(slab: SlabNode) {
|
||||
const handles =
|
||||
typeof slabDefinition.handles === 'function'
|
||||
? slabDefinition.handles(slab)
|
||||
@@ -13,7 +13,11 @@ function getHeightHandlePosition(slab: SlabNode) {
|
||||
if (!(heightHandle && heightHandle.kind === 'linear-resize')) {
|
||||
throw new Error('Missing slab height handle')
|
||||
}
|
||||
return heightHandle.placement.position(slab, {} as never)
|
||||
return heightHandle
|
||||
}
|
||||
|
||||
function getHeightHandlePosition(slab: SlabNode) {
|
||||
return getHeightHandle(slab).placement.position(slab, {} as never)
|
||||
}
|
||||
|
||||
describe('slabDefinition handles', () => {
|
||||
@@ -40,4 +44,20 @@ describe('slabDefinition handles', () => {
|
||||
expect(pointInPolygon2D([x, z], slab.polygon, { includeBoundary: false })).toBe(true)
|
||||
expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false)
|
||||
})
|
||||
|
||||
test('allows the elevation arrow to cross zero into a recessed slab', () => {
|
||||
const slab = SlabNode.parse({
|
||||
elevation: 0.05,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
const heightHandle = getHeightHandle(slab)
|
||||
|
||||
expect(heightHandle.min).toBe(-1)
|
||||
expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({ elevation: -0.15 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ const MODIFIERS = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: fal
|
||||
* Level + one wall (centerline z=0, t=0.1) + one manual slab whose bottom
|
||||
* edge starts 0.5m away from the wall.
|
||||
*/
|
||||
function seedScene() {
|
||||
function seedScene(autoFromWalls = false) {
|
||||
const levelId = 'level_slab-move-edge' as AnyNodeId
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
@@ -40,7 +40,7 @@ function seedScene() {
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
autoFromWalls: false,
|
||||
autoFromWalls,
|
||||
parentId: levelId,
|
||||
})
|
||||
const level = {
|
||||
@@ -115,4 +115,22 @@ describe('slabMoveEdgeAffordance', () => {
|
||||
expect(updated.polygon[0]![1]).toBeCloseTo(1.5, 5)
|
||||
expect(updated.polygon[1]![1]).toBeCloseTo(1.5, 5)
|
||||
})
|
||||
|
||||
test('editing an auto-generated outer boundary makes the slab manual', () => {
|
||||
const { slab } = seedScene(true)
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
const session = slabMoveEdgeAffordance.start({
|
||||
node: nodes[slab.id] as SlabNodeType,
|
||||
payload: { edgeIndex: 0 },
|
||||
nodes,
|
||||
initialPlanPoint: [2, 0.5],
|
||||
gridSnapStep: 0.1,
|
||||
} as never)
|
||||
|
||||
session.apply({ planPoint: [2, 1.5], modifiers: MODIFIERS })
|
||||
|
||||
const updated = useScene.getState().nodes[slab.id] as SlabNodeType
|
||||
expect(updated.autoFromWalls).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
clearSlabSnapFeedback()
|
||||
updateNode(slabId, { polygon: newPolygon })
|
||||
updateNode(slabId, { polygon: newPolygon, autoFromWalls: false })
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
},
|
||||
[slabId, updateNode, setSelection],
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SlabNode } from './schema'
|
||||
import { slabSlots } from './slots'
|
||||
|
||||
const HEIGHT_HANDLE_OFFSET = 0.22
|
||||
const MIN_SLAB_ELEVATION = 0.02
|
||||
const MIN_SLAB_ELEVATION = -1
|
||||
|
||||
function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] {
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
@@ -89,10 +89,10 @@ function slabHandleAnchor(slab: SlabNodeType): [number, number] {
|
||||
}
|
||||
|
||||
// Slab height arrow — vertical chevron on solid slab surface near the
|
||||
// polygon center. Drags elevation (the extrusion thickness) with
|
||||
// `anchor: 'min'` so the bottom stays at world Y=0 and the top follows
|
||||
// the pointer. Same registry-handle pipeline as the column height arrow,
|
||||
// so live override + commit-on-release come for free.
|
||||
// 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.
|
||||
function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
* Simpler model, no UX downside in practice.
|
||||
*/
|
||||
const slabSnapOptions = {
|
||||
boundaryCommitData: { autoFromWalls: false },
|
||||
resolvePlanPoint({
|
||||
node,
|
||||
nodes,
|
||||
|
||||
@@ -235,6 +235,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
useScene.getState().updateNode(slabId, {
|
||||
polygon: translatePolygon(originalPolygon, deltaX, deltaZ),
|
||||
holes: originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)),
|
||||
autoFromWalls: false,
|
||||
})
|
||||
useScene.getState().markDirty(slabId as AnyNodeId)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const slabParametrics: ParametricDescriptor<SlabNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Elevation',
|
||||
fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: 0.02, max: 1, step: 0.01 }],
|
||||
fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 }],
|
||||
},
|
||||
],
|
||||
customPanel: () => import('./panel'),
|
||||
|
||||
Reference in New Issue
Block a user