Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3731eb3
Add roof surface placement support for items
sudhir9297 May 18, 2026
ed53bc2
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
fd8e02c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
7c1e383
fixed conflict
sudhir9297 May 20, 2026
b3377da
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
f177a65
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
9af7491
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
fd27524
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 27, 2026
b516298
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 28, 2026
ebfc8ce
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 3, 2026
b7b313b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
b2ad645
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
bffdb4a
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 8, 2026
ed2d4b1
Snapshot: ridge vent locks to segment ridge during placement, free sl…
sudhir9297 Jun 8, 2026
ae5e9c0
feat(roof): contribute outer silhouette to alignment-guide candidates
sudhir9297 Jun 8, 2026
c8b8a03
feat(floorplan): upright text geometry that stays horizontal under sc…
sudhir9297 Jun 8, 2026
c3f4dbc
feat(editor): redesign material-paint cursor badge and gate floor-pla…
sudhir9297 Jun 8, 2026
ed481f8
feat(editor): 90° rotate action on the floating building menu
sudhir9297 Jun 8, 2026
7a09032
feat(column,shelf): show cursor sphere during placement preview
sudhir9297 Jun 8, 2026
f65c8b3
chore(ifc-converter): update next-env types path to .next/dev/types
sudhir9297 Jun 8, 2026
fac7b66
fix(ridge-vent): track parent segment's live overrides for real-time …
sudhir9297 Jun 8, 2026
3eb02fd
feat(editor): step building rotation in 15° increments
sudhir9297 Jun 8, 2026
b50c0d5
feat(editor): world-frame alignment guides and grid snap under buildi…
sudhir9297 Jun 8, 2026
6ef27f9
revert(editor): drop building drag-rotate and grid building-XZ chase
sudhir9297 Jun 9, 2026
6994dce
feat(editor): consistent building rotation pivot + 2D position sync
sudhir9297 Jun 9, 2026
2cdd036
Merge remote-tracking branch 'origin/main' into fix/fir-5-june
sudhir9297 Jun 9, 2026
ee7b10c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 9, 2026
2c29669
Merge remote-tracking branch 'origin/main' into fix/mon-jun-8
sudhir9297 Jun 9, 2026
16797af
feat(wall): alt-click commits a single wall instead of chaining
sudhir9297 Jun 9, 2026
d65fd5f
revert(editor): drop 15° rotate from floating building action menu
sudhir9297 Jun 9, 2026
f507b7c
chore(ifc-converter): point next-env at .next/types instead of .next/…
sudhir9297 Jun 9, 2026
557f4f3
feat(editor): unify 3D move grip with floating-menu Move via tap-to-e…
sudhir9297 Jun 9, 2026
7320e77
feat(editor): 2D alignment guides in building-local frame + world-gri…
sudhir9297 Jun 9, 2026
77e387f
chore(editor): drop dead onRotate slot, memoize dragBounds snapshot
sudhir9297 Jun 9, 2026
945bd36
chore: biome check --write (import order, formatting, unused import)
sudhir9297 Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/ifc-converter/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
22 changes: 22 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ export type FloorplanGeometry =
stroke?: string
strokeWidth?: number
paintOrder?: 'stroke' | 'fill' | 'normal'
/**
* When true, the registry layer counter-rotates the label by
* `sceneRotationDeg` so it reads horizontally on screen regardless
* of the floor-plan's scene rotation (default 90°).
*/
upright?: boolean
}
/**
* Bitmap overlay — captured top-down asset thumbnail, AI-generated
Expand Down Expand Up @@ -981,6 +987,22 @@ export type Capabilities = {
* `AlignmentFootprintConfig`.
*/
alignmentFootprint?: AlignmentFootprintConfig
/**
* Bounds drawn by the 3D drag bounding box during a move. Opt-in: when
* omitted, the box auto-measures the rendered mesh, which is correct for
* most kinds. Set this when the rendered mesh tree contains extras the
* user wouldn't think of as "the thing being dragged" — e.g. an elevator
* whose mesh includes per-level landing assemblies, and the user expects
* the box to wrap just the shaft they're moving.
*
* `size`: `[width, height, depth]` in the node's local frame.
* `centerY`: optional Y center; defaults to `size[1] / 2` (box sits on
* the ground plane). Override when the local origin isn't at the base.
*/
dragBounds?: (
node: AnyNode,
nodes?: Readonly<Record<string, AnyNode>>,
) => { size: [number, number, number]; centerY?: number }
roofAccessory?: RoofAccessoryConfig
/**
* Kind cuts a hole in the ceiling surface it is attached to (e.g. recessed
Expand Down
107 changes: 100 additions & 7 deletions packages/core/src/services/alignment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,108 @@ export type ResolveAlignmentResult = {

const EMPTY: ResolveAlignmentResult = { guides: [], snap: null }

/** Forward rotation: local XZ → world XZ for a node whose parent has
* position `bx,_,bz` and rotation-Y `rotY` (radians). Matches the
* transform used throughout the editor's tools / floor-plan. */
function localToWorld(
x: number,
z: number,
bx: number,
bz: number,
cos: number,
sin: number,
): { x: number; z: number } {
return {
x: bx + x * cos + z * sin,
z: bz - x * sin + z * cos,
}
}

