@pascal-app/viewer 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/components/renderers/ceiling/ceiling-renderer.d.ts.map +1 -1
  2. package/dist/components/renderers/ceiling/ceiling-renderer.js +4 -12
  3. package/dist/components/renderers/item/item-renderer.d.ts.map +1 -1
  4. package/dist/components/renderers/item/item-renderer.js +14 -19
  5. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  6. package/dist/components/renderers/node-renderer.js +3 -1
  7. package/dist/components/renderers/site/site-renderer.d.ts.map +1 -1
  8. package/dist/components/renderers/site/site-renderer.js +59 -15
  9. package/dist/components/renderers/slab/slab-renderer.js +1 -1
  10. package/dist/components/renderers/stair/stair-renderer.d.ts +5 -0
  11. package/dist/components/renderers/stair/stair-renderer.d.ts.map +1 -0
  12. package/dist/components/renderers/stair/stair-renderer.js +21 -0
  13. package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts +5 -0
  14. package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts.map +1 -0
  15. package/dist/components/renderers/stair-segment/stair-segment-renderer.js +20 -0
  16. package/dist/components/renderers/wall/wall-renderer.js +1 -1
  17. package/dist/components/viewer/index.d.ts.map +1 -1
  18. package/dist/components/viewer/index.js +2 -2
  19. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  20. package/dist/components/viewer/post-processing.js +33 -45
  21. package/dist/components/viewer/walkthrough-controls.d.ts +2 -0
  22. package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -0
  23. package/dist/components/viewer/walkthrough-controls.js +131 -0
  24. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  25. package/dist/hooks/use-node-events.d.ts +9 -1
  26. package/dist/hooks/use-node-events.d.ts.map +1 -1
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -0
  30. package/dist/lib/materials.d.ts +1 -0
  31. package/dist/lib/materials.d.ts.map +1 -1
  32. package/dist/lib/materials.js +1 -0
  33. package/dist/lib/merged-outline-node.d.ts +88 -0
  34. package/dist/lib/merged-outline-node.d.ts.map +1 -0
  35. package/dist/lib/merged-outline-node.js +450 -0
  36. package/dist/lib/suppress-three-clock-warning.d.ts +25 -0
  37. package/dist/lib/suppress-three-clock-warning.d.ts.map +1 -0
  38. package/dist/lib/suppress-three-clock-warning.js +51 -0
  39. package/dist/store/use-viewer.d.ts +6 -0
  40. package/dist/store/use-viewer.d.ts.map +1 -1
  41. package/dist/store/use-viewer.js +8 -1
  42. package/dist/systems/export/export-system.d.ts +2 -0
  43. package/dist/systems/export/export-system.d.ts.map +1 -0
  44. package/dist/systems/export/export-system.js +60 -0
  45. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  46. package/dist/systems/wall/wall-cutout.js +110 -61
  47. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"ceiling-renderer.d.ts","sourceRoot":"","sources":["../../../../src/components/renderers/ceiling/ceiling-renderer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAe,MAAM,kBAAkB,CAAA;AAmChE,eAAO,MAAM,eAAe,GAAI,UAAU;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,4CAoC9D,CAAA"}
1
+ {"version":3,"file":"ceiling-renderer.d.ts","sourceRoot":"","sources":["../../../../src/components/renderers/ceiling/ceiling-renderer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAgC,MAAM,kBAAkB,CAAA;AAkCjF,eAAO,MAAM,eAAe,GAAI,UAAU;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,4CA6B9D,CAAA"}
@@ -1,10 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useRegistry } from '@pascal-app/core';
2
+ import { resolveMaterial, useRegistry } from '@pascal-app/core';
3
3
  import { useMemo, useRef } from 'react';
4
4
  import { float, mix, positionWorld, smoothstep } from 'three/tsl';
5
5
  import { BackSide, FrontSide, MeshBasicNodeMaterial } from 'three/webgpu';
6
6
  import { useNodeEvents } from '../../../hooks/use-node-events';
7
- import { DEFAULT_CEILING_MATERIAL } from '../../../lib/materials';
8
7
  import { NodeRenderer } from '../node-renderer';
9
8
  const gridScale = 5;
10
9
  const gridX = positionWorld.x.mul(gridScale).fract();
@@ -34,16 +33,9 @@ export const CeilingRenderer = ({ node }) => {
34
33
  useRegistry(node.id, 'ceiling', ref);
35
34
  const handlers = useNodeEvents(node, 'ceiling');
36
35
  const materials = useMemo(() => {
37
- const mat = node.material;
38
- if (mat) {
39
- const props = mat.properties;
40
- const color = props?.color || '#999999';
41
- return createCeilingMaterials(color);
42
- }
43
- return {
44
- topMaterial: createCeilingMaterials().topMaterial,
45
- bottomMaterial: DEFAULT_CEILING_MATERIAL,
46
- };
36
+ const props = resolveMaterial(node.material);
37
+ const color = props.color || '#999999';
38
+ return createCeilingMaterials(color);
47
39
  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]);
48
40
  return (_jsxs("mesh", { material: materials.bottomMaterial, ref: ref, children: [_jsx("boxGeometry", { args: [0, 0, 0] }), _jsx("mesh", { material: materials.topMaterial, name: "ceiling-grid", ...handlers, scale: 0, visible: false, children: _jsx("boxGeometry", { args: [0, 0, 0] }) }), node.children.map((childId) => (_jsx(NodeRenderer, { nodeId: childId }, childId)))] }));
49
41
  };
