Merge pull request #116 from pascalorg/fix/community-feedback-pass-3

Fix/community feedback pass 3
This commit is contained in:
Wassim SAMAD
2026-02-24 16:17:44 +09:00
committed by GitHub
20 changed files with 795 additions and 480 deletions
@@ -201,6 +201,21 @@ export function wallOverlapsPolygon(
const nz = (dz / len) * step
if (pointInPolygon(start[0] + nx, start[1] + nz, polygon)) return true
if (pointInPolygon(end[0] - nx, end[1] - nz, polygon)) return true
// Also nudge perpendicular to the wall (into the slab interior) for walls that
// lie exactly on the slab boundary. The along-wall nudge keeps points on the
// boundary where pointInPolygon is unreliable; a perpendicular inward nudge
// moves the point clearly inside (or outside) the polygon.
// Sample the wall at 1/4, 1/2, 3/4 positions with a perpendicular nudge.
const PERP_STEP = 1e-4
const pnx = (-nz / step) * PERP_STEP // perpendicular left
const pnz = (nx / step) * PERP_STEP
for (const t of [0.25, 0.5, 0.75]) {
const bx = start[0] + dx * t
const bz = start[1] + dz * t
if (pointInPolygon(bx + pnx, bz + pnz, polygon)) return true
if (pointInPolygon(bx - pnx, bz - pnz, polygon)) return true
}
}
// Check if midpoint is inside (catches walls crossing through)
@@ -557,26 +572,42 @@ export class SpatialGridManager {
let maxElevation = -Infinity
for (const slab of slabMap.values()) {
if (slab.polygon.length < 3) continue
if (wallOverlapsPolygon(start, end, slab.polygon)) {
// Check if wall midpoint is in a hole (if so, ignore this slab)
if (!wallOverlapsPolygon(start, end, slab.polygon)) continue
const holes = slab.holes || []
if (holes.length === 0) {
// No holes: wall is on this slab
const elevation = slab.elevation ?? 0.05
if (elevation > maxElevation) maxElevation = elevation
continue
}
// Sample multiple points along the wall to check whether any portion lies on
// solid slab (not inside any hole). Checking only the midpoint fails when the
// midpoint falls in a staircase hole but the wall's endpoints are on solid slab.
const dx = end[0] - start[0]
const dz = end[1] - start[1]
let hasValidPoint = false
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
const px = start[0] + dx * t
const pz = start[1] + dz * t
let inHole = false
const midX = (start[0] + end[0]) / 2
const midZ = (start[1] + end[1]) / 2
const holes = slab.holes || []
for (const hole of holes) {
if (hole.length >= 3 && pointInPolygon(midX, midZ, hole)) {
if (hole.length >= 3 && pointInPolygon(px, pz, hole)) {
inHole = true
break
}
}
if (!inHole) {
const elevation = slab.elevation ?? 0.05
if (elevation > maxElevation) {
maxElevation = elevation
}
hasValidPoint = true
break
}
}
if (hasValidPoint) {
const elevation = slab.elevation ?? 0.05
if (elevation > maxElevation) maxElevation = elevation
}
}
return maxElevation === -Infinity ? 0 : maxElevation
}
@@ -85,7 +85,7 @@ export function initSpatialGridSync() {
}
}
} else if (node.type === 'slab' && prev.type === 'slab') {
if (node.polygon !== prev.polygon || node.elevation !== prev.elevation) {
if (node.polygon !== prev.polygon || node.elevation !== prev.elevation || node.holes !== prev.holes) {
const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId)
@@ -152,6 +152,10 @@ export const deleteNodesAction = (
return { nodes: nextNodes, rootNodeIds: nextRootIds }
})
// Notify systems that the parent has changed (e.g. Wall needs to fill a window hole)
parentsToMarkDirty.forEach((pId) => get().markDirty(pId))
// Trigger a full scene re-validation after deleting node (as deleting a slab can cause widespread changes to level elevations)
const currentNodes = get().nodes
Object.values(currentNodes).forEach((node) => {
get().markDirty(node.id)
})
}
-1
View File
@@ -170,7 +170,6 @@ const useScene: UseSceneStore = create<SceneState>()(
rootNodeIds: state.rootNodeIds,
}),
merge: (persistedState, currentState) => {
console.log('merge calling...', persistedState, currentState)
const persisted = persistedState as Partial<SceneState>
// Backward compat: add default scale to item nodes saved before scale was added
if (persisted.nodes) {
@@ -3,7 +3,7 @@ import type * as THREE from 'three'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import { getScaledDimensions, type AnyNodeId, type ItemNode, type WallNode } from '../../schema'
import { type AnyNodeId, getScaledDimensions, type ItemNode, type WallNode } from '../../schema'
import useScene from '../../store/use-scene'
// ============================================================================
@@ -32,7 +32,7 @@ export const ItemSystem = () => {
if (parentWall && parentWall.type === 'wall') {
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
const side = item.side === 'front' ? 1 : -1
mesh.position.z = (wallThickness / 2) * side;
mesh.position.z = (wallThickness / 2) * side
}
} else if (!item.asset.attachTo) {
// If parented to another item (surface placement), R3F handles positioning via the hierarchy
@@ -51,8 +51,8 @@ export const ItemSystem = () => {
}
clearDirty(id as AnyNodeId)
}, 2)
})
})
}, 2)
return null
}
@@ -1,7 +1,7 @@
import { type CeilingNode, useRegistry } from '@pascal-app/core'
import { useRef } from 'react'
import { faceDirection, float, mix, positionWorld, smoothstep, step } from 'three/tsl'
import { DoubleSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
@@ -10,7 +10,6 @@ import { NodeRenderer } from '../node-renderer'
// - Front face (looking down at ceiling from above): 30% opacity
const ceilingMaterial = new MeshBasicNodeMaterial({
color: 0x999999,
side: DoubleSide,
transparent: true,
depthWrite: false,
})
@@ -30,8 +29,8 @@ const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.
// Combine: if either X or Y is a line, show the line
const gridPattern = lineX.max(lineY)
// Grid lines at 0.8 opacity, spaces at 0.1 opacity
const gridOpacity = mix(float(0.1), float(0.8), gridPattern)
// Grid lines at 0.5 opacity, spaces at 0 opacity
const gridOpacity = mix(float(0.0), float(0.5), gridPattern)
// faceDirection is 1.0 for front face, -1.0 for back face
// Front face (top, looking down): grid pattern, Back face (bottom, looking up): solid
@@ -177,7 +177,9 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
}
return (
<group ref={ref} {...handlers}>
<group ref={ref} {...handlers} userData={{
labelPosition: [centroid[0], 1, centroid[1]]
}}>
<Html name="label" position={[centroid[0], 1, centroid[1]]} style={{
pointerEvents: 'none'
}}
+63 -48
View File
@@ -10,6 +10,7 @@ import type {
import type { Object3D } from "three";
import { create } from "zustand";
import { persist } from "zustand/middleware";
type SelectionPath = {
buildingId: BuildingNode["id"] | null;
@@ -57,62 +58,76 @@ type ViewerState = {
setCameraDragging: (dragging: boolean) => void
}
const useViewer = create<ViewerState>()((set, get) => ({
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
hoveredId: null,
setHoveredId: (id) => set({ hoveredId: id }),
const useViewer = create<ViewerState>()(
persist(
(set) => ({
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
hoveredId: null,
setHoveredId: (id) => set({ hoveredId: id }),
cameraMode: "perspective",
setCameraMode: (mode) => set({ cameraMode: mode }),
cameraMode: "perspective",
setCameraMode: (mode) => set({ cameraMode: mode }),
levelMode: "stacked",
setLevelMode: (mode) => set({ levelMode: mode }),
levelMode: "stacked",
setLevelMode: (mode) => set({ levelMode: mode }),
wallMode: 'cutaway',
setWallMode: (mode) => set({ wallMode: mode }),
wallMode: 'cutaway',
setWallMode: (mode) => set({ wallMode: mode }),
showScans: true,
setShowScans: (show) => set({ showScans: show }),
showScans: true,
setShowScans: (show) => set({ showScans: show }),
showGuides: true,
setShowGuides: (show) => set({ showGuides: show }),
showGuides: true,
setShowGuides: (show) => set({ showGuides: show }),
setSelection: (updates) =>
set((state) => {
const newSelection = { ...state.selection, ...updates };
setSelection: (updates) =>
set((state) => {
const newSelection = { ...state.selection, ...updates };
// Hierarchy Guard: If we change a high-level parent, reset the children
if (updates.buildingId !== undefined) {
newSelection.levelId = null;
newSelection.zoneId = null;
newSelection.selectedIds = [];
} else if (updates.levelId !== undefined) {
newSelection.zoneId = null;
newSelection.selectedIds = [];
} else if (updates.zoneId !== undefined) {
newSelection.selectedIds = [];
}
// Hierarchy Guard: If we change a high-level parent, reset the children
if (updates.buildingId !== undefined) {
newSelection.levelId = null;
newSelection.zoneId = null;
newSelection.selectedIds = [];
} else if (updates.levelId !== undefined) {
newSelection.zoneId = null;
newSelection.selectedIds = [];
} else if (updates.zoneId !== undefined) {
newSelection.selectedIds = [];
}
return { selection: newSelection };
return { selection: newSelection };
}),
resetSelection: () =>
set({
selection: {
buildingId: null,
levelId: null,
zoneId: null,
selectedIds: [],
},
}),
outliner: { selectedObjects: [], hoveredObjects: [] },
exportScene: null,
setExportScene: (fn) => set({ exportScene: fn }),
cameraDragging: false,
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
}),
resetSelection: () =>
set({
selection: {
buildingId: null,
levelId: null,
zoneId: null,
selectedIds: [],
},
}),
outliner: { selectedObjects: [], hoveredObjects: [] },
exportScene: null,
setExportScene: (fn) => set({ exportScene: fn }),
cameraDragging: false,
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
}));
{
name: 'viewer-preferences',
partialize: (state) => ({
cameraMode: state.cameraMode,
levelMode: state.levelMode,
wallMode: state.wallMode,
showScans: state.showScans,
showGuides: state.showGuides,
}),
},
),
);
export default useViewer;