function transformAnchorToWorld(
anchor: AlignmentAnchor,
bx: number,
bz: number,
cos: number,
sin: number,
): AlignmentAnchor {
const w = localToWorld(anchor.x, anchor.z, bx, bz, cos, sin)
return { nodeId: anchor.nodeId, kind: anchor.kind, x: w.x, z: w.z }
}

export type BuildingPose = {
position: readonly [number, number, number]
rotationY: number
}

export type ResolveAlignmentInBuildingResult = {
/** Guides in WORLD coordinates. Renderers must be in a world-space group. */
guides: AlignmentGuide[]
/** Snap delta in the BUILDING-LOCAL frame, ready to add to a local position. */
snap: { dx: number; dz: number } | null
}

/**
* Resolve alignment in WORLD space while accepting BUILDING-LOCAL anchors.
*
* Why this exists: the floor-plan grid lives in world XZ (rendered outside
* the rotated scene group), so alignment must follow the same axes —
* otherwise rotating a building drags the alignment guides off the visible
* grid and onto the rotated wall's local axes (the bug the user hit). The
* resolver itself is frame-agnostic; this wrapper just transforms anchors
* to world, resolves, then rotates the snap delta back into building-local
* so callers can add it to a local position without further math.
*
* `pose === null` → resolve in the caller's frame as-is (no transform).
*/
export function resolveAlignmentInBuildingWorld(input: {
moving: readonly AlignmentAnchor[]
candidates: readonly AlignmentAnchor[]
threshold: number
pose: BuildingPose | null
}): ResolveAlignmentInBuildingResult {
const { moving, candidates, threshold, pose } = input
if (!pose) {
return resolveAlignment({ moving, candidates, threshold })
}
const cos = Math.cos(pose.rotationY)
const sin = Math.sin(pose.rotationY)
const bx = pose.position[0]
const bz = pose.position[2]
const movingWorld = moving.map((a) => transformAnchorToWorld(a, bx, bz, cos, sin))
const candidatesWorld = candidates.map((a) => transformAnchorToWorld(a, bx, bz, cos, sin))
const result = resolveAlignment({
moving: movingWorld,
candidates: candidatesWorld,
threshold,
})
if (!result.snap) return { guides: result.guides, snap: null }
// World → local rotation (orthogonal matrix → transpose). The inverse of
// `localToWorld` above maps (dx_world, dz_world) → (dx_local, dz_local).
const dxW = result.snap.dx
const dzW = result.snap.dz
const dxL = dxW * cos - dzW * sin
const dzL = dxW * sin + dzW * cos
return { guides: result.guides, snap: { dx: dxL, dz: dzL } }
}