@@ -1 +1 @@
1
- {"version":3,"file":"item-renderer.d.ts","sourceRoot":"","sources":["../../../../src/components/renderers/item/item-renderer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,QAAQ,EAKd,MAAM,kBAAkB,CAAA;AAoDzB,eAAO,MAAM,YAAY,GAAI,UAAU;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,4CAiBxD,CAAA"}
1
+ {"version":3,"file":"item-renderer.d.ts","sourceRoot":"","sources":["../../../../src/components/renderers/item/item-renderer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,QAAQ,EAKd,MAAM,kBAAkB,CAAA;AAkCzB,eAAO,MAAM,YAAY,GAAI,UAAU;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,4CAiBxD,CAAA"}
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useInteractive, useRegistry, useScene, } from '@pascal-app/core';
2
+ import { baseMaterial, glassMaterial, useInteractive, useRegistry, useScene, } from '@pascal-app/core';
3
3
  import { useAnimations } from '@react-three/drei';
4
4
  import { Clone } from '@react-three/drei/core/Clone';
5
5
  import { useGLTF } from '@react-three/drei/core/Gltf';
@@ -7,33 +7,17 @@ import { useFrame } from '@react-three/fiber';
7
7
  import { Suspense, useEffect, useMemo, useRef } from 'react';
8
8
  import { MathUtils } from 'three';
9
9
  import { positionLocal, smoothstep, time } from 'three/tsl';
10
- import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu';
10
+ import { MeshStandardNodeMaterial } from 'three/webgpu';
11
11
  import { useNodeEvents } from '../../../hooks/use-node-events';
12
12
  import { resolveCdnUrl } from '../../../lib/asset-url';
13
13
  import { useItemLightPool } from '../../../store/use-item-light-pool';
14
14
  import { ErrorBoundary } from '../../error-boundary';
15
15
  import { NodeRenderer } from '../node-renderer';
16
- // Shared materials to avoid creating new instances for every mesh
17
- const defaultMaterial = new MeshStandardNodeMaterial({
18
- color: 0xff_ff_ff,
19
- roughness: 1,
20
- metalness: 0,
21
- });
22
- const glassMaterial = new MeshStandardNodeMaterial({
23
- name: 'glass',
24
- color: 'lightgray',
25
- roughness: 0.8,
26
- metalness: 0,
27
- transparent: true,
28
- opacity: 0.35,
29
- side: DoubleSide,
30
- depthWrite: false,
31
- });
32
16
  const getMaterialForOriginal = (original) => {
33
17
  if (original.name.toLowerCase() === 'glass') {
34
18
  return glassMaterial;
35
19
  }
36
- return defaultMaterial;
20
+ return baseMaterial;
37
21
  };
38
22
  const BrokenItemFallback = ({ node }) => {
39
23
  const handlers = useNodeEvents(node, 'item');
@@ -93,6 +77,17 @@ const ModelRenderer = ({ node }) => {
93
77
  if (Array.isArray(mesh.material)) {
94
78
  mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat));
95
79
  hasGlass = mesh.material.some((mat) => mat.name === 'glass');
80
+ // Fix geometry groups that reference materialIndex beyond the material
81
+ // array length — this causes three-mesh-bvh to crash with
82
+ // "Cannot read properties of undefined (reading 'side')"
83
+ const matCount = mesh.material.length;
84
+ if (mesh.geometry.groups.length > 0) {
85
+ for (const group of mesh.geometry.groups) {
86
+ if (group.materialIndex !== undefined && group.materialIndex >= matCount) {
87
+ group.materialIndex = 0;
88
+ }
89
+ }
90
+ }
96
91
  }
97
92
  else {
98
93
  mesh.material = getMaterialForOriginal(mesh.material);
@@ -1 +1 @@
1
- {"version":3,"file":"node-renderer.d.ts","sourceRoot":"","sources":["../../../src/components/renderers/node-renderer.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,OAAO,EAAY,MAAM,kBAAkB,CAAA;AAgBzD,eAAO,MAAM,YAAY,GAAI,YAAY;IAAE,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,mDAuBjE,CAAA"}
1
+ {"version":3,"file":"node-renderer.d.ts","sourceRoot":"","sources":["../../../src/components/renderers/node-renderer.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,OAAO,EAAY,MAAM,kBAAkB,CAAA;AAkBzD,eAAO,MAAM,YAAY,GAAI,YAAY;IAAE,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,mDAyBjE,CAAA"}
@@ -12,6 +12,8 @@ import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer';
12
12
  import { ScanRenderer } from './scan/scan-renderer';
13
13
  import { SiteRenderer } from './site/site-renderer';
14
14
  import { SlabRenderer } from './slab/slab-renderer';
15
+ import { StairRenderer } from './stair/stair-renderer';
16
+ import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer';
15
17
  import { WallRenderer } from './wall/wall-renderer';
16
18
  import { WindowRenderer } from './window/window-renderer';
17
19
  import { ZoneRenderer } from './zone/zone-renderer';
@@ -19,5 +21,5 @@ export const NodeRenderer = ({ nodeId }) => {
19
21
  const node = useScene((state) => state.nodes[nodeId]);
20
22
  if (!node)
21
23
  return null;
22
- return (_jsxs(_Fragment, { children: [node.type === 'site' && _jsx(SiteRenderer, { node: node }), node.type === 'building' && _jsx(BuildingRenderer, { node: node }), node.type === 'ceiling' && _jsx(CeilingRenderer, { node: node }), node.type === 'level' && _jsx(LevelRenderer, { node: node }), node.type === 'item' && _jsx(ItemRenderer, { node: node }), node.type === 'slab' && _jsx(SlabRenderer, { node: node }), node.type === 'wall' && _jsx(WallRenderer, { node: node }), node.type === 'door' && _jsx(DoorRenderer, { node: node }), node.type === 'window' && _jsx(WindowRenderer, { node: node }), node.type === 'zone' && _jsx(ZoneRenderer, { node: node }), node.type === 'roof' && _jsx(RoofRenderer, { node: node }), node.type === 'roof-segment' && _jsx(RoofSegmentRenderer, { node: node }), node.type === 'scan' && _jsx(ScanRenderer, { node: node }), node.type === 'guide' && _jsx(GuideRenderer, { node: node })] }));
24
+ return (_jsxs(_Fragment, { children: [node.type === 'site' && _jsx(SiteRenderer, { node: node }), node.type === 'building' && _jsx(BuildingRenderer, { node: node }), node.type === 'ceiling' && _jsx(CeilingRenderer, { node: node }), node.type === 'level' && _jsx(LevelRenderer, { node: node }), node.type === 'item' && _jsx(ItemRenderer, { node: node }), node.type === 'slab' && _jsx(SlabRenderer, { node: node }), node.type === 'wall' && _jsx(WallRenderer, { node: node }), node.type === 'door' && _jsx(DoorRenderer, { node: node }), node.type === 'window' && _jsx(WindowRenderer, { node: node }), node.type === 'zone' && _jsx(ZoneRenderer, { node: node }), node.type === 'roof' && _jsx(RoofRenderer, { node: node }), node.type === 'roof-segment' && _jsx(RoofSegmentRenderer, { node: node }), node.type === 'stair' && _jsx(StairRenderer, { node: node }), node.type === 'stair-segment' && _jsx(StairSegmentRenderer, { node: node }), node.type === 'scan' && _jsx(ScanRenderer, { node: node }), node.type === 'guide' && _jsx(GuideRenderer, { node: node })] }));
23
25
  };
@@ -1 +1 @@
1
- {"version":3,"file":"site-renderer.d.ts","sourceRoot":"","sources":["../../../../src/components/renderers/site/site-renderer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAe,MAAM,kBAAkB,CAAA;AA+B7D,eAAO,MAAM,YAAY,GAAI,UAAU;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,mDA2DxD,CAAA"}
1
+ {"version":3,"file":"site-renderer.d.ts","sourceRoot":"","sources":["../../../../src/components/renderers/site/site-renderer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAwC,MAAM,kBAAkB,CAAA;AAmCtF,eAAO,MAAM,YAAY,GAAI,UAAU;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,mDAgHxD,CAAA"}
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useRegistry } from '@pascal-app/core';
2
+ import { useRegistry, useScene } from '@pascal-app/core';
3
+ import polygonClipping from 'polygon-clipping';
3
4
  import { useMemo, useRef } from 'react';
4
- import { BufferGeometry, Float32BufferAttribute, Shape } from 'three';
5
+ import { BufferGeometry, Float32BufferAttribute, Path, Shape } from 'three';
5
6
  import { useNodeEvents } from '../../../hooks/use-node-events';
7
+ import useViewer from '../../../store/use-viewer';
6
8
  import { NodeRenderer } from '../node-renderer';
7
9
  const Y_OFFSET = 0.01;
8
10
  /**
@@ -26,22 +28,64 @@ const createBoundaryLineGeometry = (points) => {
26
28
  export const SiteRenderer = ({ node }) => {
27
29
  const ref = useRef(null);
28
30
  useRegistry(node.id, 'site', ref);
29
- // Create floor shape from polygon points
30
- const floorShape = useMemo(() => {
31
+ const theme = useViewer((state) => state.theme);
32
+ const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa';
33
+ // Cache slab polygon references to keep the selector stable across unrelated store updates
34
+ const slabPolygonsCache = useRef([]);
35
+ const slabPolygons = useScene((state) => {
36
+ const nodeList = Object.values(state.nodes);
37
+ const levelIndexById = new Map();
38
+ let lowestLevelIndex = Number.POSITIVE_INFINITY;
39
+ nodeList.forEach((n) => {
40
+ if (n.type !== 'level')
41
+ return;
42
+ levelIndexById.set(n.id, n.level);
43
+ lowestLevelIndex = Math.min(lowestLevelIndex, n.level);
44
+ });
45
+ const next = nodeList
46
+ .filter((n) => n.type === 'slab' && n.visible && n.polygon.length >= 3)
47
+ .filter((n) => {
48
+ if (!Number.isFinite(lowestLevelIndex))
49
+ return true;
50
+ const parentLevel = n.parentId ? levelIndexById.get(n.parentId) : undefined;
51
+ return parentLevel === lowestLevelIndex;
52
+ })
53
+ .map((n) => n.polygon);
54
+ const prev = slabPolygonsCache.current;
55
+ if (next.length === prev.length && next.every((p, i) => p === prev[i]))
56
+ return prev;
57
+ slabPolygonsCache.current = next;
58
+ return next;
59
+ });
60
+ // Ground shape: site polygon with slab footprints punched as holes
61
+ const groundShape = useMemo(() => {
31
62
  if (!node?.polygon?.points || node.polygon.points.length < 3)
32
63
  return null;
64
+ const pts = node.polygon.points;
33
65
  const shape = new Shape();
34
- const firstPt = node.polygon.points[0];
35
- // Shape is in X-Y plane, we rotate it to X-Z plane
36
- // Negate Y (which becomes Z) to get correct orientation
37
- shape.moveTo(firstPt[0], -firstPt[1]);
38
- for (let i = 1; i < node.polygon.points.length; i++) {
39
- const pt = node.polygon.points[i];
40
- shape.lineTo(pt[0], -pt[1]);
41
- }
66
+ shape.moveTo(pts[0][0], -pts[0][1]);
67
+ for (let i = 1; i < pts.length; i++)
68
+ shape.lineTo(pts[i][0], -pts[i][1]);
42
69
  shape.closePath();
70
+ if (slabPolygons.length > 0) {
71
+ const multiPolygons = slabPolygons.map((p) => [
72
+ p.map((pt) => [pt[0], -pt[1]]),
73
+ ]);
74
+ const unioned = polygonClipping.union(multiPolygons[0], ...multiPolygons.slice(1));
75
+ for (const geom of unioned) {
76
+ const ring = geom[0];
77
+ if (ring && ring.length > 0) {
78
+ const hole = new Path();
79
+ hole.moveTo(ring[0][0], ring[0][1]);
80
+ for (let i = 1; i < ring.length; i++)
81
+ hole.lineTo(ring[i][0], ring[i][1]);
82
+ hole.closePath();
83
+ shape.holes.push(hole);
84
+ }
85
+ }
86
+ }
43
87
  return shape;
44
- }, [node?.polygon?.points]);
88
+ }, [node?.polygon?.points, slabPolygons]);
45
89
  // Create boundary line geometry
46
90
  const lineGeometry = useMemo(() => {
47
91
  if (!node?.polygon?.points || node.polygon.points.length < 2)
@@ -49,8 +93,8 @@ export const SiteRenderer = ({ node }) => {
49
93
  return createBoundaryLineGeometry(node.polygon.points);
50
94
  }, [node?.polygon?.points]);
51
95
  const handlers = useNodeEvents(node, 'site');
52
- if (!(node && floorShape && lineGeometry)) {
96
+ if (!(node && lineGeometry)) {
53
97
  return null;
54
98
  }
55
- return (_jsxs("group", { ref: ref, ...handlers, children: [node.children.map((child) => (_jsx(NodeRenderer, { nodeId: typeof child === 'string' ? child : child.id }, typeof child === 'string' ? child : child.id))), _jsxs("mesh", { position: [0, Y_OFFSET - 0.005, 0], receiveShadow: true, rotation: [-Math.PI / 2, 0, 0], children: [_jsx("shapeGeometry", { args: [floorShape] }), _jsx("shadowMaterial", { opacity: 0.75, transparent: true })] }), _jsx("line", { frustumCulled: false, geometry: lineGeometry, renderOrder: 9, children: _jsx("lineBasicMaterial", { color: "#f59e0b", linewidth: 2, opacity: 0.6, transparent: true }) })] }));
99
+ return (_jsxs("group", { ref: ref, ...handlers, children: [node.children.map((child) => (_jsx(NodeRenderer, { nodeId: typeof child === 'string' ? child : child.id }, typeof child === 'string' ? child : child.id))), groundShape && (_jsxs("mesh", { position: [0, -0.05, 0], receiveShadow: true, rotation: [-Math.PI / 2, 0, 0], children: [_jsx("shapeGeometry", { args: [groundShape] }), _jsx("meshStandardMaterial", { color: bgColor, depthWrite: true, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 })] })), _jsx("line", { frustumCulled: false, geometry: lineGeometry, renderOrder: 9, children: _jsx("lineBasicMaterial", { color: "#f59e0b", linewidth: 2, opacity: 0.6, transparent: true }) })] }));
56
100
  };
@@ -13,5 +13,5 @@ export const SlabRenderer = ({ node }) => {
13
13
  return DEFAULT_SLAB_MATERIAL;
14
14
  return createMaterial(mat);
15
15
  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]);
16
- return (_jsx("mesh", { castShadow: true, receiveShadow: true, ref: ref, ...handlers, visible: node.visible, material: material, children: _jsx("boxGeometry", { args: [0, 0, 0] }) }));
16
+ return (_jsx("mesh", { castShadow: true, receiveShadow: true, ref: ref, ...handlers, material: material, visible: node.visible, children: _jsx("boxGeometry", { args: [0, 0, 0] }) }));
17
17
  };
@@ -0,0 +1,5 @@
1
+ import { type StairNode } from '@pascal-app/core';
2
+ export declare const StairRenderer: ({ node }: {
3
+ node: StairNode;
4
+ }) => import("react/jsx-runtime").JSX.Element;
5
+ //# sourceMappingURL=stair-renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stair-renderer.d.ts","sourceRoot":"","sources":["../../../../src/components/renderers/stair/stair-renderer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAyB,MAAM,kBAAkB,CAAA;AAOxE,eAAO,MAAM,aAAa,GAAI,UAAU;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,4CAoC1D,CAAA"}
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRegistry, useScene } from '@pascal-app/core';
3
+ import { useLayoutEffect, useMemo, useRef } from 'react';
4
+ import { useNodeEvents } from '../../../hooks/use-node-events';
5
+ import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials';
6
+ import { NodeRenderer } from '../node-renderer';
7
+ export const StairRenderer = ({ node }) => {
8
+ const ref = useRef(null);
9
+ useRegistry(node.id, 'stair', ref);
10
+ useLayoutEffect(() => {
11
+ useScene.getState().markDirty(node.id);
12
+ }, [node.id]);
13
+ const handlers = useNodeEvents(node, 'stair');
14
+ const material = useMemo(() => {
15
+ const mat = node.material;
16
+ if (!mat)
17
+ return DEFAULT_STAIR_MATERIAL;
18
+ return createMaterial(mat);
19
+ }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]);
20
+ return (_jsxs("group", { "position-x": node.position[0], "position-z": node.position[2], ref: ref, "rotation-y": node.rotation, visible: node.visible, ...handlers, children: [_jsx("mesh", { castShadow: true, material: material, name: "merged-stair", receiveShadow: true, children: _jsx("boxGeometry", { args: [0, 0, 0] }) }), _jsx("group", { name: "segments-wrapper", visible: false, children: (node.children ?? []).map((childId) => (_jsx(NodeRenderer, { nodeId: childId }, childId))) })] }));
21
+ };
@@ -0,0 +1,5 @@
1
+ import { type StairSegmentNode } from '@pascal-app/core';
2
+ export declare const StairSegmentRenderer: ({ node }: {
3
+ node: StairSegmentNode;
4
+ }) => import("react/jsx-runtime").JSX.Element;
5
+ //# sourceMappingURL=stair-segment-renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stair-segment-renderer.d.ts","sourceRoot":"","sources":["../../../../src/components/renderers/stair-segment/stair-segment-renderer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,gBAAgB,EAAyB,MAAM,kBAAkB,CAAA;AAM/E,eAAO,MAAM,oBAAoB,GAAI,UAAU;IAAE,IAAI,EAAE,gBAAgB,CAAA;CAAE,4CA8BxE,CAAA"}
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useRegistry, useScene } from '@pascal-app/core';
3
+ import { useLayoutEffect, useMemo, useRef } from 'react';
4
+ import { useNodeEvents } from '../../../hooks/use-node-events';
5
+ import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials';
6
+ export const StairSegmentRenderer = ({ node }) => {
7
+ const ref = useRef(null);
8
+ useRegistry(node.id, 'stair-segment', ref);
9
+ useLayoutEffect(() => {
10
+ useScene.getState().markDirty(node.id);
11
+ }, [node.id]);
12
+ const handlers = useNodeEvents(node, 'stair-segment');
13
+ const material = useMemo(() => {
14
+ const mat = node.material;
15
+ if (!mat)
16
+ return DEFAULT_STAIR_MATERIAL;
17
+ return createMaterial(mat);
18
+ }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]);
19
+ return (_jsx("mesh", { material: material, position: node.position, ref: ref, "rotation-y": node.rotation, visible: node.visible, ...handlers, children: _jsx("boxGeometry", { args: [0, 0, 0] }) }));
20
+ };
@@ -17,5 +17,5 @@ export const WallRenderer = ({ node }) => {
17
17
  return DEFAULT_WALL_MATERIAL;
18
18
  return createMaterial(mat);
19
19
  }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]);
20
- return (_jsxs("mesh", { castShadow: true, receiveShadow: true, ref: ref, visible: node.visible, material: material, children: [_jsx("boxGeometry", { args: [0, 0, 0] }), _jsx("mesh", { name: "collision-mesh", visible: false, ...handlers, children: _jsx("boxGeometry", { args: [0, 0, 0] }) }), node.children.map((childId) => (_jsx(NodeRenderer, { nodeId: childId }, childId)))] }));
20
+ return (_jsxs("mesh", { castShadow: true, material: material, receiveShadow: true, ref: ref, visible: node.visible, children: [_jsx("boxGeometry", { args: [0, 0, 0] }), _jsx("mesh", { name: "collision-mesh", visible: false, ...handlers, children: _jsx("boxGeometry", { args: [0, 0, 0] }) }), node.children.map((childId) => (_jsx(NodeRenderer, { nodeId: childId }, childId)))] }));
21
21
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/index.tsx"],"names":[],"mappings":"AAYA,OAAO,EAAkB,KAAK,kBAAkB,EAAsB,MAAM,oBAAoB,CAAA;AAEhG,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AA0CrC,OAAO,QAAQ,oBAAoB,CAAC;IAClC,UAAU,aAAc,SAAQ,kBAAkB,CAAC,OAAO,KAAK,CAAC;KAAG;CACpE;AA+BD,UAAU,WAAW;IACnB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,gBAAgB,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAA;IACvC,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,QAAA,MAAM,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,WAAW,CA6DjC,CAAA;AASD,eAAe,MAAM,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/index.tsx"],"names":[],"mappings":"AAaA,OAAO,EAAkB,KAAK,kBAAkB,EAAsB,MAAM,oBAAoB,CAAA;AAEhG,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AA0CrC,OAAO,QAAQ,oBAAoB,CAAC;IAClC,UAAU,aAAc,SAAQ,kBAAkB,CAAC,OAAO,KAAK,CAAC;KAAG;CACpE;AA+BD,UAAU,WAAW;IACnB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,gBAAgB,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAA;IACvC,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,QAAA,MAAM,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,WAAW,CA8DjC,CAAA;AASD,eAAe,MAAM,CAAA"}
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { CeilingSystem, DoorSystem, ItemSystem, RoofSystem, SlabSystem, WallSystem, WindowSystem, } from '@pascal-app/core';
3
+ import { CeilingSystem, DoorSystem, ItemSystem, RoofSystem, SlabSystem, StairSystem, WallSystem, WindowSystem, } from '@pascal-app/core';
4
4
  import { Bvh } from '@react-three/drei';
5
5
  import { Canvas, extend, useFrame, useThree } from '@react-three/fiber';
6
6
  import { useEffect, useMemo, useRef } from 'react';
@@ -74,7 +74,7 @@ const Viewer = ({ children, selectionManager = 'default', perf = false, }) => {
74
74
  }, shadows: {
75
75
  type: THREE.PCFShadowMap,
76
76
  enabled: true,
77
- }, children: [_jsx(ViewerCamera, {}), _jsx(Lights, {}), _jsx(Bvh, { children: _jsx(SceneRenderer, {}) }), _jsx(LevelSystem, {}), _jsx(GuideSystem, {}), _jsx(ScanSystem, {}), _jsx(WallCutout, {}), _jsx(CeilingSystem, {}), _jsx(DoorSystem, {}), _jsx(ItemSystem, {}), _jsx(RoofSystem, {}), _jsx(SlabSystem, {}), _jsx(WallSystem, {}), _jsx(WindowSystem, {}), _jsx(ZoneSystem, {}), _jsx(PostProcessing, {}), _jsx(GPUDeviceWatcher, {}), _jsx(ItemLightSystem, {}), selectionManager === 'default' && _jsx(SelectionManager, {}), perf && _jsx(PerfMonitor, {}), children] }));
77
+ }, children: [_jsx(ViewerCamera, {}), _jsx(Lights, {}), _jsx(Bvh, { children: _jsx(SceneRenderer, {}) }), _jsx(LevelSystem, {}), _jsx(GuideSystem, {}), _jsx(ScanSystem, {}), _jsx(WallCutout, {}), _jsx(CeilingSystem, {}), _jsx(DoorSystem, {}), _jsx(ItemSystem, {}), _jsx(RoofSystem, {}), _jsx(SlabSystem, {}), _jsx(StairSystem, {}), _jsx(WallSystem, {}), _jsx(WindowSystem, {}), _jsx(ZoneSystem, {}), _jsx(PostProcessing, {}), _jsx(GPUDeviceWatcher, {}), _jsx(ItemLightSystem, {}), selectionManager === 'default' && _jsx(SelectionManager, {}), perf && _jsx(PerfMonitor, {}), children] }));
78
78
  };
79
79
  const DebugRenderer = () => {
80
80
  useFrame(({ gl, scene, camera }) => {
@@ -1 +1 @@
1
- {"version":3,"file":"post-processing.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/post-processing.tsx"],"names":[],"mappings":"AA4BA,eAAO,MAAM,WAAW;;;;;;;;;;;;;CAavB,CAAA;AAQD,QAAA,MAAM,oBAAoB,YAkRzB,CAAA;AAED,eAAe,oBAAoB,CAAA"}
1
+ {"version":3,"file":"post-processing.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/post-processing.tsx"],"names":[],"mappings":"AA4BA,eAAO,MAAM,WAAW;;;;;;;;;;;;;CAavB,CAAA;AAQD,QAAA,MAAM,oBAAoB,YAoQzB,CAAA;AAED,eAAe,oBAAoB,CAAA"}
@@ -1,12 +1,12 @@
1
1
  import { useFrame, useThree } from '@react-three/fiber';
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Color, Layers, UnsignedByteType } from 'three';
4
- import { outline } from 'three/addons/tsl/display/OutlineNode.js';
5
4
  import { ssgi } from 'three/addons/tsl/display/SSGINode.js';
6
5
  import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js';
7
6
  import { add, colorToDirection, diffuseColor, directionToColor, float, mix, mrt, normalView, oscSine, output, pass, sample, time, uniform, vec4, } from 'three/tsl';
8
7
  import { RenderPipeline } from 'three/webgpu';
9
8
  import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers';
9
+ import { mergedOutline } from '../../lib/merged-outline-node';
10
10
  import useViewer from '../../store/use-viewer';
11
11
  // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion
12
12
  export const SSGI_PARAMS = {
@@ -45,6 +45,7 @@ const PostProcessingPasses = () => {
45
45
  l.disable(SCENE_LAYER);
46
46
  return l;
47
47
  }, []);
48
+ const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode);
48
49
  // Subscribe to projectId so the pipeline rebuilds on project switch
49
50
  const projectId = useViewer((s) => s.projectId);
50
51
  // Bump this to force a pipeline rebuild (used by retry logic)
@@ -144,49 +145,36 @@ const PostProcessingPasses = () => {
144
145
  // Composite: scene * AO + diffuse * GI
145
146
  sceneColor = vec4(add(scenePassColor.rgb.mul(ao), add(zonePass.rgb, scenePassDiffuse.rgb.mul(gi))), contentAlpha);
146
147
  }
147
- function generateSelectedOutlinePass() {
148
- const edgeStrength = uniform(3);
149
- const edgeGlow = uniform(0);
150
- const edgeThickness = uniform(1);
151
- const visibleEdgeColor = uniform(new Color(0xff_ff_ff));
152
- const hiddenEdgeColor = uniform(new Color(0xf3_ff_47));
153
- const outlinePass = outline(scene, camera, {
154
- selectedObjects: useViewer.getState().outliner.selectedObjects,
155
- edgeGlow,
156
- edgeThickness,
157
- });
158
- const { visibleEdge, hiddenEdge } = outlinePass;
159
- const outlineColor = visibleEdge
160
- .mul(visibleEdgeColor)
161
- .add(hiddenEdge.mul(hiddenEdgeColor))
162
- .mul(edgeStrength);
163
- return outlineColor;
164
- }
165
- function generateHoverOutlinePass() {
166
- const edgeStrength = uniform(5);
167
- const edgeGlow = uniform(0.5);
168
- const edgeThickness = uniform(1.5);
169
- const pulsePeriod = uniform(3);
170
- const visibleEdgeColor = uniform(new Color(0x00_aa_ff));
171
- const hiddenEdgeColor = uniform(new Color(0xf3_ff_47));
172
- const outlinePass = outline(scene, camera, {
173
- selectedObjects: useViewer.getState().outliner.hoveredObjects,
174
- edgeGlow,
175
- edgeThickness,
176
- });
177
- const { visibleEdge, hiddenEdge } = outlinePass;
178
- const period = time.div(pulsePeriod).mul(2);
179
- const osc = oscSine(period).mul(0.5).add(0.5); // osc [ 0.5, 1.0 ]
180
- const outlineColor = visibleEdge
181
- .mul(visibleEdgeColor)
182
- .add(hiddenEdge.mul(hiddenEdgeColor))
183
- .mul(edgeStrength);
184
- const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor);
185
- return outlinePulse;
186
- }
187
- const selectedOutlinePass = generateSelectedOutlinePass();
188
- const hoverOutlinePass = generateHoverOutlinePass();
189
- const compositeWithOutlines = vec4(add(sceneColor.rgb, selectedOutlinePass.add(hoverOutlinePass)), sceneColor.a);
148
+ // Single merged outline node: one shared depth pass for both selected + hovered groups.
149
+ const outliner = useViewer.getState().outliner;
150
+ const outlineNode = mergedOutline(scene, camera, {
151
+ primaryObjects: outliner.selectedObjects,
152
+ secondaryObjects: outliner.hoveredObjects,
153
+ primaryEdgeThickness: uniform(1),
154
+ secondaryEdgeThickness: uniform(1.5),
155
+ });
156
+ // Selected: white visible, yellow hidden
157
+ const selectedVisibleColor = uniform(new Color(0xff_ff_ff));
158
+ const selectedHiddenColor = uniform(new Color(0xf3_ff_47));
159
+ const selectedStrength = uniform(3);
160
+ const selectedOutline = outlineNode.primaryVisibleEdge
161
+ .mul(selectedVisibleColor)
162
+ .add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor))
163
+ .mul(selectedStrength);
164
+ // Hovered: blue visible, yellow hidden, pulsing
165
+ const hoverVisibleColor = uniform(new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff));
166
+ const hoverHiddenColor = uniform(new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47));
167
+ const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5);
168
+ const pulsePeriod = uniform(3);
169
+ const osc = hoverHighlightMode === 'delete'
170
+ ? float(1)
171
+ : oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5); // [ 0.5, 1.0 ]
172
+ const hoverOutline = outlineNode.secondaryVisibleEdge
173
+ .mul(hoverVisibleColor)
174
+ .add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
175
+ .mul(hoverStrength)
176
+ .mul(osc);
177
+ const compositeWithOutlines = vec4(add(sceneColor.rgb, selectedOutline.add(hoverOutline)), sceneColor.a);
190
178
  const finalOutput = vec4(mix(bgUniform.current, compositeWithOutlines.rgb, contentAlpha), float(1));
191
179
  const renderPipeline = new RenderPipeline(renderer);
192
180
  renderPipeline.outputNode = finalOutput;
@@ -206,7 +194,7 @@ const PostProcessingPasses = () => {
206
194
  }
207
195
  renderPipelineRef.current = null;
208
196
  };
209
- }, [renderer, scene, camera, isInitialized, zoneLayers]);
197
+ }, [renderer, scene, camera, hoverHighlightMode, isInitialized, zoneLayers]);
210
198
  useFrame((_, delta) => {
211
199
  // Animate background colour toward the current theme target (same lerp as AnimatedBackground)
212
200
  bgTarget.current.set(useViewer.getState().theme === 'dark' ? DARK_BG : LIGHT_BG);
@@ -0,0 +1,2 @@
1
+ export declare const WalkthroughControls: () => import("react/jsx-runtime").JSX.Element | null;
2
+ //# sourceMappingURL=walkthrough-controls.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"walkthrough-controls.d.ts","sourceRoot":"","sources":["../../../src/components/viewer/walkthrough-controls.tsx"],"names":[],"mappings":"AAeA,eAAO,MAAM,mBAAmB,sDAwH/B,CAAA"}
@@ -0,0 +1,131 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { PointerLockControls } from '@react-three/drei';
4
+ import { useFrame, useThree } from '@react-three/fiber';
5
+ import { useCallback, useEffect, useRef } from 'react';
6
+ import { Vector3 } from 'three';
7
+ import useViewer from '../../store/use-viewer';
8
+ const MOVE_SPEED = 5;
9
+ const EYE_HEIGHT = 1.6;
10
+ const _direction = new Vector3();
11
+ const _forward = new Vector3();
12
+ const _right = new Vector3();
13
+ export const WalkthroughControls = () => {
14
+ const controlsRef = useRef(null);
15
+ const walkthroughMode = useViewer((s) => s.walkthroughMode);
16
+ const keys = useRef({ w: false, a: false, s: false, d: false });
17
+ const camera = useThree((s) => s.camera);
18
+ // Set initial eye height
19
+ useEffect(() => {
20
+ if (walkthroughMode) {
21
+ camera.position.y = EYE_HEIGHT;
22
+ }
23
+ }, [walkthroughMode, camera]);
24
+ // Keyboard handlers
25
+ useEffect(() => {
26
+ if (!walkthroughMode)
27
+ return;
28
+ const onKeyDown = (e) => {
29
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
30
+ return;
31
+ const key = e.key.toLowerCase();
32
+ // ESC exits walkthrough mode completely
33
+ if (e.key === 'Escape') {
34
+ e.preventDefault();
35
+ e.stopPropagation();
36
+ useViewer.getState().setWalkthroughMode(false);
37
+ return;
38
+ }
39
+ if (key === 'w' || key === 'arrowup')
40
+ keys.current.w = true;
41
+ if (key === 'a' || key === 'arrowleft')
42
+ keys.current.a = true;
43
+ if (key === 's' || key === 'arrowdown')
44
+ keys.current.s = true;
45
+ if (key === 'd' || key === 'arrowright')
46
+ keys.current.d = true;
47
+ };
48
+ const onKeyUp = (e) => {
49
+ const key = e.key.toLowerCase();
50
+ if (key === 'w' || key === 'arrowup')
51
+ keys.current.w = false;
52
+ if (key === 'a' || key === 'arrowleft')
53
+ keys.current.a = false;
54
+ if (key === 's' || key === 'arrowdown')
55
+ keys.current.s = false;
56
+ if (key === 'd' || key === 'arrowright')
57
+ keys.current.d = false;
58
+ };
59
+ window.addEventListener('keydown', onKeyDown);
60
+ window.addEventListener('keyup', onKeyUp);
61
+ return () => {
62
+ window.removeEventListener('keydown', onKeyDown);
63
+ window.removeEventListener('keyup', onKeyUp);
64
+ // Reset keys on cleanup
65
+ keys.current = { w: false, a: false, s: false, d: false };
66
+ };
67
+ }, [walkthroughMode]);
68
+ // Release pointer lock when walkthrough mode is turned off
69
+ useEffect(() => {
70
+ if (!walkthroughMode && document.pointerLockElement) {
71
+ document.exitPointerLock();
72
+ }
73
+ }, [walkthroughMode]);
74
+ // Movement loop
75
+ useFrame((_, delta) => {
76
+ if (!(walkthroughMode && controlsRef.current))
77
+ return;
78
+ _direction.set(0, 0, 0);
79
+ // Get camera forward and right vectors (XZ plane only)
80
+ camera.getWorldDirection(_forward);
81
+ _forward.y = 0;
82
+ _forward.normalize();
83
+ _right.crossVectors(_forward, camera.up).normalize();
84
+ if (keys.current.w)
85
+ _direction.add(_forward);
86
+ if (keys.current.s)
87
+ _direction.sub(_forward);
88
+ if (keys.current.d)
89
+ _direction.add(_right);
90
+ if (keys.current.a)
91
+ _direction.sub(_right);
92
+ if (_direction.lengthSq() > 0) {
93
+ _direction.normalize().multiplyScalar(MOVE_SPEED * delta);
94
+ camera.position.add(_direction);
95
+ // Keep eye height constant
96
+ camera.position.y = EYE_HEIGHT;
97
+ }
98
+ });
99
+ const handleClick = useCallback(() => {
100
+ if (walkthroughMode && controlsRef.current) {
101
+ // Feature detection: some browsers (Facebook/Instagram in-app, older Safari)
102
+ // don't support pointer lock on the canvas element
103
+ if (typeof controlsRef.current.lock === 'function') {
104
+ try {
105
+ controlsRef.current.lock();
106
+ }
107
+ catch {
108
+ // Silently ignore — pointer lock unavailable in this browser context
109
+ }
110
+ }
111
+ }
112
+ }, [walkthroughMode]);
113
+ // Click to lock
114
+ useEffect(() => {
115
+ if (!walkthroughMode)
116
+ return;
117
+ const canvas = document.querySelector('canvas');
118
+ if (!canvas)
119
+ return;
120
+ canvas.addEventListener('click', handleClick);
121
+ return () => canvas.removeEventListener('click', handleClick);
122
+ }, [walkthroughMode, handleClick]);
123
+ if (!walkthroughMode)
124
+ return null;
125
+ // Skip PointerLockControls on browsers that don't support pointer lock
126
+ // (Facebook/Instagram in-app browsers, some iOS WebViews)
127
+ if (typeof document !== 'undefined' && !('requestPointerLock' in HTMLElement.prototype)) {
128
+ return null;
129
+ }
130
+ return _jsx(PointerLockControls, { ref: controlsRef });
131
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"use-gltf-ktx2.d.ts","sourceRoot":"","sources":["../../src/hooks/use-gltf-ktx2.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAU3C,QAAA,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,UAAU,CAAC,OAAO,OAAO,CA2B5D,CAAA;AAED,OAAO,EAAE,WAAW,EAAE,CAAA"}
1
+ {"version":3,"file":"use-gltf-ktx2.d.ts","sourceRoot":"","sources":["../../src/hooks/use-gltf-ktx2.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAU3C,QAAA,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,UAAU,CAAC,OAAO,OAAO,CA2B5D,CAAA;AACD,OAAO,EAAE,WAAW,EAAE,CAAA"}