export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignmentResult {
const { moving, candidates, threshold } = input
if (threshold <= 0 || moving.length === 0 || candidates.length === 0) return EMPTY

// Best match per axis: smallest |Δ| on the matched axis (tightest
// alignment), then — crucially — tie-break to the candidate anchor NEAREST
// on the perpendicular axis. Anchors are real points (corners / endpoints /
// midpoints), so the guide always connects to the closest actual point of
// the candidate, never a far one that merely shares the same coordinate.
// Best match per axis: among all candidate anchors within `threshold` of
// the moving anchor on the matched axis, pick the one CLOSEST in the
// perpendicular direction — so the guide always connects to the visually
// nearest actual point of the candidate. Primary delta only breaks perp
// ties.
//
// Why perp-first: a wall pre-rotation contributes anchors that share an
// exact X (vertical wall) or Z (horizontal wall), so primary deltas tie
// and perp picks the nearer endpoint. Post-rotation, the same wall's
// anchors are at slightly-different world coordinates after a float
// rotation — primary deltas differ by tiny amounts and a primary-first
// tie-break would lock onto whichever happens to be marginally tighter,
// often the far endpoint. Perp-first keeps the "closest point of
// reference" behaviour stable through rotation.
type Best = {
delta: number
primary: number
Expand All @@ -102,13 +195,13 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment
const adz = Math.abs(dz)
if (
adx <= threshold &&
(bestX === null || adx < bestX.primary || (adx === bestX.primary && adz < bestX.perp))
(bestX === null || adz < bestX.perp || (adz === bestX.perp && adx < bestX.primary))
) {
bestX = { delta: dx, primary: adx, perp: adz, m, c }
}
if (
adz <= threshold &&
(bestZ === null || adz < bestZ.primary || (adz === bestZ.primary && adx < bestZ.perp))
(bestZ === null || adx < bestZ.perp || (adx === bestZ.perp && adz < bestZ.primary))
) {
bestZ = { delta: dz, primary: adz, perp: adx, m, c }
}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ export {
type AlignmentGuide,
type AlignmentGuideAxis,
type AnchorKind,
type BuildingPose,
bboxAnchors,
bboxCornerAnchors,
type ResolveAlignmentInBuildingResult,
type ResolveAlignmentInput,
type ResolveAlignmentResult,
resolveAlignment,
resolveAlignmentInBuildingWorld,
} from './alignment'
export {
collectAlignmentAnchors,
Expand Down Expand Up @@ -56,4 +59,5 @@ export {
snapScalar,
snapServices,
snapVec3ToGrid,
snapWorldXZToBuildingLocal,
} from './snap'
47 changes: 47 additions & 0 deletions packages/core/src/services/snap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,53 @@ export function snapVec3ToGrid(point: Vec3, step: number = DEFAULT_GRID_STEP): V
return [snapScalar(point[0], step), point[1], snapScalar(point[2], step)]
}

/**
* Snap a world XZ point to the grid, then express it in the local frame of
* a building positioned at `buildingPosition` with rotation `buildingRotationY`
* (radians, around the Y axis). Returns both the snapped world point and its
* local-frame equivalent, so callers can render in either frame without
* recomputing the rotation.
*
* Use when a tool needs to keep snapping on the world grid (the grid the
* editor renders) even when the active building is rotated. Snapping in the
* building's local frame would otherwise chase the rotated axes and miss
* the visible grid lines.
*/
export function snapWorldXZToBuildingLocal(
worldX: number,
worldZ: number,
buildingPosition: Vec3,
buildingRotationY: number,
step: number = DEFAULT_GRID_STEP,
): { world: [number, number]; local: [number, number] } {
if (step <= 0) {
const dx = worldX - buildingPosition[0]
const dz = worldZ - buildingPosition[2]
const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY)
return {
world: [worldX, worldZ],
local: [dx * cos - dz * sin, dx * sin + dz * cos],
}
}
const snappedWX = Math.round(worldX / step) * step
const snappedWZ = Math.round(worldZ / step) * step
const dx = snappedWX - buildingPosition[0]
const dz = snappedWZ - buildingPosition[2]
const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY)
// The forward (local → world) rotation used in the editor is
// wx = bx + lx*cos + lz*sin
// wz = bz - lx*sin + lz*cos
// so the inverse (orthogonal, so transpose) is
// lx = dx*cos - dz*sin
// lz = dx*sin + dz*cos
return {
world: [snappedWX, snappedWZ],
local: [dx * cos - dz * sin, dx * sin + dz * cos],
}
}

// ─── Angle snap ───────────────────────────────────────────────────────

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
'use client'

import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { memo } from 'react'
import useAlignmentGuides from '../../store/use-alignment-guides'
import { formatMeasurement } from '../editor/measurement-pill'
import { useFloorplanRender } from './floorplan-render-context'

/**
* Figma-style alignment guides for the 2D floor plan.
*
* Subscribes to `useAlignmentGuides` — populated by
* `FloorplanRegistryMoveOverlay` (Path 2) during a generic free-translate
* drag. Each guide renders as a red line between the moving and matched
* candidate anchors with small `×` end-caps. A distance pill is drawn at
* the line's midpoint when the perpendicular gap is non-zero.
* Subscribes to the editor-local `useAlignmentGuides` store (separate
* from the core store the 3D layer reads). Guides come in
* building-local meters, so the layer is mounted INSIDE the rotated
* `<g data-floorplan-scene>` — the SVG transform that takes the rest
* of the floor-plan geometry from local → screen carries the guide
* lines too. Pill labels are counter-rotated by `sceneRotationDeg`
* (from `FloorplanRenderProvider`) so they stay upright even when the
* scene `<g>` is rotated by building rotation.
*
* Stroke widths and handle radii are scaled by `unitsPerPixel` so they
* stay a constant size on screen no matter the zoom. Text labels are
* counter-rotated by `sceneRotationDeg` so they read upright even when
* the building rotation rotates the scene `<g>`.
* Each guide renders as a red line between the moving and matched
* candidate anchors with small `×` end-caps. A distance pill is drawn
* at the midpoint when the perpendicular gap is non-zero.
*
* Mounted inside the `data-floorplan-scene` group so coordinates match
* world meters 1:1 with the rest of the floor plan.
* Stroke widths and handle radii are scaled by `unitsPerPixel` so they
* stay a constant size on screen no matter the zoom.
*/
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
return { kind: 'icon', icon: 'mdi:trash-can-outline' }
}

if (mode === 'material-paint') {
return { kind: 'asset', iconSrc: '/icons/paint.png' }
}

return null
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import {
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../lib/sfx-bus'
import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor from '../../store/use-editor'
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'

Expand Down Expand Up @@ -464,6 +464,11 @@ export function FloorplanRegistryMoveOverlay() {
movingLocalBBox.x + movingLocalBBox.width + dxProposed,
movingLocalBBox.y + movingLocalBBox.height + dzProposed,
)
// Local-frame resolve (anchors come from the building-local
// SVG `getBBox()`). Guides land in the editor-local alignment
// store, which the 2D FloorplanAlignmentGuideLayer renders
// inside the rotated scene <g>. The 3D pipeline uses a
// separate store, so frames stay isolated per surface.
const result = resolveAlignment({
moving: movingAnchors,
candidates: candidateAnchors,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1596,6 +1596,34 @@ function InteractiveGeometry({
</g>
)
}
case 'text': {
if (!g.upright) return <FloorplanGeometryRenderer geometry={g} key={keyHint} />
// Counter-rotate by the scene rotation so the label reads
// horizontally on screen even when the floor-plan view is
// rotated (default `sceneRotationDeg` is 90°).
return (
<g key={keyHint} transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}>
<text
dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={g.fill ?? '#171717'}
fontFamily={g.fontFamily}
fontSize={g.fontSize}
fontWeight={g.fontWeight}
opacity={g.opacity}
paintOrder={g.paintOrder}
stroke={g.stroke}
strokeLinecap={g.stroke ? 'round' : undefined}
strokeLinejoin={g.stroke ? 'round' : undefined}
strokeWidth={g.strokeWidth}
textAnchor={g.textAnchor ?? 'start'}
x={0}
y={0}
>
{g.text}
</text>
</g>
)
}
default:
return (
<FloorplanGeometryRenderer
Expand Down
Loading
Loading